본문으로 건너뛰기
Y

dsh-blackjack

yul761/dsh-blackjack

社区福利小游戏插件:在 dsh 里玩 21 点赢取可消费的模型额度

설치

dsh plugin --profile web add github:yul761/dsh-blackjack

README

dsh-blackjack

⚠️ 社区第三方项目声明

本项目由个人开发者发布与运营,与任何模型厂商均无关联:不是任何厂商的官方 项目、不代表其立场、未获其背书或授权,也不使用其名称与标识作为本项目的品牌 元素。项目名中的 dsh- 前缀仅遵循社区插件的命名惯例,用于标识兼容目标。

计量代理转发到的上游是由运营者自行配置的 OpenAI 兼容端点,具体选用哪家 服务由部署本项目的人自己决定。

一个面向 dsh 社区的开源福利小游戏:开发者在自己的 dsh 里 玩 21 点,赢取由运营者出资的游戏币 CHIP;CHIP 可单向兑换为「已兑换额度」, 额度只能通过本项目的计量代理消费。核心体验是"跑任务没额度了?打两把续命"。

仓库包含两部分:

  • 插件(安装进开发者各自的 dsh):牌桌 UI、余额/兑换交互、无感续命路由。
  • 中心服务(本目录 packages/server,运营者自托管):牌局权威、四桶守恒账本、 计量代理、周期结算任务。

参与规则(重要,请先读)

  • 参与永远免费。 每日固定几手免费牌,零成本参与;不存在也永远不会存在 "付费换手数 / 换概率 / 换赔率"的任何设计。
  • 游戏币单向、封闭。 CHIP 与已兑换额度:
    • 不可提现、不折算任何法定货币、界面不显示金额;
    • 不可转让(含玩家之间转让)、不可退换;
    • 不会进入任何模型厂商的账户
    • 唯一用途是通过本项目的计量代理消费——除此之外没有任何出口。
  • 有时效。 已兑换额度有效期至本赛季末(含宽限期);连续 30 天无游戏且无消费 的账户余额会被回收进奖池。这两条在插件内会提前提醒。
  • 玩家自己的 API key 永远不进入本系统:不托管、不代管、不作为任何游戏内资源。
  • 积分只是排序与荣誉货币,对 CHIP 没有固定汇率。

安装插件

开发者在自己的 dsh 里体验游戏,装的是 packages/plugin(npm 包名 dsh-blackjack),不需要碰服务端代码:

dsh plugin --profile web add -w dsh-blackjack
dsh --profile web

web 换成你实际要装的 profile 名;-w 是必须的,profile 目录本身是一个 pnpm 工作区,缺了这个参数 pnpm 会拒绝安装。插件自己的完整说明——命令表、配置 项、续命路由的触发条件、参与规则、卸载方式——见 packages/plugin/README.md

合规与联系方式

本项目按《生成式人工智能服务管理暂行办法》的义务制要求运行:开放注册、逐请求 记录可追溯日志、内置可即时停服的总开关。

  • 合规问询、下架请求、滥用举报:<运营者联系邮箱占位:请在部署前替换>
  • 收到监管或上游要求时,运营者会用总开关(见下文 runbook)即时停服并回复。

快速开始

环境变量

复制 .env.example.env.env 已在 .gitignore 中,切勿提交):

