阶段0 三份 feature-design-doc 过对抗双评审+主控终审;WU2 按 S0 实测改预置池机制。 ops 脚本离线预建 new-api user+token+¥100 灌 game-cloud 池表。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 KiB
date, topic, status, sot-impact, 上级, 依赖, 关联, 图清单
| date | topic | status | sot-impact | 上级 | 依赖 | 关联 | 图清单 | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-07-07 | newapi-per-user-quota | 草案 | 修订「生成引擎运行时」(§6.1 job 契约新增 userToken 位、§6.5 新增 quota_exhausted 失败因);修订「数据模型」(新增额度池表 newapi_quota_pool——离线预建 FREE 条目 + 注册 claim 绑定);关联「鉴权与权限」(不改注册路,仅消费 WU1 afterCommit 发出的「注册成功」事件从池 claim 一个条目绑定,运行时不调 new-api);修订「契约#6 dify-workflow-io.json」与「aigc.yaml FailureReason」(跨契约共享枚举同批新增 quota_exhausted,须与 WU3 协调)。刻意不改「变现端到端」——本设计对接 new-api 网关额度,不在 game-cloud 自建 credits 钱包。 | docs/mvp/MVP作战清单.md | docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md(WU1 §3.6 是唯一注册接缝:afterCommit 发「注册成功」领域事件/RocketMQ 消息,载荷 userId/registerChannel/anonId。本设计是该事件的消费者,不在注册事务内挂点;WU1 迁移取 V31,本设计顺延取 V32) |
|
|
内测 new-api per-user ¥100 生成额度接入 · 功能设计
0 一图看懂
内测阶段每个用户拿 ¥100 生成额度。这笔额度不在 game-cloud 里记一本自己的账,而是直接落在 new-api 网关上——权威余额闸 = 用户 token 的 tokens.remain_quota(预建时挂 ¥100 折算值、unlimited_quota=false),权威消耗 = 该 user 的 users.used_quota(网关每调一次 LLM 逐笔累加);users.quota 同额设一份保持账户自洽,但触发耗尽 402 的那个字段是 token 的 remain_quota。做法分两段:离线由一个 ops 脚本在 mini-infra 一次性预建 N 个专属 (user + token + ¥100),把条目写进 game-cloud 的一张额度池表(S0 实测坐实 new-api 的 admin 令牌无法替他人建 token、只能直连它的 postgres 才能 per-user 开户,故 provision 全部离线做、绝不进运行时);运行时消费 WU1 注册成功后 afterCommit 发出的事件,从池里 CAS claim 一个 FREE 条目绑给该用户,这是 game-cloud 内部一条 SQL、零 new-api 外呼。生成任务派发时把这个用户 claim 到的 token 随 job 带到 worker,worker 用它去调网关,网关按这把 token 扣该用户的额度。额度花完,网关直接拒,用户看到一句「生成额度已用完」。
核心思想:new-api per-user quota 当账本;provision 离线预置池,运行时只 claim 一个条目 + 生成扣额,零运行时 admin 耦合。 边界:不建钱包 / 不自助充值续额 / 运行时不调 admin API。新失败面=池空(懒 claim + 水位告警 + 补池)。成功=真机五验。
三句话:核心思想——把 new-api 的 per-user quota 直接当额度账本(权威余额=token remain_quota、权威消耗=user used_quota),额度由离线 ops 脚本预建成池、注册时消费 WU1「注册成功」事件从池 CAS claim 一个条目绑玩家、生成时随 job 带该 token 扣额、耗尽网关直接拒。边界——只接网关额度,不在 game-cloud 建 credits 钱包、不做用户自助充值也不做运营续额闭环、运行时不调 new-api admin API(provision 全离线)、权威 logs.quota 对账延后;per-user 门只对真实 member 在线创作生效,编排器/bake-off/系统触发旁路走全局 key。怎么算成功——真机五验:ops 脚本预建 N 条池(new-api users/tokens 新增、每 token remain_quota=¥100 折算值);注册后从池 claim 一条 FREE→CLAIMED 绑该玩家;一次真 gen 后该 user used_quota 增;余额耗尽后 gen 被网关干净拒且回传可读拒因;池空时注册仍成功、水位告警、首次生成懒 claim 兜。
1 意图与目标
内测要给种子创作者发生成额度,防止有人无限刷生成把网关额度烧穿,同时给「花了多少、还剩多少」一个权威口径。创始人已拍定这条口径不是在后端自建一套 credits 钱包,而是复用 new-api 网关本就有的 per-user 额度体系:每个内测用户 ¥100,消耗由网关权威计量。至于这批 per-user 账户怎么建,S0 实测坐实了一条硬约束——new-api 的 admin 令牌能建用户、设 quota,却不能替他人建 token(建 token 严格自认证、access_token 也不可经 API 设),任何 per-user token 都得直连它的 postgres 才能造出来。据此创始人拍定:provision 不进 game-cloud 运行时,而是离线由一个 ops 脚本一次性预建一批 (user+token+¥100) 成池,运行时注册只从池里 claim 一个绑给用户——零运行时 admin 调用、零 game-cloud↔new-api-postgres 耦合。
选这条路的理由是它把「计量」这件最容易算错、最需要和真实扣费对齐的事,交给了唯一真正扣费的那一层。网关调用一次 LLM、按 token 数和模型倍率扣一次 quota,used_quota 就是逐笔累加的真账;后端若自建钱包,就得把网关的扣费公式(含缓存折扣、模型倍率、分组倍率)在 Java 侧再实现一遍并保持同步,这正是 wg1/gen-worker/worker/cost.py 反复踩过的对账坑——它自己都在注释里写明「精确 logs.quota 仍需网关 access token 读 /api/log/self,sk- key 无管理权」。把账本收敛到网关,这条线只需做「离线预置 token、运行时把对的 token 递到对的调用上」,不碰计量。
目标锁三条,全部可真机取证:
- 每个注册用户从预置池 claim 到一个专属 user + 专属 token,token
remain_quota= ¥100 按网关口径折算的 quota 值(¥100 由 ops 脚本预建时一次性设好)。 - 每次生成消耗都落在该用户自己的 token 上,网关
used_quota随之增长,账目可查。 - 额度耗尽后生成被网关拒绝,后端把它翻译成用户可读的「生成额度已用完」,而不是一个含糊的「生成失败」。
2 边界
做:一个离线 ops 预置脚本(宿主 mini-infra,用 new-api 管理凭据 + 直连其 postgres:建 user / DB 写 access_token / 以该用户身份建 token / 设 quota),一次性预建 N 个 (user + token + ¥100) 并把条目写进 game-cloud 池表;game-cloud 维护一张额度池表(预建条目 status FREE/CLAIMED + claim 绑定字段);消费 WU1 afterCommit 发出的「注册成功」事件,在 WU2 自己的事务线上从池做一次幂等、CAS 的 claim(从 FREE 抢一个绑 game_player,不进注册事务、渠道过滤);给生成 job 的 §6.1 契约加一个 userToken 位并让三个 worker 入口从「读全局 env key」改成「优先读 job 上的 token」;给失败因枚举加一个 quota_exhausted,且按 contract-first 同批改 aigc.yaml 与 dify-workflow-io.json 两处共享枚举让耗尽能干净回传。
不做:不在 game-cloud 运行时调 new-api admin API、不做运行时开户——provision 全部离线由 ops 脚本完成,运行时只对池表做 claim,零 game-cloud↔new-api-postgres 运行时耦合;不在 game-cloud 建 credits/钱包表,不做扣费公式的二次实现,不做用户自助充值 / 支付 / 提现,也不做运营续额闭环(这些属「变现端到端」,本设计刻意不碰;每个池条目 ¥100 由 ops 脚本预建时一次性设好,运行时绝不改 quota、绝不对已 claim/已消耗条目重设额度);不做权威 logs.quota 对账(需管理 token 读 /api/log/self,明确延后到有需求时再接);不改注册路本身(只挂事件消费者,passport 不感知 WU2);不改现有 bake-off / spike / 编排器批跑的行为——这些触发没有 game_player 成员身份,dispatch 层的 per-user 门只对真实 member 生效,系统/编排触发在派发时按「无成员身份」直接旁路、回落全局 key(判据见 §3.6,堵在 dispatch 层而非 worker 层)。
flowchart TB
subgraph IN["做(本设计闭环 · 六接线点 A-F)"]
A["A 管理凭据<br/>ops 脚本用·不进运行时"]
B["B 离线 ops 预置脚本<br/>预建 N×(user+token+¥100)"]
C["C 注册 claim 消费者<br/>从池 CAS 绑定·渠道过滤"]
D["D 额度池表<br/>FREE/CLAIMED 条目"]
E["E job 加 userToken 位<br/>§6.1 契约追加字段"]
F["F worker 取 job token<br/>缺失回落全局 key"]
end
subgraph OUT["不做(越界,明确挡住)"]
X["运行时调 new-api admin API / 运行时开户"]
W["credits 钱包 / 扣费公式二次实现"]
R["用户自助充值 / 支付 / 提现 / 续额"]
L["权威 logs.quota 对账(延后)"]
S["改 spike/bake-off 全局 key 路"]
end
IN -.->|边界| OUT
3 方案
3.1 六个接线点如何串成一条数据流
额度供给分两段:离线预置(ops 脚本预建池,接线点 A·B·D)与运行时(注册 claim、生成扣额、耗尽拒,接线点 C·E·F)。下图是事实源,逐点的字段契约在其后展开。
flowchart LR
subgraph OFFLINE["离线预置(ops 脚本 · A·B·D)"]
OPS["ops 预置脚本<br/>建户 API→DB 写 access_token<br/>→以该用户建 token API→设 quota API"]
OPS -->|凭据 A + postgres 脏活| NAP["new-api:预建 N×(user+token+¥100)"]
NAP -->|脚本回写条目 FREE| POOL["额度池表 newapi_quota_pool<br/>N 条 FREE (D)"]
end
U[用户] -->|注册| REG["registerPlayer(WU1)<br/>注册事务提交"]
REG -->|同事务| PL["game_player 落库"]
REG -.->|afterCommit 发事件| EVT["注册成功事件/RocketMQ<br/>userId·registerChannel·anonId"]
EVT -->|WU2 消费(C)· 渠道过滤| CLAIM["从池 CAS claim 一个 FREE<br/>绑 game_player·写 biz_no"]
CLAIM -->|抢占 FREE→CLAIMED| POOL
CLAIM -.->|池空| ALERT["不抛·水位告警<br/>懒 claim 兜(§3.6)"]
U -->|创作生成| GEN["dispatchGeneric<br/>组 §6.1 job (E)"]
GEN -->|creatorUserId 是否为 member?| MEMB{有 game_player 行}
MEMB -->|否 系统/编排/bake-off| GLOB["旁路:回落全局 key<br/>不懒 claim"]
MEMB -->|是 真实 member| LOOK["查池表该玩家 CLAIMED 条目"]
LOOK -->|命中| TOK["job.userToken=token_key"]
LOOK -->|无 CLAIMED 条目| LZ["懒 claim 单 CAS 抢一次<br/>池空→干净失败+水位告警"]
LZ -->|抢到| TOK
TOK -->|job.userToken| WK["worker (F)"]
WK -->|用 job.userToken 调网关| NA["new-api 网关"]
NA -->|扣该 user 额度| LEDGER["token remain_quota 减<br/>user used_quota 增"]
NA -.->|余额耗尽 402/403| FAIL["quota_exhausted 干净回传"]
3.2 A · 管理凭据:S0 已实测坐实
现有那把共享 NEWAPI_KEY(sk-84GA...,见 docs/内网凭据与端点.md new-api 段)是一把普通调用 token,没有管理权——它能调 /v1/chat/completions,但建不了 user、改不了 quota。ops 预置脚本要建 user、设 quota,必须用 new-api 的管理凭据。S0(2026-07-07,真机亲验、测试用户已清理)把这套管理面全部坐实,不再是假设:
- root 与令牌:root 用户 =
id=1qingse(role=100、status=1),持 32 字符 system access_token(存 new-api postgresusers.access_token)。 - 鉴权头:
Authorization: Bearer <access_token>且New-Api-User: 1(缺New-Api-User头即 401)。这是 New API 分支(buildfd3e95ac2902-20260527,非 one-api)。 - 端点(尾斜杠必须,gin
RedirectTrailingSlash,否则 307):GET /api/user/?p=0&page_size=N列用户、POST /api/user/{username,password,display_name}建用户、PUT /api/user/{id,username,display_name,quota,group}设 quota、GET /api/token/?p=0列 token(tokens表有name列,可按 user+name 建/查 token)。 - 折算:
quota_per_unit=500000、usd_exchange_rate用默认7.3(DB options 未覆盖),¥100 grant_quota =round(100/7.3×500000) ≈ 6,849,315(回读落值确认)。
这套管理凭据只给离线 ops 预置脚本用,不进 game-cloud 运行时(运行时零 admin API 调用);令牌存进 docs/内网凭据与端点.md(「new-api 管理令牌」节),不进 env-only(遵守协议 §9 内网密钥进项目文档)。
关键约束(S0 坐实,正是它决定了 provision 走离线预置池):admin 令牌不能替他人建 token——POST /api/token/ 严格自认证(New-Api-User 必须等于令牌属主,否则 401「与登录用户不匹配」;body.user_id 被忽略、token 落到 root 名下),且 access_token 不可经 PUT /api/user/ 设(回读 empty)。因此任何 per-user token 都得直连 new-api 的 postgres(infra-postgres:5432/new-api)才能造出来,纯 HTTP API 做不到。这把「碰 postgres 的脏活」收进离线 ops 脚本,运行时便彻底不必接触 new-api 的内部 schema。
3.3 B · 离线 ops 预置脚本:一次性预建额度池
provision 不进 game-cloud 运行时,而是一个离线脚本,宿主 mini-infra(SSH 直连、绕系统代理,遵守内网直连纪律),建议 Python(可同时用 HTTP API 与 psycopg 直连 postgres,把干净活和 §3.2 那一步碰 postgres 的脏活一体承载)。脚本一次预建 N 个额度账户(内测 ≤200 规模,N 取略高于当前种子用户数、留补池余量)。
每个池条目四步建成(S0 坐实的唯一可行路径):
POST /api/user/(API,root 令牌)建用户,username 用确定性命名neice_<序号>(neice_001…neice_200),拿newapi_user_id。- DB 直写 new-api postgres(
infra-postgres:5432/new-api):UPDATE users SET access_token=<确定值> WHERE id=<uid>——这一步是 S0 坐实的关键,access_token无法经 API 设、又是下一步「以该用户身份建 token」的前提。 POST /api/token/(API,用该用户的 access_token +New-Api-User:<uid>,即以该用户身份)建 token,name 用neice_<序号>、unlimited_quota=false、expired_time=不过期,拿 48 位token_key。PUT /api/user/(API,root 令牌)设quota=¥100 折算(grant_quota,§3.2 口径),token 侧remain_quota挂同额。
幂等(脚本可重跑补池):按确定性 username neice_<序号> 去重——重跑前先 GET /api/user/ 列已建用户,跳过已存在的序号,只补齐缺口到目标 N;补池 = 提高目标 N 重跑脚本。每条落一行审计日志(序号 / user_id / token_id / remain_quota / 结果),单条失败可单独重试、不影响已建条目。
产出交接给 game-cloud:脚本把每个建成条目的 (newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, quota_per_unit_snapshot, usd_rate_snapshot) 写入 game-cloud 池表(§3.5),status=FREE。两种落法二选一——(a) 脚本直连 game-cloud MySQL INSERT ... ON DUPLICATE KEY UPDATE(按 newapi_user_id 去重);(b) 脚本导出 JSON、game-cloud 侧一次性导入脚本读入。内测取 (a) 最短路(脚本本就在做 DB 脏活,多一个 MySQL 连接可接受),去重键 = newapi_user_id。
¥100 ↔ quota 折算:口径与 cost.py 同源、读网关而非硬编码。GET /api/status(无需鉴权,cost.py:51-55 已这么用)拿 quota_per_unit(默认 500000)/usd_exchange_rate(默认 7.3),grant_quota = round(yuan / usd_exchange_rate × quota_per_unit),¥100 ≈ 6,849,315(S0 实测参考值,供验收对表)。折算时把 (quota_per_unit, usd_exchange_rate, grant_quota) 三元快照随条目写进池表(quota_per_unit_snapshot/usd_rate_snapshot/grant_quota),日后网关调率不影响已发额度的审计追溯。
外部交互处置:每次 API 外呼连接超时 5s、读超时 10s,5xx/超时重试 2 次(指数退避 0.5s→1s),4xx 不重试;postgres 直写用事务、失败回滚该条。脚本是离线一次性运行,不承载运行时 SLA,但仍全程可追溯日志(脱敏 token/access_token,见 3.8)。它把「三步建 token + 一次碰 postgres」这组无内建幂等的原子副作用一次性做完并落进池表,运行时的幂等语义则完全由 §3.4 的 claim 状态机承载——两者解耦:脚本负责「池里有货」,claim 负责「一人分一个、不重分」。
3.4 C · 注册 claim:消费 WU1「注册成功」事件,从池 CAS 抢一个 FREE 条目绑玩家
接缝同前不变。WU1 §3.6 对外只暴露一条接缝:注册事务提交后(afterCommit)投递一个「注册成功」领域事件 / RocketMQ 消息,载荷含 userId、registerChannel、anonId,不在事务内提供任何挂点,且明确要求「hook 消费的是『注册成功』事件而非『password 注册成功』事件」。因此 WU2 不能在注册事务里 co-commit(那是 WU1 用事件刻意解耦要避开的耦合),而是作为该事件的消费者,在 WU2 自己的线上从池 claim。宿主放 game-module-aigc(新子包,co-locate 派发期的池查询、passport 不感知 WU2),经 RocketMQ 订阅注册成功 topic。
provision 已离线完成,运行时不开户、不调 new-api——claim 是一次纯 game-cloud 内部 DB 操作,没有任何 HTTP 外呼、没有跨 HTTP 开事务的原子性隐患,比原「运行时三步开户 + 状态机 + 三补偿腿」的方案简单一个量级。
渠道过滤(同前):只对真实种子渠道 claim(registerChannel ∈ {password, invite});sms 自动注册与 sms-mock 8888 后门跳过——避免一次性手机号每次首登都占掉一个 ¥100 池条目(见 §6 攻击面),这类若真去生成再由懒 claim 兜。
claim 的原子绑定(单条 SQL CAS,幂等):
- 幂等前置:该
game_player是否已 claim 由uk(claimed_by_game_player_id)保证一玩家至多一条CLAIMED条目——已有则 no-op 复用(重投事件 / 并发天然收敛)。 - 抢占 FREE 条目:
UPDATE newapi_quota_pool SET status='CLAIMED', claimed_by_game_player_id=?, claimed_at=now(), biz_no='claim_<gamePlayerId>' WHERE id=(SELECT id FROM (SELECT id FROM newapi_quota_pool WHERE status='FREE' ORDER BY id LIMIT 1) t) AND status='FREE'(行级 CAS,只有把某条FREE→CLAIMED翻转成功的那一路生效)。并发多消费者 / 重投由status='FREE'条件 +uk(claimed_by_game_player_id)+uk(biz_no)三重收敛成一条,任何路径都不会给同一玩家占第二个条目、也不会两玩家抢到同一条。 - 抢到(affected=1)→ 该玩家已绑好
token_key,结束。
注册不阻断(WU1 已保证):事件发出即返回,claim 与注册解耦,池抖动不拖垮注册。
新失败模式——池空(无 FREE 条目):这是 provision 从运行时移到离线后新引入的失败面,必须显式兜。处置:
- claim 消费者遇池空不抛异常:池空是非瞬时故障(补池是人工 / 离线动作,不在 MQ 重试窗口内可恢复),抛异常只会把消息灌进 DLQ 空转。改为记 WARN + 触发池水位告警(提示 ops 补池),该玩家暂不绑定,注册照常成功。
- 持久兜底 = 首次生成懒 claim(§3.6 E):该玩家真正要生成时,派发线做一次单 CAS 懒 claim——池已补(ops 重跑脚本)则绑上继续;仍空则当次干净失败给可读拒因(「额度账户准备中,请稍后重试」),玩家补池后重试即得。懒 claim 是一条 DB CAS(不是慢外呼),放在派发热路是安全的(与原方案的三步慢开户根本不同,见 §3.6)。
- 可选的定时补绑腿(best-effort,默认不做):补池后由
@Scheduled扫「password/invite渠道、注册已成功、但无CLAIMED池条目」的玩家批量补 claim,让额度在首次生成前就绪。它需按game_player反查未绑玩家(join 查询);因懒 claim 已覆盖真正需要额度的那一刻(生成时),此腿仅作体验前置增强、延后。
池水位告警:FREE 条目数低于阈值(如 <20 或 <种子用户日增量)即告警,提示 ops 重跑脚本补池。这是池模型的核心运维信号,进观测。
stateDiagram-v2
[*] --> FREE: ops 脚本离线预建<br/>(user+token+¥100) 写池表
FREE --> CLAIMED: 注册/懒 claim CAS 抢占<br/>绑 game_player·写 biz_no
CLAIMED --> INVALID: 运行时发现 token 失效(401)<br/>隔离·告警 ops(§3.9)
INVALID --> [*]: ops 排查/补池替换
CLAIMED --> [*]: 玩家生命周期内长期持有
FREE --> FREE: 池空→水位告警·懒 claim 兜(§3.6)
3.5 D · 额度池表:新表 newapi_quota_pool(V32.0.0)
池表存的是预建条目,不再是「一玩家一映射行」——ops 脚本预建时按条目 INSERT(status=FREE、claimed_by 空),注册时某条被 claim 才翻转成 CLAIMED 并绑到玩家。它带自己的生命周期(FREE→CLAIMED→可选 INVALID)与审计快照(折算三元、幂等单号),独立成表既方便脚本批量灌数、也让 claim 有清晰的抢占边界,不污染玩家主表。DO 继承 TenantBaseDO(沿用 creator/时间/deleted/tenant_id 审计列;池是系统级资源,tenant_id 落系统租户即可)。
版本号仲裁(与 WU1 并行落地必须协调):执行副本当前连续到 V30.0.0,而 WU2 依赖的 WU1(game_player 增列 + mobile 放宽)已取 V31.0.0 并先落——WU2 骑在 WU1 的 schema 之上,故顺延取 V32.0.0__create_newapi_quota_pool.sql。若两单同取 V31 会 Flyway 撞号、校验失败无法应用。落地前把「ls .../db/migration | sort -V | tail 复核执行副本当时最大号、若已有更高号再顺延」当硬前置(与 WU1 §3.1 同口径),不能只当脚注;且同 WU1 一样同时落契约源 contracts/db-schemas/ 与执行副本两处,守门①在本单做实。
字段级契约草案:
| 列 | 类型 | 语义 |
|---|---|---|
id |
bigint PK | 池条目主键(自增) |
newapi_user_id |
bigint, uk | 预建的 new-api users.id(脚本灌数去重键) |
newapi_token_id |
bigint | 预建的 new-api tokens.id |
newapi_token_key |
varchar(64) | tokens.key(48 位 sk-…;敏感,随 job 下发) |
grant_quota |
bigint | 该条目预置的 ¥100 折算 quota(审计) |
quota_per_unit_snapshot |
bigint | 折算时网关 quota_per_unit |
usd_rate_snapshot |
decimal(10,4) | 折算时 usd_exchange_rate |
status |
varchar(16) | FREE(可 claim)/ CLAIMED(已绑玩家)/ INVALID(token 失效隔离,§3.9);claim CAS 抢占的锚(§3.4) |
claimed_by_game_player_id |
bigint, null, uk | claim 后 ↔ game_player.id;FREE 时为 null(唯一键允许多 null,故多条 FREE 可并存,CLAIMED 保证一玩家至多一条) |
claimed_at |
datetime, null | claim 时间 |
biz_no |
varchar(64), null, uk | claim 幂等键 claim_<gamePlayerId>;FREE 时为 null |
remark |
varchar(255) | 隔离原因等 |
newapi_token_key 是密钥落库,内测阶段明文存但须视为敏感(3.8 讲传输与日志脱敏,生产化时应加密列——本阶段不做,记为红线待办)。前后兼容:纯新增表,不改任何既有表结构,无迁移风险;回滚 = drop 表 + 摘事件消费者。
额度落点钉死(避免摇摆):¥100 grant 由 ops 脚本预建时写 new-api 两处,但权威口径唯一——token 的 remain_quota 是权威余额闸(预建 token 时 unlimited_quota=false、挂 grant_quota),users.quota 设同额只为账户自洽、不作限额裁决;users.used_quota 是权威消耗(网关逐笔累加)。触发耗尽 402 的是 token remain_quota→0(网关按这把 token 扣该 user)。据此三处验收对同一口径:预建后查 token remain_quota=grant_quota(§5.1)、一次 gen 后查 user used_quota 增量(§5.3)、把 token remain_quota 设近 0 触发耗尽(§5.4)。池表只存 grant_quota 审计快照,不在 game-cloud 侧记余额(余额始终以网关为准)。
3.6 E · job 加 userToken 位与 per-user 门的适用边界(§6.1 契约变更)
AigcGenerateExecutor.dispatchGeneric(658-681)组 §6.1 job 时追加一个 userToken 字段。但per-user 额度门只对「真实 member 在线创作」生效,这是一条必须钉死的边界,否则会误伤既有验证线。
为什么必须分身份:编排器批跑与 bake-off 是「伪装在线提交」——经 submitGenerate → dispatchGeneric(AigcTaskServiceImpl 202-204 注释确认),同样流经此处;task_source(V17)当前无 ReqVO 信号、全落 online,据 source 辨别不了。若在此一律按 member 查池、无 CLAIMED 条目即懒 claim 或干净失败,n=5 收敛环 / bake-off / 脚本路会被这道闸挡死;更糟的是 admin/system 触发身份属另一套 id 空间、没有 game_player 行,懒 claim 会替一个 admin/system id 占掉一个 ¥100 池条目(白白耗池,这些 id 也不是真实玩家)。
据此的判据(今天就成立)——门只认 creatorUserId 是否命中一行 game_player:
- 无
game_player行(系统 / 编排器 / bake-off / admin 触发)→ 旁路:不查池、不懒 claim,job.userToken留空,worker 回落全局 key(§3.7)。这条兜住越界回归。 - 命中
game_player行(真实 member 在线创作)→ 走 per-user 门:来源链creatorUserId(AigcTaskDO38 行既有字段)→ 查newapi_quota_pool取该玩家CLAIMED条目的newapi_token_key→ 放进job.userToken。将来编排器据契约显式声明source=orchestrator后,可把 source 叠加进旁路判据、更精确。
该玩家无 CLAIMED 条目(存量 null / 注册时池空未绑)时——派发线做一次单 CAS 懒 claim。这里有个相对原「运行时开户」方案的关键收益:claim 是纯 game-cloud 内部一条 FREE→CLAIMED 的 DB CAS,不是三步慢外呼——原方案那条「懒开户在有界消费池(consumeThreadMax=15)里串 createUser→createToken→setUserQuota 三次分钟级外呼、首次生成高峰叠加 new-api 抖动拖垮共享池」的稳定性红线,随 provision 离线化一并消失,派发热路里放一条 DB CAS 是安全的。处置:命中 CLAIMED 条目取 newapi_token_key 放进 job.userToken;无 CLAIMED 条目则懒 claim 抢一个 FREE——抢到就用;池空抢不到就当次干净失败给可读拒因(quota_exhausted,文案「额度账户准备中,请稍后重试」),同时触发池水位告警提示 ops 补池,玩家待补池后重试即得(§3.4 的池空处置)。无论如何绝不派一个没 token 的 create 路 job 下去。
契约兼容声明(须随契约通知相关方):userToken 是追加字段,老 worker 收到多余字段不炸——三个 worker 入口解析 job 均走 job.get(...) 逐键取值(worker_service.py:142-146 即此模式),未知键被忽略,不做严格 schema 校验。所以这是一次向后兼容的加法:新后端 + 老 worker = worker 忽略 userToken 走全局 key(行为同今天);新后端 + 新 worker = 走 per-user token。无需 worker 与后端同步上线。
3.7 F · worker 取 job token 与回落策略
三个入口从「读全局 env NEWAPI_KEY」改成「优先读 job.userToken」:cheap-worker/cheap_service_driver.py:28-44(_resolve_key 现从 client.get_api_key() 读 env)、cheap-worker/worker_service.py 的 job 消费边界、wg1/gen-worker/worker/_client.py:47(get_api_key 现读 env NEWAPI_KEY)。
回落策略(须给判断):job.userToken 存在则用它;缺失则回落全局 env key。判断依据在 dispatch 层已划清(§3.6)——真实 member 的 create 路由 E 保证必带 token(拿不到就在后端 fail-fast 干净失败,worker 见不到无 token 的 member create job);而系统 / 编排器 / bake-off / spike / 脚本路本就没有 game_player 成员身份,E 已按「无成员身份」旁路、不给它们塞 token,它们缺 userToken 是正常且预期的,回落全局 key 才能让这些既有验证路继续跑。若在 worker 这层对缺失一律硬拒,会误伤这些路。所以额度口子不在 worker 层堵,而在后端 dispatch 层按身份堵死(member create 路无 token 即失败、系统路旁路)——worker 只做「有则用、无则回落」,并在回落时打一条 WARN(便于发现本该带 token 的 member create job 异常走了共享计量)。这样既不破坏既有验证路,又不给 member create 路留静默烧共享额度的洞。
3.8 per-user token 在内网传输的安全面
userToken 是一把 new-api 调用凭据,现在要随 job 从后端流到 worker(内网 HTTP dispatch)。它的暴露面:job 载荷若被完整打进日志、token 出现在 worker 结果回传里、dispatch 通道本身是否鉴权。处置:
- 日志脱敏:后端与 worker 两侧都不整条打
userToken,仿DesensitizedUtil只留前后各 4 位;池表与 job 载荷不进 INFO 级全量日志。 - 传输面同现状:job dispatch 与 worker 回调都在内网(Tailscale 100.64.x / localhost),与现有全局
NEWAPI_KEY已经躺在 worker env 里是同一信任边界——本变更没有把凭据带出内网,只是从「一把共享」变成「每人一把」,粒度更细、单把泄漏的爆炸半径反而更小(只影响单个用户 ¥100)。 - 与回调 HMAC 收口同源:回调方向(worker→后端)已有
aigc.executor.callback-secret的 HMAC-SHA256 验签(AdminAigcTaskController内网回调 3b-B)。派发方向(后端→worker)当前是内网可达即信任;userToken进入载荷把「保护 dispatch 通道」的优先级抬高了一档——内测阶段维持内网可达 + 日志脱敏可接受,生产化时应给 dispatch 通道同等的服务间鉴权(记为红线待办,随 facade 回调 HMAC 收口一并规划,不在本内测闭环内实现)。
3.9 额度耗尽如何干净失败(§6.5 失败因)
用户 token remain_quota 耗尽时,网关对 /v1/chat/completions 返非 2xx(one-api 系为 402/403,错误体含「用户额度不足 / insufficient user quota」类 message)。今天 worker 把任何非 2xx 都归 llm_error(ExecutorLlmClient 134-138 的 HTTP 错分支同理),用户只会看到含糊的「生成失败」。要干净失败:
- contract-first(不可只加 Java 枚举):
FailureReasonEnum的头注明写「取值与 Difyoutput.failureReason严格一致,不可单边扩展(跨契约共享枚举)」。故quota_exhausted必须同一改动里落三处:FailureReasonEnum(game-module-aigc-api)新增QUOTA_EXHAUSTED("quota_exhausted", "生成额度已用完")、contracts/api-schemas/aigc.yaml:296的FailureReason.enum、contracts/dify-workflow-io.json:50的output.failureReason.enum。该枚举与 Dify 输出共享、dify-workflow-io.json归 WU3 面,须与 WU3 同批协调落(quota_exhausted由 worker/网关侧产出、Dify 工作流本身不产出,但共享枚举须登记一致,否则 docs/contract 门拦)。落库列game_aigc_task.failure_reason是varchar(32)(quota_exhausted15 字符,容得下)。 - 消费方对未知值的容错须落地核实、非断言:前端
failureReason→文案映射须有默认回落(在game-studio对应映射处核,未知值回落到通用「生成失败」不炸);DB 列无 enum 约束天然容错;这三处逐个确认后再落,别把「不炸」当断言。 - worker 在调网关拿到非 2xx 时,分辨两类失效(确切 message/code 待 S4 真机耗尽 / 凭据失效测试确认——S0 只坐实了管理面,未测
/v1/chat/completions的耗尽错误体):① 402/403 额度耗尽 → 回调failure_reason=quota_exhausted;② 401/凭据失效(token 被 admin 删/改、或CLAIMED条目存的token_key已陈旧)→ 不塌成 quota_exhausted,触发一次 re-claim:把该CLAIMED池条目隔离为INVALID(供 ops 排查 / 补池替换、不再派发),经 §3.4 的 CAS 给该玩家 re-claim 另一个FREE条目,并按可重试失败回传(维持llm_error,不为此再摊一次跨契约枚举同步成本);池空则告警 ops。内测把池条目 tokenexpired_time由 ops 脚本设不过期(unlimited/null),从源头避免自伤式过期,401 应属罕见。 - 后端
handleCallback落终态时把该拒因透传给前端,用户看到「生成额度已用完」。
¥100 花完后的用户体验:生成入口被挡(3.10 的可选轻门或耗尽时的干净失败),用户看到明确的「额度已用完」文案。内测不提供自助充值,也不做运营续额闭环:每个池条目 ¥100 由 ops 脚本预建时一次性设好,运行时绝不改 quota(无运行时 setUserQuota),更严禁对已 claim/已消耗条目重设额度(绝对赋值会把已花额度刷回满额=免费续杯)。若未来确需续额,那是对 token remain_quota 的 delta-aware 增量(读现值再加)、或引入独立 grant 流水表承载多笔——池表 uk(claimed_by_game_player_id) 一人一条、biz_no 一单,本就不为多笔续额设计。续额属「变现端到端」范畴,不在本内测闭环。
3.10 可选加项:入队前轻余额门
AigcTaskServiceImpl.enqueueWithControlPlane(335 起,现有降级门①/配额并发门②/背压门③;submitGenerate 与 retryTask 共走)可加一个可选的轻余额预检:在既有门旁查该玩家 claim 到(CLAIMED 条目)的 token 的 remain_quota,若低于一次生成的估算成本(如 ¥0.15 折算 quota)则提前拒(新增错误码「额度不足」),省掉一次注定被网关拒的空跑。同 §3.6 的身份边界:此预检也只对真实 member(creatorUserId 命中 game_player)生效,编排器/bake-off 经此共享入队门时须旁路(无成员身份即跳过预检),否则同样会误伤批跑。
这门是 best-effort 加项,不是闭环必需:查网关失败(超时/网关抖)不阻断——直接放行让网关在真扣费点做权威判定(网关才是额度的最终裁决者,预检只是体验优化)。默认可先不做,留作接线点跑通后的体验增强。
4 步骤计划
代码物随阶段一落地,本设计只定架构级 HOW。
| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
|---|---|---|---|---|
| S0 实测管理面(A)✅ 已完成 | docs/内网凭据与端点.md 管理令牌节 + 管理面三件事实已坐实:root=id1 qingse + 鉴权头 Bearer access_token+New-Api-User:1 + 端点尾斜杠 + 折算 500000/7.3/¥100≈6,849,315;关键约束坐实:admin 不能替他人建 token、access_token 不可经 API 设 → per-user token 必须碰 postgres |
真机手工建户/建token(以该用户身份)/setQuota 各成功一次、测试用户已清理 0 残留(S0 findings 已亲验) | mini-infra new-api 可达 | 无——已实测锁定,本档 3.2/3.3/3.9 假设项已消解 |
| S1 ops 预置脚本 + 池表(B·D) | Python 预置脚本(建户 API → DB 写 access_token → 以该用户身份建 token API → 设 quota API;按 neice_<序号> 幂等补池;产出写 game-cloud 池表)+ V32.0.0 池表迁移(契约源+执行副本两处)+ DO/Mapper |
staging 跑脚本预建 N 条:new-api users/tokens 新增 N 行、每 token remain_quota=¥100 折算、unlimited=false;池表 N 条 FREE、token_key/快照落齐;脚本重跑不重建(按序号去重) |
S0 事实 | postgres 直写耦合 new-api schema——限离线脚本承担、不进运行时;ls|sort -V|tail 硬复核版本号 |
| S2 事件消费 claim + 池空处置(C) | RocketMQ 消费 WU1 注册成功事件(渠道过滤)+ 单 CAS FREE→CLAIMED claim(uk_claimed_by/uk_biz_no 幂等)+ 池空不抛/水位告警 + 懒 claim 挂点(§3.6 兜) |
注册后池一条 FREE→CLAIMED、claimed_by=该 game_player.id;重投事件不二次 claim(uk+CAS 拦);把池 FREE 清零后注册仍成功、水位告警触发、补池后首次生成懒 claim 绑上 |
S1 池 + WU1 事件接缝已落 | 池空是新失败面(懒 claim + 告警兜);幂等靠 uk + 单 CAS |
| S3 job 加位 + 身份门 + worker 取 token(E·F) | dispatchGeneric 追加 userToken + member/系统身份分流(无 game_player 行旁路回落)+ 单 CAS 懒 claim(池空当次干净失败、不阻塞派发线)+ 三 worker 入口改读 job token + 回落 |
新后端+老 worker 回归不炸;member create 路用 per-user token;编排器/bake-off/n=5 收敛环仍能跑(旁路全局 key 不被误挡);无 CLAIMED 条目时懒 claim 绑上或干净失败、派发池不阻塞 | S2 池 | 契约兼容——追加字段、worker .get 忽略未知;懒 claim 是 DB CAS(非慢外呼),派发热路安全 |
| S4 耗尽干净失败(§6.5,contract-first) | 同批改三处枚举(FailureReasonEnum + aigc.yaml:296 + dify-workflow-io.json:50,与 WU3 协调)+ worker 分辨 402/403 额度耗尽 vs 401 凭据失效(后者 re-claim 池条目)+ 前端拒因回落核实 |
真机把 token remain_quota 设近 0 → gen → 后端落 quota_exhausted;docs/contract 门绿;前端未知值不炸 |
S3 | 网关额度错/凭据错的确切 message——S4 真机耗尽 / 凭据失效测试确认(S0 只坐实管理面、未测耗尽错误体);跨契约共享枚举须与 WU3 同批 |
| S5(可选) | 入队前轻余额门(member-only) | 余额不足入队即拒;查网关失败不阻断;编排器旁路不被误挡 | S3 | best-effort,不做也闭环 |
5 验证方式
验收门全部可真机取证(内网直连绕系统代理,SSH mini-infra 走真 IP):
口径钉死(对齐 §3.5):权威余额 = token remain_quota、权威消耗 = user used_quota,验收对同一口径。
- 预置池:ops 脚本跑一次,
SELECT count(*) FROM users WHERE username LIKE 'neice_%'与tokens各新增 N 行、每 tokenremain_quota= ¥100 折算值(unlimited_quota=false,默认约 6,849,315);game-cloudSELECT count(*) FROM newapi_quota_pool WHERE status='FREE'= N,token_key/grant_quota/快照三元落齐。 - 注册 claim:注册一个内测用户(走 WU1 password 路)后,池里恰有一条
FREE→CLAIMED、claimed_by_game_player_id=该game_player.id、biz_no='claim_<id>'、claimed_at非空;池FREE计数 -1。 - 生成扣减:记录一次真 gen 前后该 claim 到的 user
SELECT used_quota FROM users WHERE id=<newapi_user_id>,Δused_quota > 0;且该 tokenremain_quota相应减少(权威余额闸)。 - 耗尽干净拒:
PUT/ SQL 把该 tokenremain_quota设近 0 → 触发一次 gen → 观察网关返 402/403 且后端任务终态failure_reason=quota_exhausted(而非llm_error),前端拿到可读拒因。 - claim 幂等:对同一
game_player_id重放注册成功事件(或手工重投消费),池不新增第二条CLAIMED、该玩家仍绑同一条(uk_claimed_by+ 单 CAS 拦住);并发重投时验只有一条路径把某条FREE→CLAIMED。 - 池空降级:把池
FREE清零后注册一个用户,注册(WU1)成功可登录、该玩家无CLAIMED条目、水位告警触发;ops 补池(重跑脚本)后对该玩家首次生成触发懒 claim 绑上(或定时补绑腿)。 - 越界不误伤:驱动一次 bake-off / n=5 收敛环批跑(系统/编排触发,无
game_player成员身份),验其 job 不被 per-user 门挡、userToken留空、worker 回落全局 key 正常出图(证 §3.6 身份分流成立)。 - 存量 null 懒 claim:对一个无
CLAIMED条目的存量 member 触发生成,验单 CAS 懒 claim 就地绑上(池有货)或干净失败给可读拒因(池空),派发线不被阻塞。
可观测信号:池水位(FREE 计数)与 claim 成功/池空计数、池条目 status 流转(FREE→CLAIMED、CLAIMED→INVALID 隔离)、任务终态 failure_reason 分布(quota_exhausted 与 llm_error 可区分)、worker→网关外呼日志(脱敏 token;运行时不存在 admin 外呼日志,佐证零运行时 admin 调用)。
6 风险与回滚
- provision 已离线,运行时零 new-api admin 耦合(本方案相对 on-demand 的关键收益):管理凭据与 postgres 直写只活在离线 ops 脚本里,game-cloud 运行时零 admin API 调用、零 new-api-postgres 连接。原「运行时开户外呼进 DB 事务 / 三步慢外呼拖垮派发池 / CAS+PROVISIONING 串行三补偿腿」那一整套原子性与稳定性风险,随 provision 离线化一并消失。
- 新失败面:池空:provision 离线化换来的代价是池会耗尽。三层兜——注册时池空不抛异常(记 WARN + 水位告警、不绑、注册照常成功)、首次生成单 CAS 懒 claim(池已补则绑上、仍空则当次干净失败给可读拒因)、ops 重跑脚本补池(按
neice_<序号>幂等去重、提高目标 N)。池水位告警(FREE<阈值)是核心运维信号,进观测;缺了它池会静默见底、所有新用户拿不到额度。 - claim 幂等:一玩家至多一条
CLAIMED(uk_claimed_by)+biz_no唯一键 + 单条FREE→CLAIMEDCAS,重投 / 并发收敛成一条——不会给同一玩家占第二个条目、也不会两玩家抢到同一条。这比原方案简单:claim 是纯 DB 操作,没有「HTTP 副作用无法回滚」的问题。 - 越界不误伤既有验证线:per-user 门只对真实 member(
creatorUserId命中game_player)生效;编排器批跑/bake-off/n=5 收敛环「伪装在线提交」经submitGenerate→dispatchGeneric(AigcTaskServiceImpl202-204)同样流经派发点,无成员身份即旁路回落全局 key、绝不懒 claim(否则替一个 admin/system id 白占一个 ¥100 池条目)。堵点在 dispatch 层按身份分流,不在 worker 层。 - 账本口径唯一:权威余额 = token
remain_quota、权威消耗 = userused_quota;users.quota只作账户自洽不作限额裁决。¥100 由 ops 脚本预建时一次性设好,运行时绝不改 quota(无运行时setUserQuota),更严禁对已 claim/已消耗条目重设额度(绝对赋值会刷回满额=免费续杯);不做运营续额闭环,续额若需为 delta-aware 增量或独立 grant 流水表,属变现端到端、不在本闭环。 - 契约一致性(contract-first,会被门拦):
quota_exhausted是跨契约共享枚举(FailureReasonEnum头注禁单边扩展),必须同批落 Java 枚举 +aigc.yaml:296+dify-workflow-io.json:50三处、与 WU3 协调;前端未知值容错须逐处核实非断言。worker 须分辨 402/403 额度耗尽(→quota_exhausted)与 401 凭据失效(→隔离该条目INVALID+ re-claim 另一FREE+ 可重试失败),池条目 tokenexpired_time由 ops 脚本设不过期避免自伤。 - 凭据面:
newapi_token_key明文落池表、随 job 内网传输,内测可接受但须日志脱敏;claim 按registerChannel过滤——sms-mock8888 后门与一次性手机号自动注册不 claim,避免白占 ¥100 池条目。管理令牌(system access token)是皇冠明珠(泄漏可建任意用户/设任意额度/读全部 channel key,爆炸半径远大于任何 per-user token):现只被离线 ops 脚本使用,只存docs/内网凭据与端点.md、最小权限、可轮换、标注泄漏应急,单列一条红线;new-api postgres 直写凭据同属敏感(ops 脚本用,泄漏可乱改 new-api 账本),一并列红线。生产化的列加密与 dispatch 通道服务间鉴权随 facade HMAC 收口规划,不在本闭环。 - 脏活耦合 new-api schema:postgres 直写
users.access_token耦合 new-api 内部 schema(升级可能改列名)——但仅限离线 ops 脚本承担,New API 升级时只需改脚本一处、不波及 game-cloud 运行时。这正是选池模型(相对让 game-cloud 加 new-api postgres JDBC 的 on-demand 混合方案)的关键收益。 - 版本号并行仲裁:WU1 与 WU2 同批新增迁移——WU1 取 V31、WU2 顺延 V32,落地前
ls .../db/migration | sort -V | tail硬复核,避免 Flyway 撞号。 - 对 WU1 的依赖面:WU2 骑在 WU1 的
mobile放宽可空之上;mobile可空的跨模块回归(trade/aigc/遥测等假定非空的消费方、脱敏空安全)与防枚举验证码闸门归 WU1 验收(主线 WU 归属),WU2 不重复挑,仅登记此依赖:WU1 未证前 WU2 的 member 身份查询不假定mobile非空。 - 回滚:本设计全为加法(离线 ops 脚本 / 新池表 / 事件消费者 claim / job 追加字段 / 新枚举值),回滚 = 摘事件消费者 + worker 恢复读全局 key + drop
newapi_quota_pool+ 撤userToken字段 + 回撤三处枚举;ops 脚本是离线工具,不用则不跑、删除即可;老全局NEWAPI_KEY路径始终保留,回滚即退回今天的共享计量行为,零数据损伤。
附 图清单与状态
| 图 | 媒介 | 位置 | 状态 |
|---|---|---|---|
| 图0 一图看懂 | inline SVG | §0 | 随文(house style:浅底/实线现行/虚线待办/红线拒绝路) |
| 图1 六接线点数据流 | mermaid | §3.1 | 随文(事实源) |
| 图2 池条目 FREE→CLAIMED claim 状态机 | mermaid | §3.4 | 随文(事实源) |
| 图3 边界 in/out | mermaid | §2 | 随文(事实源) |
关联 commit:
cb9f135d。收口时按 frontmattersot-impact把可存留结论折进「生成引擎运行时」(§6.1/§6.5) 与「数据模型」两个 canonical SoT,并把quota_exhausted同步进contracts/api-schemas/aigc.yaml与contracts/dify-workflow-io.json两处共享枚举(与 WU3 协调),本设计档降为留痕。S0 已实测坐实(管理端点/鉴权头/折算/「admin 不能替他人建 token」均已验证),本档 3.2/3.3 的原「待实测」管理面假设项已消解,provision 据此定为离线预置池 + 注册 claim;§3.9 的耗尽 / 凭据失效错误码 S0 未覆盖,仍待 S4 真机确认。