变量说明
DATABASE_PATHSQLite 文件路径。容器内默认 /data/app.db(挂载卷上)。
PORTHTTP 端口,默认 8787
ADMIN_TOKEN管理端点 /admin/* 的凭证(请求头 X-Admin-Token)。不设则所有管理端点一律 403。
DEEPSEEK_API_KEY上游 OpenAI 兼容端点的 API key(变量名沿用代码中的既有名称)。只存在于服务端环境变量。
DEEPSEEK_BASE_URL上游 base URL,代理会向 ${BASE_URL}/chat/completions 转发。
GITHUB_CLIENT_IDGitHub OAuth App 的 client_id(公开值,用来拼配对页的 authorize 跳转链接)。兑换必需。
GITHUB_CLIENT_SECRET同一个 OAuth App 的 client_secret,只在服务端用授权码换 access token 时使用,绝不出现在任何响应或日志里。兑换必需。
PUBLIC_URL本服务的对外可访问 base URL(不带尾部斜杠),用于拼配对码的 /pair/<code> 链接。兑换必需,生产环境必须设成真实域名。

兑换配置自检(缺一即停兑换,不停服):上面标了「兑换必需」的三个变量少任何一个, 兑换流程都会产出一条看起来成功、实际不可用的链接——PUBLIC_URL 缺失会给每个 玩家发 http://localhost:8787/pair/<code>GITHUB_CLIENT_ID 缺失会拼出 authorize?client_id=&…GITHUB_CLIENT_SECRET 缺失会让回调换 token 必然失败。 因此服务端在启动时会打一条醒目告警列出缺失项,并且 POST /api/pairing/start 一律返回 503(响应体带 missingConfigoperatorHint),绝不发一个废码出去。 游戏本身(发牌、动作、余额)与计量代理不依赖这三个变量,照常工作。

本地运行

pnpm install
pnpm --filter @dsh-blackjack/server test        # 全量测试
pnpm --filter @dsh-blackjack/server typecheck
pnpm dev                                        # tsx watch

Docker

docker build -t dsh-blackjack -f packages/server/Dockerfile .
docker run -p 8787:8787 -v "$PWD/data":/data -e ADMIN_TOKEN=... -e DEEPSEEK_API_KEY=... dsh-blackjack

镜像以非 root 用户 node(uid 1000)运行,数据库默认落在 /data

Railway 部署

  1. 新建服务指向本仓库。仓库根的 railway.json 已把构建器指到 packages/server/Dockerfile,无需在面板里再设。
  2. 必须挂载持久卷到 /datarailway volume add --mount-path /data)。没有卷 时数据库会写进容器可写层,每次重新部署都会静默丢掉全部玩家余额DATABASE_PATH 默认已指向 /data/app.db
  3. 卷的属主是 root,会盖掉镜像构建期的 chown。本仓库的 entrypoint 已处理:容器 以 root 启动,只把数据目录交给 node,随即 setpriv 降权再 exec 应用——不要 改用 Railway 文档建议的 RAILWAY_RUN_UID=0,那会让整个应用跑在 root 下,废掉 非 root 加固。
  4. PORT 要显式设成 8787,或把服务域名的目标端口改成 Railway 注入的 PORT(默认 8080)。两者不一致时平台会显示 Online 但对外一律 502。
  5. 配置上面那几个环境变量,然后用 /api/health 确认存活。缺 GitHub 那两个变量时 服务照常启动,只有 POST /api/pairing/start 返回 503 并在响应里列出缺哪几项。

以上第 2–4 点都是本项目首次真实部署时踩到的:Railway 的构建器会直接拒绝 Dockerfile 里的 VOLUME 指令,持久化必须走平台侧的卷。

单实例约束(重要)

本服务只能以单进程运行,且该进程独占那一个 SQLite 文件。 以下几条安全性都 依赖"同一进程内的串行写事务":

  • 每日免费手数上限、"同一时刻只能有一局进行中"的检查;
  • 计量代理的单玩家并发上限与每分钟频率上限;
  • 四桶账本的原子转移。

横向扩容(多副本 / 多进程 / 卷共享)会让这些检查各算各的,直接击穿奖池。要扩容 必须先把账本迁到支持真正跨进程事务的存储上,这不在 v1 范围内。

身份绑定:设备码配对 + GitHub OAuth 授权码流

兑换需要绑定 GitHub 账号,一个 GitHub 身份只能对应一个玩家钱包:已绑定的玩家 不能改绑(403),且某个 GitHub id 一旦被认领就永久归属该玩家(换人再绑 409)。 这是奖池出口唯一的反小号闸门,绑定规则本身不变。

绑定走完整的 GitHub OAuth 授权码流,取代了早期"拿访问令牌直接调 GitHub 用户 接口"的做法(那种做法不校验令牌签发方,为其他应用签发或从开发机泄漏的令牌也能 拿来绑定——这个口子已经关闭):

  1. 插件调用 POST /api/pairing/start(玩家鉴权),拿到一个短 TTL(默认 900 秒,pairing_ttl_seconds)的一次性配对码和一条 ${PUBLIC_URL}/pair/<code> 链接,提示用户在浏览器里打开。

  2. 用户在浏览器打开该链接:这一步会在浏览器上种下一枚 HttpOnly、 SameSite=Lax 的绑定 cookie,页面上会先展示一条警示(这会把你的 GitHub 账号绑到一个钱包上,且不能改绑;只有这个链接是你自己生成的才继续,别人发 来的链接直接关掉),然后才是"用 GitHub 继续"按钮,跳转到 GitHub 官方 authorize 页面(state 参数即配对码)。

  3. GitHub 把用户带回 GET /auth/github/callback,服务端先核对请求带的 cookie 与该配对码绑定时记下的哈希是否一致(不一致或缺失一律 403、不写库),再用 只有它自己持有GITHUB_CLIENT_SECRET 把授权码换成 access token 取用 户身份——全程不经过插件或用户手上的任何令牌,因此不存在令牌来源不可信的 问题。

    这枚 cookie 能防住什么、防不住什么要说清楚:它证明"完成 OAuth 回调的 浏览器"就是"打开过我们自己 /pair/<code> 页面的那个浏览器"——堵住的是攻 击者绕开这个页面、直接伪造一条 GitHub authorize 链接或裸调用回调地址、让 受害者的浏览器在从没接触过我们域名的情况下完成绑定这一种手法。它防不住 "诱导受害者亲自打开攻击者生成的 /pair/<code> 链接、亲眼看到我们自己的页 面、亲手点击'用 GitHub 继续'"这种情况——受害者的浏览器会正常拿到这枚 cookie 并顺利通过校验,其 GitHub 身份依然会绑到攻击者的钱包上,且一号一钱 包规则下不可撤销。真正针对后一种手法的缓解是上面第 2 步里那条页面内警示: 把"这会绑定你的身份、只有自己生成的链接才该继续"这件事在点击按钮之前明确 摆出来,靠用户自己判断链接来源,而不是靠 cookie(cookie 在这个场景里本来 就无能为力——它只证明"这个浏览器点过我们的页面",证明不了"这个浏览器的主 人是配对码的生成者")。

  4. 校验通过后写入绑定,同时把这个配对码轮换成一枚全新的码(原码作废, 经过 GitHub 和浏览器历史的码值不再代表任何能力),302 跳到新码的配对页, 在这个新页面上提交兑换表单(POST /pair/<新码>/exchange)。兑换成功后 这枚码同样立刻作废——同一个码不能被拿去反复兑换。

未知、过期、已经用掉的配对码一律 404,互不区分,避免枚举。

绑定之后就不必再走这一趟。 上面这套浏览器流程存在的唯一理由是 GitHub 的 授权码流只能在浏览器里完成,而绑定只需要一次。玩家绑定后,插件用它自己持有的 玩家令牌直接调 POST /api/exchange(要求已绑定,否则 403 github binding required)就能兑换,对应命令是 /blackjack exchange <数量>。两条路径共用同一 套门槛与账本校验:未达门槛 403 below threshold,超出余额 402,两者都不动账本。


运维 runbook

所有可调参数都在数据库的 config 表里(不是硬编码,也不是环境变量),通过管理 端点读写,改完即时生效(max_body_bytes 例外,需重启进程):

# 查看账本快照(四桶 + 守恒结论 + 总开关状态)
curl -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/snapshot

# 改任意配置项(键必须是已知配置项)
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"key":"free_hands_per_day","value":"3"}' https://<host>/admin/config

总开关(kill switch)

curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"on":true}' https://<host>/admin/killswitch
  • 打开后计量代理立刻对所有请求返回 503(游戏与查询接口不受影响),插件会回退到 玩家自有配置。
  • 它同时是合规响应预案:收到监管或上游要求时先拉闸再处理。
  • 以下情况系统会自动拉闸:单小时消耗超过 breaker_hourly_micro;每日对账发现 四桶守恒不成立;每日对账发现"日志记录的成本"与"账本实际消耗"对不上。
  • 排障后手动 {"on":false} 复位;复位前先看最近一条 reconciliation_reports

周期任务

任务触发作用
reconcile每日 00:05四桶守恒断言 + 日志/账本交叉校验 + 按模型消耗分解,写入 reconciliation_reports;不通过则自动拉闸
孤儿预扣清扫每小时 + 启动时释放超过 1 小时未结算的预扣,退还余额并释放并发额度
recycle每日 00:15连续 inactive_recycle_days 天无游戏且无消费的账户余额回池
season仅手动赛季结算,见下

手动触发:curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/jobs/<name>

赛季轮换

  1. 先拉总开关,停下新的消耗。
  2. /admin/jobs/reconcile,确认 ok: true(不通过就别继续,先查账)。
  3. /admin/jobs/season:未过兑换门槛的游戏余额按 season_carryover_ratio 折算为积分并回池;过线余额结转。已兑换额度不动,其宽限期由不活跃回收兜底。
  4. 按当时的上游价目表重新锁定本赛季汇率:更新 chip_rate_microprice_table
  5. 更新 season_id(以及可选的 sponsor_text),然后关掉总开关。

日常巡检

  • /admin/snapshotok 必须恒为 true;为 false 属停机级问题。
  • reconciliation_reports 最新一条里的 consumptionCrossCheck.ok 同样必须为真。
  • 最后一道防线是独立的上游预算账号:一切防线失效时,最坏结局也只是当期活动 提前结束。

许可

MIT,见 LICENSE。服务端开源是本项目的核心资产:账本与代理的每一行 计价逻辑都可审计。

관련 플러그인