diff --git a/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md b/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md
new file mode 100644
index 00000000..c2217413
--- /dev/null
+++ b/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md
@@ -0,0 +1,270 @@
+---
+date: 2026-07-07
+topic: 内测-注册登录-用户名密码
+status: 草案
+sot-impact: 修订 topic 鉴权与权限(新增用户名+密码鉴权路径,与短信/邀请码并存);修订 topic 数据模型(game_player 增 username/password 列、mobile 放宽可空、加 uk_username)。收口时把两条结论折进 后端/鉴权与权限.md 与 后端/数据模型.md,本档降为留痕。
+上级: docs/mvp/MVP作战清单.md
+关联:
+ - contracts/db-schemas/V11.0.0__create_passport_player_invite.sql @ cb9f135d(被扩展的 game_player 建表)
+ - game-cloud/huijing-module-system/.../service/passport/PassportServiceImpl.java @ cb9f135d(registerPlayer / buildLoginResp 复用锚)
+ - game-cloud/huijing-module-system/.../controller/app/passport/AppPassportController.java @ cb9f135d(新增端点挂载点)
+ - game-cloud/huijing-module-system/.../dal/redis/passport/PassportSmsIpCounterRedisDAO.java @ cb9f135d(纯 IP 计数复用锚)
+ - game-cloud/huijing-module-system/.../service/user/AdminUserServiceImpl.java:572-583 @ cb9f135d(BCrypt 范式)
+ - game-cloud/huijing-module-system/.../controller/admin/auth/vo/AuthLoginReqVO.java:24-33 @ cb9f135d(用户名/密码校验范式)
+ - game-studio/src/api/passport.ts、src/views/login/Login.vue @ cb9f135d(前端改动面)
+图清单: [图0 一图看懂, 图1 双身份路径共存, 图2 注册/登录时序含 IP 闸与开户 hook, 图3 步骤计划]
+---
+
+# 内测·用户名密码注册登录 功能设计
+
+## 0 一图看懂
+
+内测种子用户不再依赖短信:输入用户名和密码即可完成注册与登录,账号身份从"必须有手机号"松绑为"用户名或手机号二选一"。后端在既有 passport 模块里新增一对免登端点,复用已经跑通的建号事务、BCrypt 编码、OAuth2 发 token 三段;防刷靠应用层按纯 IP 计数的时/日双桶实现,同时把这层闸补到此前漏保护的短信登录与邀请码注册上。
+
+```mermaid
+flowchart LR
+ U["种子用户"] -->|用户名+密码| REG["password-register
password-login
(app-api/passport)"]
+ REG --> IP{"同 IP 时/日
计数闸"}
+ IP -->|超阈值| DENY["429 请求过频"]
+ IP -->|放行| SVC["PassportServiceImpl
registerPlayer + BCrypt + buildLoginResp"]
+ SVC --> DB[("game_player
username/password 新列
mobile 放宽 NULL")]
+ SVC --> TOK["OAuth2 token
userType=MEMBER"]
+ SVC -.事务内写 outbox.-> HOOK["WU2 new-api 开户充值 hook
(至少一次·幂等·本档只交生产者)"]
+```
+
+**核心思想**:不新建鉴权体系,在 passport 现有骨架上加一条与短信/邀请码平权的登录路径,用一列 `username`、一列 `password` 和一处 `mobile` 放宽承载;防刷收敛到一处纯 IP 计数组件,避免每个端点各写一套。
+**边界**:只做用户名密码的注册与登录(内测种子),不做找回密码、改密、手机号绑定、图形验证码、真正的边缘网关限流。
+**怎么算成功**:真机上用户名密码注册返回 OAuth2 token、随后同凭据登录成功、同 IP 高频尝试被 429 拒、存量短信与邀请码两条路零回归。
+
+## 1 意图与目标
+
+内测阶段要把种子用户请进来试玩和创作,可当前 passport 只认手机号:登录靠短信验证码,注册靠"验证码即占有证明"的自动注册,或报备前的邀请码旁路。staging 未接真实短信网关,只能靠 `sms-mock-enabled` 发一个固定码 8888 兜底登录——这是给自己人测试用的后门,把它当成种子用户的注册入口既不体面也不安全(任何人凭 8888 登录任意手机号)。创始人据此拍定内测口径:种子用户"输入用户名和密码即可"注册登录,无需短信;网关层对同 IP 的注册/登录尝试做限流防刷。
+
+目标有三条,都要能验:
+
+- **可注册可登录**:陌生用户提交合法用户名与密码即建号并拿到真 OAuth2 token;用同一对凭据能再次登录。token 结构、userType、client 与短信路完全一致,下游鉴权、`/me`、创作守卫无需感知登录方式的差异。
+- **可防刷**:同一 IP 在时窗内的注册与登录尝试次数受限,越限返回 429,日志留痕可审计;这层保护同时补到目前裸奔的 `sms-login` 与 `invite-register`。
+- **零回归**:短信验证码登录、自动注册、邀请码注册、`sms-mock` 后门四条既有路径行为不变。
+
+## 2 边界
+
+**做**:`game_player` 增 `username` / `password` 两列并放宽 `mobile` 为可空、加 `uk_username`;新增 `password-register` 与 `password-login` 两个免登端点及其请求/响应契约;用户名密码校验、BCrypt 编码与校验、防枚举口径;应用层纯 IP 计数防刷(新端点 + 回填 `sms-login` / `invite-register`);`wanxiang.passport.*` 段新增开关与阈值;`game-studio` 登录页新增用户名密码 Tab、`passport.ts` 端点封装、文案;`/me` 返回体补 `username` 以便前端展示非手机号身份;交付"注册成功"事件的**生产者**(注册事务内写 outbox、投递器发 MQ,至少一次),作为 WU2 new-api 开户充值 hook 的接缝。
+
+**不做**:找回密码 / 改密 / 手机号与用户名互绑(内测不需要,留后续账号中心);图形验证码或滑块(与短信路一样,列为公网上线前的加固项,非内测门槛);真正的边缘网关(huijing-gateway / nginx)限流作为主方案——现实是产品端走单体直连 48080,网关不在链路(详见 §3.3 的口径澄清与权衡);`real_name` / `id_card_no` 实名字段(V11 已预留,提现前才收集);把 WU2 的 new-api 开户逻辑实现进来(本档交"注册成功"事件的生产者与至少一次接缝契约,**消费端**开户充值随 WU2;即"不做"的是消费者,不是连事件发射也不做)。
+
+## 3 方案
+
+### 3.1 最大决策:单表双身份路径如何共存
+
+`game_player` 当前把手机号钉成了身份主键——`mobile VARCHAR(11) NOT NULL`,唯一键 `uk_mobile(mobile, deleted, tenant_id)`,没有任何用户名或密码的概念。要让"没有手机号"的用户也能落到同一张玩家表(这样 `id` 仍是 OAuth2 token 的 userId,下游一视同仁),必须动三处:加 `username`、加 `password`、把 `mobile` 从 NOT NULL 放宽为 NULL。
+
+这里的关键是唯一键在 NULL 上的行为。MySQL 的 UNIQUE 索引允许索引列取 NULL 时存在多行——当 `mobile` 为 NULL,`uk_mobile` 不再对这些行施加唯一约束,于是任意多个"无手机号"的用户名用户可以共存,而手机号非空的用户之间仍然一号一人。反过来,新加的 `uk_username(username, deleted, tenant_id)` 对手机号用户(`username` 为 NULL)不设限,对用户名用户严格唯一。两条路径因此在同一张表里互不干扰:
+
+```mermaid
+flowchart TB
+ subgraph 短信/邀请码用户
+ A["mobile=13800000000
username=NULL
password=NULL"]
+ end
+ subgraph 用户名密码用户
+ B["mobile=NULL
username=seed_alice
password=BCrypt(...)"]
+ end
+ A -.受 uk_mobile 约束.-> UKM["uk_mobile 唯一
(NULL 行不参与)"]
+ B -.受 uk_username 约束.-> UKU["uk_username 唯一
(NULL 行不参与)"]
+```
+
+**契约变更(DB schema,contract-first)**。新增一支 Flyway 迁移,只做 ALTER,不碰存量数据的值:
+
+| 变更 | 列/键 | 定义 | 说明 |
+|---|---|---|---|
+| 加列 | `username` | `VARCHAR(30) NULL` | 用户名,`^[a-zA-Z0-9]{4,30}$`(沿用 admin `AuthLoginReqVO` 范式);手机号用户为 NULL |
+| 加列 | `password` | `VARCHAR(100) NULL` | BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);非用户名用户为 NULL;**永不返回、永不落日志** |
+| 加键 | `uk_username` | `UNIQUE(username, deleted, tenant_id)` | 与 `uk_mobile` 同范式,含 deleted/tenant_id 适配逻辑删+多租户 |
+| 改列 | `mobile` | `VARCHAR(11) NULL`(原 NOT NULL) | 放宽可空,承载"无手机号"的用户名用户 |
+| 扩语义 | `register_channel` | 值域加 `password` | 现有 `sms`/`invite` 之外新增,漏斗归因 |
+
+**版本号与放置**。定序以执行副本 `game-cloud/huijing-server/src/main/resources/db/migration/` 为唯一依据——它是真正被 Flyway 应用的 classpath,凡低于已应用版本或与已存在版本同号都会被拦。实测执行副本已连续到 `V30.0.0`(其中 `V26.0.0__aigc_task_add_idempotency_key.sql` 已占用 V26),因此新迁移取其上的下一号 `V31.0.0__passport_player_add_username_password.sql`。落地前把「用 `ls .../db/migration | sort -V | tail` 复核执行副本当时最大号、若已有人加了更高号则顺延」当作**硬前置**执行,不能只当脚注——早先本档误记执行副本停在 V19、据此取 V26,正好会与既有 V26 撞号导致 duplicate/checksum 校验失败、迁移根本无法应用,就是漏做这一步。
+
+顺带订正一处 SoT 漂移:契约源 `contracts/db-schemas/` 目前只到 V25,缺 V14–V20 与 V26–V30,也就是执行副本反而**领先**契约源(V11 守门①「同版本同时落源+执行副本」其实早已长期失守,方向与直觉相反)。本单 V31 必须**同时**落契约源与执行副本把守门①在本次做实;缺失的 V14–V20、V26–V30 回填契约源属既有欠账,另案登记补齐,不在本单强行夹带。文件不放任何单模块 `-server/db/migration/`,否则同版本出现在多个 classpath jar 会触发 Flyway 重复校验失败。含中文 SQL,mini-desktop 执行须 `--default-character-set=utf8mb4`。
+
+**存量兼容与迁移**。存量 `game_player` 行全部有 `mobile`、`username`/`password` 为新加列取 NULL——`mobile NOT NULL → NULL` 是放宽,不破坏任何存量行;两个新列默认 NULL,存量短信/邀请码用户天然落在"手机号路径"一侧。`PlayerDO` 随之补 `username`/`password` 两个字段(`password` 仅服务层内部用,不进任何 RespVO)。
+
+**回滚**:写补偿迁移 `V31.0.1`,`DROP INDEX uk_username`、`DROP COLUMN username/password`;`mobile` 收回 NOT NULL 前必须先确认无 NULL 行(即无用户名用户),否则收回会失败——因此回滚前置一步"确认或清理无手机号用户",与 V11 对 `player_user_id` 放宽的回滚纪律同口径。
+
+### 3.2 端点与 Service:复用建号事务、BCrypt、发 token 三段
+
+不新建鉴权服务。在 `AppPassportController` 加两个 `@PermitAll` 免登端点,在 `PassportServiceImpl` 加两个方法,把已经验证过的三段拼起来:建号走现有 `registerPlayer`(扩参承载 username/password),编码校验密码走 admin 侧同款 BCrypt(`AdminUserServiceImpl` 的 `passwordEncoder.encode/matches`),发 token 与组装响应直接复用 `buildLoginResp`——`userType=MEMBER`、`clientId=default`、`refresh_token` 有效期由 OAuth2 client 承载,与短信路产出的 token 一字不差。
+
+```mermaid
+sequenceDiagram
+ participant C as game-studio
+ participant K as AppPassportController
+ participant IP as IP 计数闸(纯 IP)
+ participant S as PassportServiceImpl
+ participant DB as game_player
+ participant T as OAuth2TokenService
+ participant H as WU2 new-api hook
+
+ Note over C,K: 注册 password-register
+ C->>K: {username,password,nickname?,anonId?}
+ K->>IP: 注册场景 时/日计数++
+ IP-->>K: 超阈值→429 / 放行
+ K->>S: passwordRegister(reqVO, clientIp)
+ S->>DB: selectByUsername(username)
+ alt 用户名已占用
+ S-->>C: 1-002-090-004 用户名已被占用
+ else 可注册
+ S->>DB: insert(username, BCrypt(password), mobile=NULL, channel=password)
+ Note over S,DB: 唯一键 uk_username 并发兜底→DuplicateKey 回查报占用
+ S->>T: createAccessToken(id, MEMBER, default)
+ S-->>C: LoginRespVO(token...)
+ S-)H: 事务内写 outbox→投递器发 MQ(至少一次·幂等)
+ end
+
+ Note over C,K: 登录 password-login
+ C->>K: {username,password,anonId?}
+ K->>IP: 登录场景 时/日计数++
+ IP-->>K: 超阈值→429 / 放行
+ K->>S: passwordLogin(reqVO, clientIp)
+ S->>DB: selectByUsername(username)
+ alt 查无 或 密码不匹配
+ S-->>C: 1-002-090-005 用户名或密码错误(统一)
+ else 匹配且未禁用
+ S->>T: createAccessToken(...)
+ S-->>C: LoginRespVO(token...)
+ end
+```
+
+**防枚举口径的非对称,须讲清**。短信路对手机号做"防枚举静默转登录":发码不区分是否已注册、`sms-login` 未注册即自动注册,外部无从探测某号是否存在。用户名路做不到这种静默——注册时若用户名已被别人占用,不可能悄悄并进别人的账号,必须明确报"用户名已被占用"(错误码 `PLAYER_USERNAME_ALREADY_REGISTERED`),这在语义上不可避免地暴露了"该用户名存在"。这是用户名体系的固有代价,缓解手段就是本设计的 IP 限流:让批量刷用户名探测的成本变高。登录侧则收紧——"查无此用户名"与"密码错误"一律返回同一个 `PLAYER_USERNAME_OR_PASSWORD_ERROR`,不给区分,避免用登录接口反推用户名是否存在。只统一错误码还不够:若查无用户名时立即返回、密码错时才跑 BCrypt.matches(有意的高开销),响应时延就把"用户名存在(慢)/不存在(快)"区分开,登录接口仍是个存在性 oracle。因此 `passwordLogin` 查无用户名时也对一个固定的假 BCrypt hash 跑一次 matches 再返回,让两个分支耗时不可区分,把时序侧信道一并堵掉。
+
+**请求/响应 VO 契约(字段级)**:
+
+`PasswordRegisterReqVO`
+| 字段 | 类型 | 校验 | 说明 |
+|---|---|---|---|
+| `username` | String | `@NotEmpty` `@Length(4,30)` `@Pattern(^[a-zA-Z0-9]{4,30}$)` | 用户名,登录主键之一;沿用 admin `AuthLoginReqVO` 范式 |
+| `password` | String | `@NotEmpty` `@Length(6,32)` | 明文密码,服务层立即 BCrypt 编码;比 admin 的 4-16 收紧下限至 6,因本端点直面公网(admin 账号由运营在验证码后台内建,威胁模型不同——生产稳定优先)|
+| `nickname` | String | `@Size(max=30)` 可选 | 不传则默认生成(见下) |
+| `anonId` | String | `@Size(max=64)` 可选 | 客户端 anonId,仅注册落 `first_anon_id` 一次,匿名↔登录归因,与短信路同口径 |
+
+`PasswordLoginReqVO`:`username`(同上 Pattern)、`password`(`@NotEmpty`,登录不必重复长度校验以免给出策略线索)、`anonId?`。
+
+响应复用 `LoginRespVO`(`userId` / `accessToken` / `refreshToken` / `expiresTime` / `nickname` / `creatorFlag`),不新增结构。
+
+**registerPlayer 扩参**。现签名 `registerPlayer(mobile, channel, inviteCodeId, anonId, nickname, clientIp)` 把手机号钉死为入参。改为再收 `username` 与 `encodedPassword` 两个可空参数(短信/邀请码路传 null,用户名路传 mobile=null),插入时按路径落对应列。默认昵称生成 `generateNickname` 现取手机号后 4 位,用户名路无手机号,改为:有 `nickname` 用之,否则用户名路取 `username` 本身、手机号路维持后 4 位。`register_channel` 落 `password`。并发兜底:现有 `catch DuplicateKeyException` 回查转登录是针对 `uk_mobile` 的;用户名路命中 `uk_username` 冲突应报 `PLAYER_USERNAME_ALREADY_REGISTERED`(不转登录——注册态不能因为撞名就把人送进别人账号),据此分支需按 channel 区分冲突语义。
+
+**`/me` 补 `username`,并封空安全**。`PlayerMeRespVO` 现返回脱敏 `mobile`;用户名用户 `mobile` 为 NULL,前端拿不到可展示身份。补 `username` 字段(明文,用户名非敏感),`mobile` 保持脱敏且允许空。这里有个当前实现会踩的坑:`getPlayerMe`(`PassportServiceImpl.java:300`)现在无条件 `DesensitizedUtil.mobilePhone(player.getMobile())`,而用户名用户 `mobile` 恰为 null——`/me` 契约必须显式规定 `mobile` 为 null 时跳过脱敏、直接回空串,实现处对 null 短路(不把 null 塞进脱敏函数),否则用户名用户一调 `/me` 就可能 NPE 直接回归。这是一处响应契约扩展,前端 `PlayerMeResp` 接口同步加 `username`。
+
+**密码绝不落控制台,靠机制而非配置**。`@ApiAccessLog(sanitizeKeys={"password"})` 只脱敏一条链路——它由 `ApiAccessLogFilter` 消费、写进持久化的 `system_api_access_log`。但还有第二条链路会漏:`ApiAccessLogInterceptor.preHandle`(`ApiAccessLogInterceptor.java:44-52`)在 `!isProd` 时用 `log.info` 把**原始 requestBody** 直接打到控制台/stdout,不受 `sanitizeKeys` 约束,staging 现靠调低 `logging.level` 压着。密码是可跨站复用的持久凭据,价值远高于一次性验证码,任何环境抬高日志级别或聚合 stdout 就是明文外泄——不能只靠 staging 的 logging.level 配置兜。因此对 `password-register`/`password-login` 两端点做**代码级**保证:在所有环境让这两个路径的请求体不进 `ApiAccessLogInterceptor` 的 stdout 打印(按路径跳过该 interceptor 的 body 打印,或对这两个 URI 屏蔽 body),并在冒烟里加一条断言——注册/登录跑一遍后 grep 服务 stdout 必须搜不到明文密码。§5 的安全基线取证据此从「日志无明文」升级为「机制保证 + stdout 断言」,不接受仅配置压制。
+
+**错误码(1-002-090 段续接,api 模块 `ErrorCodeConstants`)**:
+| 码 | 常量 | 文案 |
+|---|---|---|
+| `1_002_090_004` | `PLAYER_USERNAME_ALREADY_REGISTERED` | 用户名已被占用 |
+| `1_002_090_005` | `PLAYER_USERNAME_OR_PASSWORD_ERROR` | 用户名或密码错误 |
+| `1_002_090_006` | `PASSWORD_AUTH_DISABLED` | 用户名密码登录通道未开启 |
+
+禁用账号复用 `PLAYER_IS_DISABLED(1_002_090_002)`,IP 越限复用全局 `TOO_MANY_REQUESTS(429)`——与短信路一致,不为限流新造码。
+
+### 3.3 IP 限流:落应用层纯 IP 计数,兼澄清"网关层"口径
+
+创始人原话是"网关层对同 IP 限流"。落地前须把口径对齐现实:产品端 `game-studio` 走单体直连(`.env.staging` `VITE_API_BASE=48080`),huijing-gateway 当前不在产品端链路、无活体网关实例。若字面执行"网关层限流",等于要为内测先把网关拉进产品链路,属于计划外的基建改动,与"内测能用就行"的验收强度不符。
+
+两个方案摆清:
+
+- **方案 A(推荐)·应用层纯 IP 计数**。复用 `PassportSmsIpCounterRedisDAO` 已经在用的模式——Redis 自增 + 首次设 TTL 的时/日双桶,纯 IP 维度(key 只含 IP 与日期,不含请求体)。在 `password-register`/`password-login` 的 Service 入层先增后判,越限抛 `TOO_MANY_REQUESTS`。今天就能跑、无新基建、能返回精确业务错误码、计数可观测。
+
+ **客户端 IP 的取法是这套防刷成不成立的命门,不能沿用发码路的 `ServletUtils.getClientIP()`**。它底层是 Hutool `JakartaServletUtil.getClientIP`,优先读 `X-Forwarded-For` 并取**最左段**——而最左段恰恰是客户端能自己写的。内测产品端浏览器单体直连 48080、链路里没有任何反代(§3.3 开头的现实),意味着 XFF 完全由客户端控制:攻击者每个请求塞一个新的伪造 XFF 就落进一个新桶,纯 IP 时/日闸被平凡绕过,用户名占用探测与密码暴破随之不受限。据此定死取 IP 的口径:**内测直连场景一律采信连接层 `request.getRemoteAddr()`,不读任何 XFF**(无可信反代就不该相信可伪造的头);将来公网上线在 48080 前置一层强制改写 XFF 的可信反代后,再改为只采信反代自己 append 的**最右侧受信段**,配可信代理白名单,绝不采信客户端可写的最左段。Service 入层从 controller 显式接 `request.getRemoteAddr()` 传入计数器,不走 `getClientIP()`。这样限流的桶键锚在真正的连接来源上,才对得起 §1「可防刷」的命名。
+- **方案 B(备选/纵深)·nginx `limit_req`**。在 48080 前置反代上按 `$binary_remote_addr` 限速,是字面意义的"网关/边缘层"。优点是请求还没进 JVM 就被挡;缺点是它给的是 503/自定义页而非项目的 `CommonResult` 业务错误码、防枚举语义无法在这层表达,且 staging 是否有可控的前置 nginx 尚未确认。
+
+**推荐 A 为内测主方案,B 作为公网上线时的纵深加固**(真有边缘反代后叠加,与图形验证码一并作上线闸门)。两者不互斥:A 保证业务语义与可测性,B 在更外层削峰。
+
+**一个必须避开的坑**:框架另有 `@RateLimiter` + `ClientIpRateLimiterKeyResolver` 注解式限流,但它的 key 是 `md5(方法名 + 方法入参 + IP)`(`ClientIpRateLimiterKeyResolver` 第 20-24 行)。用它保护 `password-register/login` 会把请求体里的 username/password 拌进 key——攻击者每次换一个用户名就落进不同的桶,纯 IP 限流形同虚设。因此这两个端点**不**走该注解,一律走 §3.3 方案 A 的服务层纯 IP 计数。`send-sms-code` 现在既挂了 `@RateLimiter`(key 含 mobile,非纯 IP,只挡同号同 IP 重放)又有服务层纯 IP 双闸;`sms-login` 与 `invite-register` 目前两样都没有,本设计把服务层纯 IP 计数补给它们。
+
+**计数组件的复用方式**。`PassportSmsIpCounterRedisDAO` 的 key 前缀写死为 `passport_sms_ip_*`、方法名是 `incrDay/HourAndGet`,语义绑死"发码"。若三个新场景(注册/登录/邀请码)直接调它,计数会与发码混桶、语义错乱。取最小且不漂移的改法:把它泛化为按 `scene` 参数分桶——key 变 `passport_ip:{scene}:hour:{ip}:{yyyyMMddHH}` / `:day:`,`scene ∈ {sms, register, login, invite}`;现有发码调用回填 `scene=sms`。要说清的是这里**计数语义等价、但不是无条件"行为等价"**:Redis key 字符串改了名,上线那一刻旧前缀 `passport_sms_ip_*` 的存量计数桶全部失效,等于限流窗口对已在计数的 IP 一次性重置,且原先盯旧前缀的观测/告警会断。这几件在内测都无害(桶 TTL 有界、只重置一次、观测同步改名即可),但落档要如实标为"一次性重置",不粉饰成零成本。这样每个场景独立时/日桶、阈值各自可配、单一组件承载,避免每个端点各抄一份计数代码。
+
+**频控后端故障时的姿态,须显式决策**。现有 `incrDay/HourAndGet` 直接 `stringRedisTemplate.increment` 且**无 try/catch**,Redis 抖动/超时会把异常一路抛穿——若不处理,回填后连注册/登录/`sms-login`/`invite-register` 会被频控后端的单点故障整体打挂,认证不可用。按外部交互红线定死姿态:频控计数对 Redis 异常取 **fail-open + WARN 告警**(捕获异常、记一条含 IP 与场景的告警日志、放行本次请求),理由是内测阶段认证可用性优先于把防刷做到严丝合缝,绝不让 Redis 单点静默拖垮登录;代价(Redis 挂时防刷短暂失效)记账接受,Redis 恢复后限流自动复位。这条决策写进泛化后的计数组件,不留给实现临场拍。
+
+### 3.4 配置开关与阈值(沿用 wanxiang.passport.* 段)
+
+新增开关一枚、阈值四对,全部进 `wanxiang.passport.*`,与既有 `invite-register-enabled` / `sms-ip-limit-*` / `sms-mock-*` 同段:
+
+| 键 | 默认 | 说明 |
+|---|---|---|
+| `wanxiang.passport.password-auth-enabled` | staging `true` | 用户名密码注册+登录总开关;关闭时两端点返回 `PASSWORD_AUTH_DISABLED`(信任边界在后端服务层,不靠前端隐藏 Tab)|
+| `wanxiang.passport.register-ip-limit-per-hour` / `-per-day` | 推断初值 20 / 60 | 注册场景 IP 时/日上限;注册比发码宽松一档,与运营确认后调 |
+| `wanxiang.passport.login-ip-limit-per-hour` / `-per-day` | 推断初值 30 / 100 | 登录场景 IP 时/日上限 |
+
+阈值为推断初值(`sms-ip-limit` 现为 10/30),标注"与运营确认后调整",不写死进代码常量。
+
+### 3.5 前端改动面(game-studio)
+
+`Login.vue` 现为 sms / invite 两 Tab,加第三个"用户名密码"Tab:用户名、密码两个输入框,前端做与后端一致的用户名正则与密码长度预校验(前端不是信任边界,仅提示),复用现有隐私政策勾选、`?redirect=` 回跳、`setLogin` 落持久化、`user_login` 遥测双带(userId+anonId)四段。登录成功路径与短信路完全一致(都拿 `LoginResp`)。
+
+`passport.ts` 加 `PasswordRegisterReq` / `PasswordLoginReq` 两个入参接口与 `passwordRegister` / `passwordLogin` 两个函数,严格贴新增的 VO 契约,走同一个 `request` 信封解包;`PlayerMeResp` 接口补 `username`。`locales/entry.zh|en` 加该 Tab 的字段标签、按钮、以及三个新错误码的前端文案回落(错误码文案后端已带,前端主要补 Tab 标题与占位符)。
+
+### 3.6 使用路径与接缝:WU2 new-api 开户充值 hook 的挂载点
+
+内测里"注册成功"是一个业务事件,WU2 要在此刻给新玩家在 new-api 侧开户并发放初始额度。本档只交挂载点与接缝契约,不实现:
+
+外部调用绝不能与本地建号事务耦合——new-api 若超时或 502,不该把一个已经合法的注册回滚掉,也不该让用户卡在注册请求上等外部系统。但"解耦"不等于可以丢消息:如果只是 `afterCommit` 里直发 MQ,事务已提交、消息却在 produce 侧发送超时/失败或应用此刻崩溃,就会出现"注册成功了、开户事件从未入队"的黑洞,重试队列/死信只能兜"已投递但消费失败",兜不到根本没发出的那批——"先能登录、额度稍后补"会在这个丢消息窗口里静默落空。按"生产稳定>开发期简单"红线,`afterCommit` 直发是 lean 路径,生产级现货是事务性 outbox,本档对 WU2 的接缝按**至少一次**收紧、定死一种机制,不留给实现自选:
+
+- **机制(定死一种·本地消息表 outbox)**:注册事务**内**同库写一行 outbox 记录(`registerPlayer` 提交时与建号原子落库,载荷含 `userId`、`registerChannel`、`anonId`、幂等键);事务外由投递器扫 outbox 发 RocketMQ、发成功后标记已投递。建号成功则 outbox 记录必已落库,投递器保证最终发出——把"至少一次"锚在数据库事务上,而非 MQ 的即时可达性上。三条注册路(sms 自动注册、invite、password)共用这一张出口,WU2 无需感知登录方式。(若后续统一走 RocketMQ 事务消息亦可,但那也是"至少一次"的一种实现,接缝语义不变。)
+- **生产者归属**:写 outbox 是 **WU1 的交付**(本单只做生产者),消费端 new-api 开户充值随 WU2;本单不实现消费。这样验收项"注册成功事件被发出"有对应交付物,不成孤儿。
+- **幂等**:以 `userId` 为幂等键,new-api 开户与充值必须可重放不重复发额(同一 userId 二次消费为 no-op)——因为"至少一次"必然带来重复投递,消费侧幂等是配套硬要求。
+- **超时/失败/重试**:投递器发 MQ 设超时;发失败保留 outbox 未投递态下轮重投(有限次退避)。消费侧调 new-api 设超时;消费失败进重试队列(有限次退避)。
+- **补偿**:投递或消费重试耗尽落死信/告警,由运营侧补开户;注册本身已成功,不因开户失败对用户可见地失败——玩家先能登录,额度稍后补。
+
+本设计对 WU2 的硬要求两条:一是 hook 消费的是"注册成功"事件而非"password 注册成功"事件,口径统一在三条路的公共出口;二是投递语义是至少一次(outbox 兜底),消费侧据此做幂等,WU2 不得实现只在 afterCommit 直发的有损版本再自称达标。
+
+## 4 步骤计划
+
+代码物随执行阶段(阶段 1)落地,本档只定序与验收。
+
+```mermaid
+flowchart LR
+ S1["① 契约
Flyway V31 + VO + 错误码 + PlayerDO/Mapper"] --> S2["② 服务层
registerPlayer 扩参 + password 两方法 + BCrypt + IP 计数泛化"]
+ S2 --> S3["③ 端点+配置
两 @PermitAll 端点 + wanxiang.passport 开关阈值 + 回填 sms/invite IP 闸"]
+ S3 --> S4["④ 前端
Login.vue Tab + passport.ts + locales"]
+ S4 --> S5["⑤ 真机验收
注册→登录→限流→零回归 + hook 挂点冒烟"]
+```
+
+| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
+|---|---|---|---|---|
+| ① 契约 | V31 迁移(两处:契约源+执行副本)、`PasswordRegister/LoginReqVO`、`LoginRespVO` 复用、`PlayerMeRespVO` 加 username、三个错误码、`PlayerDO`+`PlayerMapper.selectByUsername` | Flyway 迁移在 dev 库应用无版本乱序/无同号;建表 DDL 校验通过 | 落地前 `ls .../db/migration \| sort -V \| tail` 硬复核执行副本最大号再定号 | mobile 放宽的存量兼容(放宽为松约束,低风险)|
+| ② 服务层 | `registerPlayer` 扩参、`passwordRegister/passwordLogin`(含查无用户名跑固定假 hash 对齐耗时)、BCrypt 编码校验、`PassportSmsIpCounterRedisDAO` 泛化 scene + Redis 异常 fail-open 告警、**注册事务内写 outbox(注册成功事件生产者)** | 服务层单测:注册占用、登录防枚举(错误码+耗时不可区分)、禁用拒登、并发撞名兜底、IP 越限抛 429、Redis 异常放行且告警、outbox 记录随建号原子落库 | ① | 泛化计数组件 key 改名致存量桶一次性重置(TTL 有界、内测无害,如实记账)|
+| ③ 端点+配置 | 两 `@PermitAll` 端点(`@ApiAccessLog(sanitizeKeys={"password"})` **且代码级绕过 `ApiAccessLogInterceptor` 的 stdout body 打印**)、Service 入层取 `request.getRemoteAddr()` 传入计数、`wanxiang.passport.*` 新键、回填 `sms-login`/`invite-register` 的服务层 IP 闸 | 端点级:Knife4j/curl 打通;回填不改短信/邀请码语义;冒烟 grep stdout 无明文密码 | ② | IP 取连接层 remote-addr(不采信 XFF);上线接反代后须切最右受信段 |
+| ④ 前端 | `Login.vue` 用户名密码 Tab、`passport.ts` 两函数+`PlayerMeResp.username`、locales | 构建通过;本地联调三 Tab 均可登录 | ③ | 与既有两 Tab 状态不串 |
+| ⑤ 真机验收 | dev 单点串行 e2e | §5 判据全绿 | ①-④ | — |
+
+## 5 验证方式
+
+全部可真机取证或机器验,判据与 §1 目标一一对应:
+
+- **注册返 token**:对未占用用户名 POST `password-register`,返回体含非空 `accessToken`/`refreshToken`、`userId`>0;库内 `game_player` 出现 `username=该名, mobile=NULL, register_channel=password, password` 为 BCrypt 密文(`$2a$` 前缀、非明文)。
+- **登录成功且防枚举**:用同一对凭据 POST `password-login` 返回 token;密码错一位 → `1-002-090-005`;换一个不存在的用户名同样是 `1-002-090-005`(两者文案一致,不可区分);抽样比对"查无用户名"与"密码错误"两分支的响应时延处于同一量级(固定假 hash 已对齐 BCrypt 耗时),不构成存在性 oracle。
+- **占用可见、登录不可见**:重复注册同用户名 → `1-002-090-004 用户名已被占用`(这是有意暴露,配合限流)。
+- **IP 限流生效**:同 IP 连续注册/登录超过配置阈值 → `429 请求过于频繁`;日志出现限流 WARN 且含 IP 与计数;Redis 见 `passport_ip:register:*` / `:login:*` 计数桶。
+- **伪造 XFF 不能绕过**:携带每次变化的伪造 `X-Forwarded-For` 高频打注册/登录,仍按连接来源被 429(证明限流锚在 `getRemoteAddr()`、不采信客户端可写的 XFF)。
+- **回填生效**:同 IP 高频打 `sms-login` / `invite-register` 同样被 429(此前无此保护)。
+- **存量零回归**:短信验证码登录(含 `sms-mock` 8888)、自动注册、邀请码注册四条路真机各跑一遍,行为与改动前一致;OAuth2 token、`/me`、创作守卫不受影响。
+- **token 后续可用 + /me 空安全**:password 路拿到的 token 调 `/me` **不 NPE**、返回该用户身份(`username` 有值、`mobile` 为 null 时回空串不报错),并能通行一次需登录态的下游调用——证明与短信路 token 等价、且脱敏对 null 已封空安全。
+- **开户事件生产者冒烟**:注册成功后 outbox 表出现对应记录且被投递器发出 MQ(日志/MQ 可观测),验证生产者已交付;WU2 实现前此处只验事件被发出,不验开户结果。
+- **安全基线(机制保证,非配置压制)**:`system_api_access_log` 中 password 键被脱敏、不见明文;且冒烟里对 `password-register`/`password-login` 跑一遍后 grep 服务 stdout/控制台断言搜不到明文密码(覆盖 `ApiAccessLogInterceptor` 那条链路);服务日志只见 `userId`,无密码、无 token。
+
+## 6 风险与回滚
+
+- **客户端 IP 取法**:限流按 §3.3 采信连接层 `request.getRemoteAddr()`、不读客户端可伪造的 XFF,堵死「伪造 XFF 换桶绕过」这一最危险方向;§5 专门有一条「伪造 XFF 高频打注册/登录仍被 429」取证。反方向的残余前提是——将来公网上线在 48080 前置可信反代后,`getRemoteAddr()` 会塌成反代地址、限流退化为全局闸;届时必须切到「只采信反代 append 的最右侧受信段 + 可信代理白名单」,这是上线闸门的一项,不在内测范围内提前做。
+- **单账号多 IP 暴破**:本单防刷是纯 IP 单维度,没有按 `username` 的失败登录节流或锁定。配合弱口令下限(6 位、无复杂度、无图形验证码)与 login 默认 100 次/日/IP,攻击者用多 IP 轮转打同一个目标账号时,每个账号没有独立上限。内测阶段先记账接受该残余风险,触发处置是「一旦观测到针对单账号的高失败率,立即用 §3.3 的 scene 计数器加一个按 `username` 的失败桶做短时锁定」——组件现成(scene ∈ 增加 `login_fail`),越限临时锁定该账号;账号级失败锁定与图形验证码同列为公网上线闸门。
+- **密码策略偏弱**:内测取 `@Length(6,32)`、无复杂度要求、无图形验证码,靠 IP 限流兜防刷。这是"内测能用就行"的刻意取舍;公网上线前把图形验证码/滑块与密码复杂度一并补上(与 `sms-mock` 关闭、短信网关接入同属上线闸门)。
+- **用户名枚举**:注册占用错误不可避免地暴露用户名存在性,限流是唯一缓解。若内测发现被刷,先下调 `register-ip-limit`,再考虑提前上图形验证码。
+- **总开关误关**:`password-auth-enabled=false` 会让两端点直接拒服务;它是后端信任边界的一部分,前端仅据此隐藏 Tab,不能反向依赖前端。
+- **回滚**:功能级回滚 = `password-auth-enabled=false`(端点即时停用,无需改码)。schema 级回滚 = 补偿迁移 `V31.0.1`,`mobile` 收回 NOT NULL 前须先确认/清理无手机号用户,否则收回失败。前端回滚 = 隐藏用户名密码 Tab。三层可独立回退,互不阻塞。
+
+## 附 图清单与状态
+
+- 图0 一图看懂(§0,Mermaid,事实源)— 现行
+- 图1 单表双身份路径共存(§3.1,Mermaid)— 现行
+- 图2 注册/登录时序含 IP 闸与开户 hook(§3.2,Mermaid)— 现行
+- 图3 步骤计划(§4,Mermaid)— 现行
+
+SVG 门面(§0 概览大图,给人 30 秒看懂)按 feature-design-doc 规约由图层 opus 子代理据本文字+Mermaid 派生,尚未产出,收口前补;图文冲突以本文字为准。本档过双评审门(Codex + Opus,Codex 挂回落 Opus 单评)后交创始人批,批准后随阶段 1 落代码物。
diff --git a/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md b/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md
new file mode 100644
index 00000000..2aeec6c8
--- /dev/null
+++ b/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md
@@ -0,0 +1,370 @@
+---
+date: 2026-07-07
+topic: newapi-per-user-quota
+status: 草案
+sot-impact: 修订「生成引擎运行时」(§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)
+关联:
+ - game-cloud/huijing-module-system/.../passport/PassportServiceImpl.java:214 @ cb9f135d(WU1 注册锚,本设计不改它,只消费其 afterCommit 事件)
+ - game-cloud/game-module-aigc/.../service/task/AigcTaskServiceImpl.java:202-204 @ cb9f135d(编排器/bake-off「伪装在线提交」经 submitGenerate→dispatchGeneric,task_source 现全落 online)
+ - game-cloud/game-module-aigc/.../executor/AigcGenerateExecutor.java:658-681 @ cb9f135d(dispatchGeneric 组 §6.1 job,运行在有界消费池 consumeThreadMax=15)
+ - game-cloud/game-module-aigc/.../executor/ExecutorLlmClient.java:124-149 @ cb9f135d
+ - game-cloud/game-module-aigc/game-module-aigc-api/.../enums/FailureReasonEnum.java @ cb9f135d(跨契约共享枚举,头注禁单边扩展)
+ - contracts/api-schemas/aigc.yaml:296 · contracts/dify-workflow-io.json:50 @ cb9f135d(两处 FailureReason enum,须同批加 quota_exhausted)
+ - game-cloud/game-module-trade/.../dataobject/GrantDO.java @ cb9f135d
+ - wg1/gen-worker/worker/_client.py:47-57 · wg1/gen-worker/worker/cost.py:8-15 @ cb9f135d
+ - cheap-worker/cheap_service_driver.py:28-44 · cheap-worker/worker_service.py:142-146 @ cb9f135d
+图清单: [图0 一图看懂(SVG), 图1 六接线点数据流(mermaid), 图2 池条目 FREE→CLAIMED claim 状态机(mermaid), 图3 生成期额度扣减与失败时序(mermaid)]
+---
+
+# 内测 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 直接当额度账本(权威余额=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 层)。
+
+```mermaid
+flowchart TB
+ subgraph IN["做(本设计闭环 · 六接线点 A-F)"]
+ A["A 管理凭据
ops 脚本用·不进运行时"]
+ B["B 离线 ops 预置脚本
预建 N×(user+token+¥100)"]
+ C["C 注册 claim 消费者
从池 CAS 绑定·渠道过滤"]
+ D["D 额度池表
FREE/CLAIMED 条目"]
+ E["E job 加 userToken 位
§6.1 契约追加字段"]
+ F["F worker 取 job token
缺失回落全局 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)。下图是事实源,逐点的字段契约在其后展开。
+
+```mermaid
+flowchart LR
+ subgraph OFFLINE["离线预置(ops 脚本 · A·B·D)"]
+ OPS["ops 预置脚本
建户 API→DB 写 access_token
→以该用户建 token API→设 quota API"]
+ OPS -->|凭据 A + postgres 脏活| NAP["new-api:预建 N×(user+token+¥100)"]
+ NAP -->|脚本回写条目 FREE| POOL["额度池表 newapi_quota_pool
N 条 FREE (D)"]
+ end
+
+ U[用户] -->|注册| REG["registerPlayer(WU1)
注册事务提交"]
+ REG -->|同事务| PL["game_player 落库"]
+ REG -.->|afterCommit 发事件| EVT["注册成功事件/RocketMQ
userId·registerChannel·anonId"]
+ EVT -->|WU2 消费(C)· 渠道过滤| CLAIM["从池 CAS claim 一个 FREE
绑 game_player·写 biz_no"]
+ CLAIM -->|抢占 FREE→CLAIMED| POOL
+ CLAIM -.->|池空| ALERT["不抛·水位告警
懒 claim 兜(§3.6)"]
+
+ U -->|创作生成| GEN["dispatchGeneric
组 §6.1 job (E)"]
+ GEN -->|creatorUserId 是否为 member?| MEMB{有 game_player 行}
+ MEMB -->|否 系统/编排/bake-off| GLOB["旁路:回落全局 key
不懒 claim"]
+ MEMB -->|是 真实 member| LOOK["查池表该玩家 CLAIMED 条目"]
+ LOOK -->|命中| TOK["job.userToken=token_key"]
+ LOOK -->|无 CLAIMED 条目| LZ["懒 claim 单 CAS 抢一次
池空→干净失败+水位告警"]
+ LZ -->|抢到| TOK
+ TOK -->|job.userToken| WK["worker (F)"]
+ WK -->|用 job.userToken 调网关| NA["new-api 网关"]
+ NA -->|扣该 user 额度| LEDGER["token remain_quota 减
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=1` `qingse`(`role=100`、`status=1`),持 32 字符 system access_token(存 new-api postgres `users.access_token`)。
+- **鉴权头**:`Authorization: Bearer ` **且** `New-Api-User: 1`(缺 `New-Api-User` 头即 401)。这是 New API 分支(build `fd3e95ac2902-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 坐实的唯一可行路径)**:
+
+1. `POST /api/user/`(API,root 令牌)建用户,username 用确定性命名 `neice_<序号>`(`neice_001`…`neice_200`),拿 `newapi_user_id`。
+2. **DB 直写 new-api postgres**(`infra-postgres:5432/new-api`):`UPDATE users SET access_token=<确定值> WHERE id=`——这一步是 S0 坐实的关键,`access_token` 无法经 API 设、又是下一步「以该用户身份建 token」的前提。
+3. `POST /api/token/`(API,**用该用户的 access_token + `New-Api-User:`**,即以该用户身份)建 token,name 用 `neice_<序号>`、`unlimited_quota=false`、`expired_time=不过期`,拿 48 位 `token_key`。
+4. `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_' 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 重跑脚本补池。这是池模型的核心运维信号,进观测。
+
+```mermaid
+stateDiagram-v2
+ [*] --> FREE: ops 脚本离线预建
(user+token+¥100) 写池表
+ FREE --> CLAIMED: 注册/懒 claim CAS 抢占
绑 game_player·写 biz_no
+ CLAIMED --> INVALID: 运行时发现 token 失效(401)
隔离·告警 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_`;`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`(`AigcTaskDO` 38 行既有字段)→ 查 `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` 的头注明写「取值与 Dify `output.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_exhausted` 15 字符,容得下)。
+- **消费方对未知值的容错须落地核实、非断言**:前端 `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。内测把池条目 token `expired_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`**,验收对同一口径。
+
+1. **预置池**:ops 脚本跑一次,`SELECT count(*) FROM users WHERE username LIKE 'neice_%'` 与 `tokens` 各新增 N 行、每 token `remain_quota` = ¥100 折算值(`unlimited_quota=false`,默认约 6,849,315);game-cloud `SELECT count(*) FROM newapi_quota_pool WHERE status='FREE'` = N,`token_key`/`grant_quota`/快照三元落齐。
+2. **注册 claim**:注册一个内测用户(走 WU1 password 路)后,池里恰有一条 `FREE→CLAIMED`、`claimed_by_game_player_id`=该 `game_player.id`、`biz_no='claim_'`、`claimed_at` 非空;池 `FREE` 计数 -1。
+3. **生成扣减**:记录一次真 gen 前后该 claim 到的 user `SELECT used_quota FROM users WHERE id=`,`Δused_quota > 0`;且该 token `remain_quota` 相应减少(权威余额闸)。
+4. **耗尽干净拒**:`PUT` / SQL 把该 token `remain_quota` 设近 0 → 触发一次 gen → 观察网关返 402/403 且后端任务终态 `failure_reason=quota_exhausted`(而非 `llm_error`),前端拿到可读拒因。
+5. **claim 幂等**:对同一 `game_player_id` 重放注册成功事件(或手工重投消费),池不新增第二条 `CLAIMED`、该玩家仍绑同一条(`uk_claimed_by` + 单 CAS 拦住);并发重投时验只有一条路径把某条 `FREE→CLAIMED`。
+6. **池空降级**:把池 `FREE` 清零后注册一个用户,注册(WU1)成功可登录、该玩家无 `CLAIMED` 条目、水位告警触发;ops 补池(重跑脚本)后对该玩家首次生成触发懒 claim 绑上(或定时补绑腿)。
+7. **越界不误伤**:驱动一次 bake-off / n=5 收敛环批跑(系统/编排触发,无 `game_player` 成员身份),验其 job 不被 per-user 门挡、`userToken` 留空、worker 回落全局 key 正常出图(证 §3.6 身份分流成立)。
+8. **存量 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→CLAIMED` CAS,重投 / 并发收敛成一条——不会给同一玩家占第二个条目、也不会两玩家抢到同一条。这比原方案简单:claim 是纯 DB 操作,没有「HTTP 副作用无法回滚」的问题。
+- **越界不误伤既有验证线**:per-user 门只对真实 member(`creatorUserId` 命中 `game_player`)生效;编排器批跑/bake-off/n=5 收敛环「伪装在线提交」经 `submitGenerate→dispatchGeneric`(`AigcTaskServiceImpl` 202-204)同样流经派发点,无成员身份即旁路回落全局 key、绝不懒 claim(否则替一个 admin/system id 白占一个 ¥100 池条目)。堵点在 dispatch 层按身份分流,不在 worker 层。
+- **账本口径唯一**:权威余额 = token `remain_quota`、权威消耗 = user `used_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` + 可重试失败),池条目 token `expired_time` 由 ops 脚本设不过期避免自伤。
+- **凭据面**:`newapi_token_key` 明文落池表、随 job 内网传输,内测可接受但须日志脱敏;claim 按 `registerChannel` 过滤——`sms-mock` 8888 后门与一次性手机号自动注册不 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`。收口时按 frontmatter `sot-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 真机确认。
diff --git a/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md b/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md
new file mode 100644
index 00000000..4895a029
--- /dev/null
+++ b/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md
@@ -0,0 +1,246 @@
+---
+date: 2026-07-07
+topic: 内测-dify游戏开发节点-形态A
+status: 草案
+sot-impact: 无 canonical 修订——本设计不改 §6.1 job/result-out 契约、不改生产生成 runtime、不改契约#6(dify-workflow-io.json,反而澄清其历史身份)。仅两处收口回写:① 实施落地后回写《内网凭据与端点》(canonical)补 dify(nginx :18080/:18443、plugin_daemon :15003)与便宜档 cheap-worker(:9501)、cheap Service(:8300)的 dev 端点及 dify 专用 new-api token;② 若该内测通道日后转常设入口,再回写《生成引擎运行时》(agentic运行时架构图说,canonical)补一条「dify workflow 旁路 ingress」注记。本档阶段只申报影响面,不动 canonical。
+上级: docs/mvp/MVP作战清单.md
+关联: |
+ cheap-worker/worker_service.py(:34 DEFAULT_PORT=9501、:192-204 try_enqueue 按 job_id/traceId 在 _seen 去重、:514-547 /generate 入口、:82-105 HMAC 签名、:229-256 落盘与回调 status=succeeded/failed、:570 main 硬编码 host=0.0.0.0 无 --host) ·
+ cheap-worker/cheap_service_driver.py(:29-32 NEWAPI_KEY 从进程 env 取、非 per-job token、:200-276 驱动 cheap Service /chat @127.0.0.1:8300) ·
+ cheap-worker/cheap_budget.py(:18-37 单局 ¥10 软停/¥15 硬地板、rmb_hard_limit/soft_budget override 被 pop、调用方不可改) ·
+ cheap-worker/cheap_run.py(:42-44 game_dir=games/amgen-/、:220-234 scaffold 先克隆 _template/src 7 文件) ·
+ game-cloud/.../executor/AigcGenerateExecutor.java(:658-673 §6.1 job 组装) ·
+ game-cloud/.../controller/admin/task/AdminAigcTaskController.java(:113-169 /dify/callback-internal) ·
+ game-cloud/.../service/executor/CallbackSignatureVerifier.java(:63-99 enabled/verify) ·
+ game-cloud/.../service/callback/DifyCallbackTxService.java(:131-135 selectByTraceId→AIGC_TASK_NOT_EXISTS) ·
+ contracts/dify-workflow-io.json(契约#6,历史方向,本档不改) ·
+ commit cb9f135d
+图清单: [图1 拓扑总览, 图2 回流落点三选项决策, 图3 触发-生成-回流时序, 图4 做与不做边界]
+---
+
+# 内测 · dify 游戏开发节点直连 agentscope 生成线 —— 形态 A 落地设计
+
+## 0 一图看懂
+
+内测要交付的能力只有一句话:在 mini-infra 上那套已经部署好的 dify 里搭一条最小 workflow,其中一个「游戏开发节点」(一个 HTTP Request 节点)把用户的一句话创意组成后端生成线早已定义的 §6.1 job,直接 POST 给便宜档的 cheap-worker `:9501/generate`,由现成的 agentscope 生成线跑出一款小游戏。后端近零改动——不新增接口、不改生成主线、不碰契约。
+
+```mermaid
+flowchart LR
+ subgraph MI["mini-infra 100.64.0.8"]
+ D["dify 1.15.0
nginx :18080/:18443"]
+ DN["游戏开发节点
(HTTP Request)"]
+ NA["new-api :3000
(LLM 网关)"]
+ D --> DN
+ end
+ subgraph GEN["便宜档生成栈 (dev,如 mini-desktop)"]
+ W["cheap-worker :9501
/generate 有界队列"]
+ S["cheap Service :8300
/chat agentscope 生成"]
+ DIR[("game_dir 落盘
src/ 多文件源工程")]
+ W --> S
+ S --> DIR
+ end
+ DN -->|"§6.1 job POST
(Tailscale)"| W
+ S -->|"OpenAI 兼容调模型"| NA
+ W -.->|"回流三选项
见 图2"| OUT{{"产物落点"}}
+ DIR --> OUT
+```
+
+**核心思想**:dify 只是又一个「组 §6.1 job 的调用方」,替换掉后端 `AigcGenerateExecutor` 的角色;job 契约与 worker 生成核心一字不改,复用现货把「agentscope 能被 dify workflow 驱动」这件事跑通。
+
+**边界**:内测形态 A 是一条**独立生成通道**——产物落在 worker 的 `game_dir` 与 dify 自己手里,**不进 game-cloud 的发布/feed**,**不扣任何用户的 new-api 个人额度**。要让 dify 产物进 feed 属于形态 B(需要一个能建 task 记录的服务间免登录触发/回调面),工作量与攻击面都上一个台阶,内测不做。
+
+**怎么算成功**:从 dify(`:18080`)触发一次 workflow → cheap-worker `:9501` 收到 job 并回 202 → agentscope 真跑一局 → worker 收口日志报 `status=succeeded`、`game_dir` 落定产物。注意成功判据是那行收口日志,**不是**「`src/` 非空」——scaffold 会在真生成前先把 `_template/src`(7 个文件)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「src/ 非空」,单看它区分不出成功与失败(详见 §5)。这一条端到端链路走通,即「内测前支持 dify workflow」成立。
+
+> §0 的门面 SVG 概览图随收口由图层子代理据本节文字与图1 派生补上;当前草案以 Mermaid 为事实源。
+
+## 1 意图与目标
+
+内测要对内证明一件事:平台的游戏生成能力不只能被自家后端的执行器驱动,也能被一个通用的 workflow 编排器(dify)当作一个节点挂进去调用。这关系到后续「B 端定制」与「把生成能力对外暴露成可编排的一步」这条叙事——如果 agentscope 生成线只能被 game-cloud 内部那条异步队列唤起,它就还只是一个封闭子系统;一旦它能被 dify 这类外部编排面以标准 HTTP 调起,它才具备「作为一个可复用生成算子被别的流程组合」的形态。
+
+之所以现在能几乎零成本做成,是因为生成线早就把「投递面」设计成了纯 HTTP + 一份稳定的 job 契约。后端的 `AigcGenerateExecutor` 组一份 §6.1 job(`job_id`/`tier`/`kind`/`brief`/`model`/`budget`/`gameId`/`templateId`/`renderCtx`/`idempotency_key`/`deadline_ms`/`callback`/`traceId`),POST 给 cheap-worker 的 `/generate`,worker 入队后返 202、在后台线程跑 agentscope、完成后按 `callback.target` 把结果 HMAC 签名发回。这套投递面对调用方是谁并不关心——它只认 job 的形状。dify 的一个 HTTP Request 节点完全可以充当这个「组 job 的人」。
+
+目标就落在这条最短路径上:
+
+- 在 dify 里建一条最小 workflow,含一个把 §6.1 job 打给 cheap-worker `:9501/generate` 的 HTTP「游戏开发节点」;
+- 把 dify 的输入(用户一句话 brief)映射进 job 字段,鉴权与超时按内网现实配好;
+- 明确 dify 生成的游戏走哪条回流、落在哪里,并把「不进 feed、不扣个人额度」这两条边界钉死,避免和后端生产链路、和 WU2 的计费门互相污染;
+- 交付一次可真机取证的端到端 demo 作为验收。
+
+不做的是任何后端生成主线的改动,以及契约 #6 里「后端主动调 Dify」那个历史方向。
+
+## 2 边界
+
+```mermaid
+flowchart TB
+ subgraph IN["做 (in scope)"]
+ I1["dify 最小 workflow + 一个 HTTP 游戏开发节点"]
+ I2["dify 输入 → §6.1 job 字段映射(复用现有契约,不改)"]
+ I3["回流落点决策 + 边界钉死(不进 feed)"]
+ I4["额度接缝:dify 专用 token,不扣个人额度"]
+ I5["安全:内网 + HMAC 两端非空约束(若用回调)"]
+ I6["一次真机取证 demo 作验收"]
+ end
+ subgraph OUT["不做 (out of scope)"]
+ O1["契约#6「后端主动调 Dify」方向"]
+ O2["改后端生成主线 / §6.1 job 契约 / 生产 runtime"]
+ O3["dify 产物进 game-cloud 发布/feed(=形态B)"]
+ O4["把 dify 产物计入创作者账户/个人额度"]
+ O5["dify workflow 内自建安全/意图/质量多节点(契约#6 旧六节点形态)"]
+ end
+```
+
+在范围内的是 dify 侧的 workflow 与节点、字段映射、回流与额度的边界判断、以及安全与验收。这些改动几乎全部落在 dify 里(一份 workflow 定义)和运维配置里(端点、token、密钥),后端代码近零改。
+
+明确排除四类。其一,契约 #6(`contracts/dify-workflow-io.json`)描述的是「Java 壳主动调 Dify workflow、Dify 内跑六个节点再把结果回给 Java 壳」这个反方向,工作量最大、且依赖一个从未部署过的 Dify 生成能力,内测不碰。其二,后端生成主线、§6.1 job 契约、生产 runtime 一律不动——形态 A 的全部价值恰恰建立在「什么都不改就能被驱动」上,一旦要改后端就说明选错了形态。其三,把 dify 产物送进 game-cloud 的发布与 feed,这需要一个能在后端建 task 记录的服务间触发面,属于形态 B,见 §3.3 的论证。其四,把 dify 的试验性生成消耗计入任何真实创作者的额度账,见 §3.4。
+
+## 3 方案
+
+### 3.1 拓扑:dify 顶替执行器的「组 job」角色
+
+现行生产链路里,一次便宜档生成是这样流动的:用户在 studio 提交创意 → 后端建 `game_aigc_task` 记录、进异步队列 → `AigcGenerateExecutor` 认领任务、组 §6.1 job → 投给 cheap-worker `:9501/generate` → worker 入队返 202 → 后台线程调 cheap Service `:8300/chat` 跑 agentscope → 产物落 `game_dir`、并把 result-out 按 `callback.target` HMAC 回发 `/dify/callback-internal` → 后端三表事务落包、进发布与 feed。
+
+形态 A 只替换这条链的**最前一段**:把「后端建 task + 执行器组 job」换成「dify 的 HTTP 节点组 job」。从 worker 往后(入队、agentscope 生成、落盘)一字不动。差别只在两处,而这两处正是本设计要解决的真问题:dify 组的 job 没有对应的后端 task 记录(决定了回流落点,§3.3),dify 走的不是用户的计费门(决定了额度接缝,§3.4)。
+
+一个容易忽略但已由读码坐实的事实:cheap-worker 的生成核心(默认 `run_fn` = 驱动 cheap Service `/chat`)真正消费的 job 字段只有 `brief` 与 `gameId`(`cheap_service_driver.py:226,229`),`renderCtx`/`template_schema`/`banned_list`/`findings` 在便宜档生成路径里**并不被读取**(后端组它们是为 SAA 图路与未来引擎线预留)。这把 dify 节点要造的 job 大幅简化——dify 不必去复刻后端 `promptResourceLoader` 那套模板 schema 装配,只要 `brief` 有内容、`templateId` 给 `generic`、`gameId`/`traceId` 给一个 dify 自造的唯一值,生成就能跑。其余 §6.1 字段按契约给合理默认即可,worker 不会因为缺它们而失败。
+
+### 3.2 游戏开发节点:一个 HTTP Request 节点组 §6.1 job
+
+「游戏开发节点」就是 dify workflow 里的一个 HTTP Request 节点。它的三要素——目标、请求体、鉴权与超时——如下。
+
+**目标 URL**:`http://<便宜档生成栈内网 IP>:9501/generate`。dify 跑在 mini-infra(`100.64.0.8`),cheap-worker 在 dev 阶段通常跑在 mini-desktop(`100.64.0.7`)或与生成栈同机;两机同在 Tailscale 网内,dify 的 HTTP 节点经 Tailscale 直连 worker `:9501`。**内网直连必须绕过系统代理**(本项目反复踩的坑:系统代理 fake-ip 会把 `100.64.x` 劫走),dify 的 HTTP 节点若经其 sandbox 出网,需确认 sandbox 的 `NO_PROXY` 覆盖目标 IP,否则连不上。
+
+**请求体**:一份 §6.1 job。字段映射见下表(这是复用既有契约,不是新增契约)。
+
+| §6.1 job 字段 | 形态 A 取值 | 来源 / 说明 |
+|---|---|---|
+| `brief` | dify workflow 输入的用户一句话创意 | dify「开始」节点的输入变量,映射进来;worker 生成核心真正消费 |
+| `gameId` | dify 自造唯一串,建议 `dify-` | worker 用作 `game_dir` 目录名与 OTLP conversation.id;形态 A 下无后端含义 |
+| `traceId` | 与 `job_id` 同值,建议 `dify-` | worker 以 `traceId`(缺则 `job_id`)为幂等/追踪键;**必须加 `dify-` 前缀**做命名空间隔离,见 §3.5 |
+| `job_id` | 同 `traceId` | 贯穿 job↔result 的键 |
+| `templateId` | `"generic"` | worker `result_out.py` 缺省即 `generic`,显式给以稳形 |
+| `tier` | `"L1"` | 便宜档裸路,与执行器同值 |
+| `kind` | `"generate"` | 单局生成 |
+| `model` | dify 侧配的便宜档模型名(如 `MiniMax-M3`) | worker 侧 cheap Service 实际按自身配置取 key/model;此字段透传,建议与生成栈配置一致 |
+| `budget` | `{"maxYuan":0.15,"maxLlmCalls":8}` | 契约字段,透传即可;**它不承载封顶**——单局真实成本上界由服务端 `cheap_budget` 固定的 ¥10 软停 / ¥15 硬地板决定(`build_cheap_breaker` 把 `rmb_hard_limit`/`soft_budget` 的调用方 override 直接 pop 掉),job 里写 0.15 是死字段,别据它产生「单局最多 ¥0.15」的错觉,见 §3.4 |
+| `renderCtx` | `{"template_schema":"","banned_list":"","findings":""}` 或整体省略 | **便宜档生成路径不消费**;给空壳或不给都不影响生成 |
+| `idempotency_key` | 同 `traceId` | 契约字段,透传 |
+| `deadline_ms` | 任务级墙钟(如 `600000`) | 与 watchdog 协同;给一个合理上限即可 |
+| `callback` | 见 §3.3 决策 | 形态 A 默认**不设**(a-min);若走 a-dify 则指 dify 侧捕获入口 |
+
+**鉴权与超时**:worker 的 `/generate` 当前对入站不做鉴权(信任边界靠内网不可外达,见 §3.5)。超时要分清两段——`/generate` 是**异步投递面**,worker 入队后**立即返 202**(`{accepted,job_id,traceId,queued}`),真正的 agentscope 生成在后台线程跑、可能几十秒到几分钟。所以 dify HTTP 节点的超时设成**握手级**(约 10 秒足够,只等 202/503),**绝不能**把它设成等生成完成的长超时——生成结果不会从这个 HTTP 响应回来,它经回调或落盘回流。队满时 worker 返 503,dify 节点据此快速失败。
+
+### 3.3 回流落点:dify 生成的游戏走哪条回流(核心难点)
+
+这是形态 A 唯一的真设计难点。worker 生成完会按 job 里的 `callback.target` 把 result-out HMAC 签名发出去。生产链路里这个 target 指向后端的 `/dify/callback-internal`,后端在那里做三表事务落包、进发布与 feed。但形态 A 下 job 是 dify 自造的,`traceId` 是 dify 编的,**后端根本没有一条 `game_aigc_task` 记录与之对应**。
+
+这不是猜测,是读码坐实的硬事实:`/dify/callback-internal` 复用的唯一写入路径 `DifyCallbackTxService.handleCallbackTx` 第一步就是 `aigcTaskMapper.selectByTraceId(reqVO.getTraceId())`,查不到任务直接抛 `AIGC_TASK_NOT_EXISTS`(错误码 `1-101-000-000`)、零写入拒绝(`DifyCallbackTxService.java:131-135`)。也就是说,即便 dify 把 `callback.target` 指向后端回调口、HMAC 也签对了,后端也会因为「没有这条 task」而拒绝,产物根本进不了发布与 feed 流程——更不用说落包还依赖 `creatorUserId` 等前置,这些在形态 A 下压根不存在。
+
+于是回流落点只有三个可能,各自的结局清晰可判:
+
+```mermaid
+flowchart TB
+ W["worker 生成完
product 已落 game_dir"] --> Q{"callback.target?"}
+ Q -->|"省略 (a-min · 推荐)"| A["worker 跳过回调
产物留在 game_dir 磁盘
= 落点"]
+ Q -->|"指向 dify 捕获入口 (a-dify)"| B["第二条 dify workflow
接住 result-out
= 产物回 dify 自己消费"]
+ Q -->|"指向后端 /dify/callback-internal (a-backend)"| C["handleCallbackTx
selectByTraceId 未命中
→ AIGC_TASK_NOT_EXISTS 拒
= 不进 feed,仅能验 HMAC 接线"]
+ style A fill:#dcfce7,stroke:#16a34a
+ style C fill:#fee2e2,stroke:#dc2626
+```
+
+**推荐内测默认 = (a-min):省略 `callback.target`,产物落在 worker 的 `game_dir`。** 依据同样是读码坐实的:worker 的 `process_job` 里,生成核心 `run_fn(job)` 返回 `(summary, game_dir)`——产物**无论是否有回调,都已经写进 `game_dir` 这个磁盘目录**(`worker_service.py:229`);而当 `callback.target` 缺省时,worker 明确「job 无 callback.target,跳过回调」(`worker_service.py:251-256`)。所以省略回调不会丢产物,只是不往外发。这条路零额外部件、零后端触碰,落点就是 worker 输出目录 `games/amgen-/` 里那份 `src/` 源工程,最适合内测证明「agentscope 能被 dify 驱动」这唯一命题。但「落点可见」不等于「生成成功」——真成功判据是 worker 收口那行 `status=succeeded`(§5 有专门取证点),别把「`ls src/` 非空」当成功证据(scaffold 会先克隆 `_template`)。
+
+**可选更完整形态 = (a-dify):`callback.target` 指向 dify 侧的一个捕获入口**(dify 里再建一条以 HTTP 触发的小 workflow,专门接住 result-out 存进 dify 的变量/日志)。这才字面兑现了「产物回 dify 自己消费」。它比 a-min 多一处 dify 接线,但仍然零后端改动。若创始人希望「闭环留在 dify 内」,走这条;否则 a-min 更省。走 a-dify 时,因为要用回调,HMAC 密钥两端非空的约束就必须落实(§3.5)。
+
+**明确否决 = (a-backend):把 `callback.target` 指向后端 `/dify/callback-internal`。** 结局已如上——`selectByTraceId` 未命中、`1-101-000-000` 拒、产物不落包、不进 feed。它唯一的用处是烟测「HMAC 接线是否打通」(看后端返 401 还是走到 `AIGC_TASK_NOT_EXISTS` 的业务码,能区分「签名对不对」与「task 在不在」),但它**不是一条回流路径**,不能拿它当 demo 的落点。
+
+**要真进 feed 怎么办(形态 B,内测不做)**:得补一个「服务间免登录、且能建 task 记录」的触发面——要么让 dify 先调一个后端的「代 dify 建 task」接口拿到一条真 `game_aigc_task`(含 `creatorUserId` 等前置)再触发生成,要么把回调面扩成「无 task 时按 dify 来源新建 task 再落包」。两条都在打开新的服务间信任面(免登录建记录 = 攻击面),且要处理创作者归属、租户、审核等一连串前置,工作量与风险都显著上升,接近形态 B。内测的价值命题不需要它,故钉死在范围外。
+
+**边界(写死)**:形态 A 的 dify 产物**不进 game-cloud 的发布与 feed**,**不产生 `game_aigc_task` 记录**,**不产生创作者归属**。它是一条自证「生成线可被 dify 驱动」的独立通道,产物消费者是 dify 与运维本人。
+
+### 3.4 额度接缝:不扣个人额度,用 dify 专用 token
+
+形态 A 绕过了后端的任务表与计费门,也就绕过了 WU2 那套「new-api 按用户扣额度」的入口。这既是它的便宜之处,也是必须显式管住的一条缝:**dify 路的模型消耗默认不计入任何用户的个人额度账**。
+
+worker 往后的生成实际调的是 new-api(`100.64.0.8:3000`)来跑 LLM,这笔钱一定会花。问题只是记在谁头上。形态 A 下没有「用户」这个主体,所以:
+
+- **隔离靠的是「给 dify 起一套专属的 cheap-worker/cheap Service 实例、其进程 `NEWAPI_KEY` 环境变量 = dify 专用 token」,不是靠 job 自带 token。** 这一条必须写死,因为读码坐实:driver 的 key 从进程级 env 取(`cheap_service_driver._resolve_key()` → `client.get_api_key()` 读 `NEWAPI_KEY`),job 里根本没有任何 token/key 位。所以**切勿试图从 dify 节点透传 token**(做不到),也别以为「同一个共享 worker 能按 token 自动区分 dify 与真实用户」(它只有一把进程 env key,认不了 job 级来源)。dev 内测本就是「mini-desktop 单一 dev 生成栈」,那台栈本就 dify 专用,所以天然满足;但机制要讲清,否则会误导实现者去 job 里加 token。
+- **与 WU2 的硬冲突要点破**:WU2 拟给 job 补 `userToken` 位、让 worker 按用户取 key 做 per-user 计费。这与「dify 专用进程 env key」在**同一栈上**是互斥的——同一个进程只有一把 env `NEWAPI_KEY`,dify 路和真实 create 路一旦共栈就会共用同一把 key,隔离失效。所以二选一并写死:要么为 dify 起**独立**的 cheap Service 实例(自带专用 env key,且不承接 create 路),要么等 WU2 的 `job.userToken` 落地后由 worker 按 job 取 token(届时 dify 路给一个专用 userToken)。内测按前者(dev 单一 dify 栈天然独立),生产转常设入口时再对齐 WU2。
+- **成本上界要按真实值讲,别被 job.budget 的 0.15 骗**:单局真实成本上界 = 服务端固定的 ¥15 硬地板(软停 ¥10、硬地板 ¥15,`cheap_budget` fail-closed,job.budget 不封顶,见 §3.2),**不是 ¥0.15**——差了约 100 倍。所以 dify 路真正的额度兜底只有一条:给这个 dify 专用 new-api token 配一个 new-api 侧的用量配额上限。按 ¥15/局的上界估,若给该 token 配 ¥150 日额,理论最坏也就 ~10 局/日会触顶保护,内测反复触发跑不爆真实用户账、也跑不穿这个封顶。
+
+这个 dify 专用 token 属于凭据,实施落地时写进《内网凭据与端点》(canonical),不进 env var、不散在聊天里。
+
+### 3.5 安全:内网信任边界 + HMAC 两端非空
+
+形态 A 新增/复用的面有两处受信要讲清。
+
+**dify → worker 的入站面(`/generate`)**:worker 当前对 `/generate` 不做鉴权,信任边界完全靠「内网不可外达」。这里有一个必须说清的现状:**worker 进程无法只绑内网地址**——`main()` 硬编码 `start_server(host="0.0.0.0")`(`worker_service.py:570`)、且不提供 `--host` 参数,实际绑所有网卡。所以「别把 `:9501` 暴露到公网网卡」不是一个进程自带的旋钮,真正的防护只能落在**主机层**:让 `:9501` 所在机器没有公网可达面(Tailscale-only / NAT 之后 / 主机防火墙只放行 `100.64.x` 到 `:9501`)。这与后端 `/dify/callback-internal` 早期 3b-A「仅内网可达」是同一套兜底逻辑。在 mini-desktop 这类 NAT 后的开发工作站上内测够用,但要认清风险面:一个零鉴权、会真花 new-api 钱的生成入口若绑到 `0.0.0.0` 又暴露在可达网段,就是一个成本型 DoS 面(局域网内任何机器都能反复触发烧 token),所以「内网不可外达」目前是一条**必须由主机网络隔离兑现的假设**、不是进程自带属性。不为形态 A 单独给 worker 加鉴权(加了也是重复造 §3.3 已论证不该碰的服务间信任面);若确要进程级绑定控制,需给 `worker_service` 加 `--host`,那超出本档「后端零改」边界,列为 follow-up。验收侧建议实测一条:从非 Tailscale 来源打 `:9501` 应不可达。
+
+**worker → 回流的 HMAC(仅当用回调,即 a-dify / a-backend 烟测)**:worker 用 `CALLBACK_SECRET` 对回调**原始 body 字节**算 HMAC-SHA256、置头 `X-Callback-Signature`(`worker_service.py:82-105`);接收端以同密钥重算、常数时间比对。这里有一条必须守死的红线,和 facade 收口口径一致:**HMAC 密钥必须两端非空**。读码坐实的隐患是——`CallbackSignatureVerifier.enabled()` 判定「密钥非空即启用」,而 `verify()` 在**未启用(密钥空串)时直接返回 true 放行**(`CallbackSignatureVerifier.java:63-78`);worker 侧同理,`--callback-secret` 空则**不签**。两端只要有一端空,验签就整体关闭,回退成「裸 @PermitAll 仅靠内网」——签名这层等于没有。所以走 a-dify(或用 a-backend 烟测接线)时,worker 的 `--callback-secret` 与接收端的密钥**都必须配成同一个非空值**,且实施时把「两端非空」作为一条可机器验的启动断言(worker 启动日志会打「已启用/未启用」,接收端同样)。走 a-min(无回调)时这条不适用,因为根本没有回调面。
+
+还有一条密钥代价要提醒:a-backend 烟测的接收端是**生产后端**,它验签用的是生产 `aigc.executor.callback-secret`。若为了这个烟测把生产密钥复制进 dify 生成栈的 worker,就扩大了该密钥的存放/泄漏面——一旦 dify 栈被攻陷,攻击者能用它对任意 result-out 签名投递。所以要做 HMAC 接线自证,优先用 a-dify(dify 侧捕获入口自有一套隔离密钥、不碰生产),或用一个临时/隔离密钥做接线验证,别为一次烟测把生产 callback-secret 搬进生成栈。
+
+**命名空间隔离(防串号)**:dify 自造的 `traceId` 一律加 `dify-` 前缀。这不是洁癖——万一 dify 的 traceId 和后端某条真 `game_aigc_task` 的 traceId 撞上,且回调又误指了后端口,`selectByTraceId` 就可能命中一条真任务、把 dify 的产物错误地驱动进某个真实用户的发布流程。前缀 + 不指后端口,双保险堵掉这个串号面。
+
+### 3.6 命名血缘澄清:现行依赖里没有 Dify
+
+后端源码里有 `DifyCallbackServiceImpl`、`DifyCallbackTxService`、`/dify/callback-internal` 这一串带「Dify」字样的名字,极易让人误以为 Dify 是现行生成依赖。读码坐实的事实相反:这两个类的源码注记(`DifyCallbackServiceImpl.java:40`、`DifyCallbackTxService.java:53`)都明写——「真实 Dify/OpenGame 已降级远期、从未部署;本类是现行 M-b agent 闭环写链的内外层入口(真在用),类名『Dify』仅历史;勿据类名推断 Dify 为现行依赖」。现行后端生成线是 agentscope,这些「Dify」只是历史命名的化石。
+
+契约 #6(`dify-workflow-io.json`)同理,它描述的是一个「Java 壳 → Dify 六节点 workflow → Java 壳」的旧方向 I/O,从未落地。本设计的形态 A **不使用**这份契约的形状——形态 A 走的是 §6.1 job/result-out 这份真在用的契约,dify 只是它的一个新调用方。读本档的人要清楚:这次是**第一次真的把一个 dify 实例接进来**,接法却刻意避开了那个历史的「后端调 Dify」方向,反而让 dify 站到「调用生成线」的位置上。名字的巧合不代表血缘的延续。
+
+### 3.7 外部交互的失败、超时、重试、幂等、补偿
+
+形态 A 牵动 dify、cheap-worker、cheap Service、new-api 四个外部/异步面,逐一交代它们的异常处置。
+
+**dify HTTP 节点 → worker `/generate`**:超时设握手级(约 10s,只等 202/503)。worker 不可达 → dify 节点连接失败 → workflow 快速失败(不是静默卡住)。队满 → worker 返 503 → dify 节点据非 2xx 失败。重试与幂等这里要把方向讲对(读码坐实):worker 的 `try_enqueue` **按 `job_id`(缺则 `traceId`)在进程级 `_seen` 集去重**——同键重投直接返 `(True,202)`、**不重复入队、不重复生成**(`worker_service.py:192-204`,函数 docstring 写死「同 job_id 重投→(True,202) 幂等不重入」)。因为 §3.2 强制 dify 令 `job_id`=`traceId`=`dify-` 同值,所以**dify 侧对同一个 job 配 HTTP 重试是安全的**——重试复用同一 `job_id`,会被内置去重折叠成单次生成,不会重复烧 token。真正的 gotcha 是反向的两点,要在 demo 记录里标注:其一,`_seen` 是进程级、**永不淘汰、worker 重启即清空**;其二,正因去重,一次**真正的重新生成必须换一个新的 `job_id`/`traceId`**,否则被静默去重(照返 202、但不产新产物)——所以实现者别为省事让每次重试新造 uuid(那才会造出「重复生成」),也别以为「同 job_id 再 POST 一次能拿到一份新产物」。
+
+**worker → cheap Service `/chat`(`:8300`)**:这段是既有生成路径,worker 内部已有处置——生成异常会走兜底发 `failed` result-out(`worker_service.py` 的异常兜底路径),避免任务挂在 RUNNING。cheap Service 不可达或生成失败 → worker 产 `failed` 结果(a-min 下体现为 game_dir 里没有成功产物 + worker 日志报失败)。这段无需形态 A 额外设计,沿用现货。
+
+**worker → new-api(`:3000`)**:由 cheap Service 内部承载超时与失败,key 失效/额度耗尽 → 生成失败 → failed result-out。形态 A 的额度接缝(§3.4)已把这条的账隔离到 dify 专用 token。
+
+**worker → 回流(仅 a-dify/a-backend)**:`post_callback` 是发一次、失败记日志不重试(fire-once)。a-min 无此面。a-dify 下若 dify 捕获入口不可达,产物仍在 game_dir(落盘先于回调),只是 dify 侧没接住——降级为 a-min 的可见性,不丢产物。
+
+**失败可见性(a-min 的固有盲区,必须显式声明)**:a-min(推荐默认、无回调)下,dify 的 HTTP 节点在 worker 入队那一刻就拿到 202 返回并把 workflow 判为成功,之后 agentscope 的真实结果**完全不回流给 dify**。所以一旦 new-api 额度耗尽 / key 失效 / cheap Service 生成失败,worker 只会打一行 `status=failed` 的收口日志、产物为空,而 **dify 工作流对此彻底不可见、没有任何用户可读的拒因**。换句话说 **a-min 下「dify 节点返 202」= 已受理,绝不等于「生成成功」**;失败只能靠人去 `ls game_dir` / `tail` worker 日志发现。这是 a-min 换来的极简所付的代价,内测取证时人肉盯日志可接受。若要让失败在触发点可见(比如把 WU2 的 `quota_exhausted` 这类干净拒因透传给 dify),必须走 a-dify——把回调指向 dify 侧捕获入口,让 result-out 的 `status`/拒因回到 dify 能读的地方;届时可把 a-dify 提为 demo 取证的必选,而非仅可选。
+
+**补偿语义**:形态 A 不引入跨系统事务,产物落盘是幂等的(同 game_dir 覆盖写)。唯一需要人管的补偿是「dify 被误触发多次」时清理多余 game_dir,属运维动作,非自动补偿。
+
+## 4 步骤计划
+
+形态 A 的落地几乎全在 dify 侧配置与一次运维接线,代码物随阶段 1(实施),本档只到设计。分三步。
+
+**第一步 · 生成栈就位并可被内网直连**。在 dev 生成机(如 mini-desktop)拉起 **dify 专属的** cheap-worker(`:9501`)与 cheap Service(`:8300`,其进程 `NEWAPI_KEY`=dify 专用 token,与真实 create 路隔离,见 §3.4),并确认该机无公网可达面(`:9501` 硬编码绑 `0.0.0.0`、无 `--host`,只能靠主机 Tailscale-only/NAT/防火墙收口,见 §3.5)。交付物 = 两个进程在跑、`:9501/generate` 与 `:8300/chat` 仅 Tailscale 内网可达。验证 = 从 dify 所在的 mini-infra 上 `curl --noproxy` 打一个最小 job 到 `:9501` 拿到 202;并实测非 Tailscale 来源打 `:9501` 不可达。依赖 = 生成栈现货、new-api 专用 token。风险 = 系统代理劫持内网 IP(用 `NO_PROXY`/`--noproxy` 绕过)。
+
+**第二步 · dify 建最小 workflow + 游戏开发节点**。在 dify(`:18080`)里建一条 workflow:一个开始节点收 `brief` 输入 → 一个 HTTP Request「游戏开发节点」按 §3.2 映射组 §6.1 job、POST 到 `:9501/generate`(超时握手级、`traceId` 带 `dify-` 前缀、`callback` 按 §3.3 决策默认省略)。交付物 = 一条可运行的 dify workflow,其定义 DSL 导出留痕(建议存 `spikes/` 或本档 assets,供可复现)。验证 = 在 dify 里手动跑一次,节点拿到 202。依赖 = 第一步就位。风险 = dify sandbox 出网未绕代理(同第一步)。
+
+**第三步 · 端到端取证 + 边界与凭据回写**。跑通 dify → worker → agentscope → game_dir 全链,取证(§5)。收口把 dify/worker/Service 端点与 dify 专用 token 回写《内网凭据与端点》,把 dify workflow DSL 留痕。交付物 = 一份真机取证记录 + 凭据回写。验证 = §5 验收门全绿。依赖 = 前两步。风险 = 无回调时忘了看 game_dir(落点提醒)。
+
+## 5 验证方式
+
+内测最小可行判据只有一条链:**从 dify workflow 触发一次 → cheap-worker 跑 agentscope → 拿到生成产物**。它可真机取证,取证点逐条可机器验或肉眼可查:
+
+1. **触发**:在 dify(`http://100.64.0.8:18080`)运行 workflow,输入一句 brief。dify 侧显示游戏开发节点返回 202、body 含 `{"accepted":true,"job_id":"dify-...","traceId":"dify-...","queued":N}`。**注意 202 只证明「已受理」,不证明「生成成功」**——它甚至可能是同 `job_id` 重投被去重返的 202(此时 `queued` 不增、无新生成,§3.7);真成功证据在第 4 步。所以每次要真生成必须换新 `job_id`/`traceId`。
+2. **收单**:cheap-worker 日志出现 `收到 job trace_id=dify-..., templateId=generic, gameId=dify-... → 受理(202)`(`worker_service.py:544` 那行)。这是「dify 的 job 确实到了 worker」的直接证据。
+3. **真跑 agentscope**:worker 日志显示后台线程驱动 cheap Service 生成(`:8300` 各 POST),生成过程有 LLM 调用痕迹;cheap Service 侧日志对应产出。
+4. **生成成功(不是「产物可见」,别踩假绿)**:成功判据用 worker 的真状态信号,**不看 `src/` 是否非空**——因为 scaffold 会在真生成前先把 `_template/src`(实测 7 个文件:`assets/core/game-logic/game/host-config/main/render.js`)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「`src/` 非空、多文件」,单看它区分不出成败。可用的成功证据有三,任一即可:①grep worker 收口日志到 `status=succeeded`(`process_job` 在 `worker_service.py:254/256` 那两行本就打 `status={succeeded|failed}`);②核 `_wg1-gen//evidence/verdict.json` 九门 pass;③核 `game_dir/src/game-logic.js` 已偏离 `_template` 基线(agent 真改过游戏本体)。产物落点目录是 **`game-runtime/games/amgen-/src/`**(`cheap_run.game_dir` 返 `games/amgen-/`,dify 令 `gameId=dify-` 时实际目录是 `amgen-dify-/`,`gameId` 已含 `dify-` 前缀,别按字面去找 `dify-`)。a-dify 下,额外在 dify 捕获入口看到 result-out 被接住、其 `status` 可读。
+5. **边界反证(可选但推荐)**:确认后端**没有**新增 `game_aigc_task` 记录、feed 里**没有**这款 dify 产物——反证「不进 feed」这条边界成立。若走了 a-backend 烟测,确认后端返 `1-101-000-000`(task 不存在)而非 401,反证「HMAC 接线通、但产物如设计般被拒于 feed 外」。
+
+这条链任一环断了都能定位到具体环节(202 没拿到 = dify→worker 网络/代理;收单没日志 = worker 没跑/端口错;`status=failed` 或产物只剩 `_template` = cheap Service/new-api 失败)——但要认清:a-min 下这套定位**全靠运维在生成栈这侧看 worker 日志/ls 目录,dify 那侧对失败是盲的**(§3.7 失败可见性)。走通即「内测前支持 dify workflow」成立。
+
+## 6 风险与回滚
+
+**回滚**:形态 A 是纯增量、后端零改动,回滚只需在 dify 里停用/删除那条 workflow,生成栈照常服务生产链路,无任何后端状态需要还原。这是形态 A 相对形态 B 最大的安全优势。
+
+**主要风险**:其一,内网代理劫持——`100.64.x` 被系统代理 fake-ip 劫走导致连不上,处置是 dify sandbox 与运维 curl 一律 `NO_PROXY`/`--noproxy`。其二,回调面若误配(a-backend 或 a-dify 密钥单端空)——处置是钉死「不指后端口 + HMAC 两端非空 + traceId 带 `dify-` 前缀」,并把两端非空做成启动断言。其三,入站面无鉴权 + `:9501` 硬编码绑 `0.0.0.0`——处置是靠主机层网络隔离(Tailscale-only/NAT/防火墙)兜住,验收实测非 Tailscale 来源打 `:9501` 不可达(§3.5)。其四,dify 反复触发烧 new-api 专用 token——量级要按真实值算:单局成本上界是服务端固定的 ¥15(不是 job.budget 的 0.15),唯一真实兜底是给该专用 token 配 new-api 侧用量配额(如 ¥150/日 ≈ 最坏 10 局触顶);注意同一次 POST 的 HTTP 重试会被 worker 按 `job_id` 去重折叠、不会重复烧钱,真正的多次生成只发生在「每次换新 id」时。其五,读者误把「Dify」类名当现行依赖——处置见 §3.6,本档已把命名血缘讲清。
+
+**已知限制**:worker 的去重是**进程级 `_seen`、永不淘汰、重启即清空**——同 `job_id`/`traceId` 重投会被静默去重(返 202 但不产新产物),一次真正的重新生成必须换新 id(§3.7)。这不是「会重复生成」的限制,恰恰相反是「同 id 只产一次」,实现 dify 重试时据此复用同一 `job_id` 即可,无需在形态 A 里对生成主线动任何去重逻辑。
+
+## 附 图清单与状态
+
+| 图 | 位置 | 角色 | 状态 |
+|---|---|---|---|
+| 图1 拓扑总览 | §0 | Mermaid 事实源 | 随文本 |
+| 图2 回流落点三选项决策 | §3.3 | Mermaid 事实源 | 随文本 |
+| 图3 触发-生成-回流(隐含于图1+§3.7 文字) | §3.7 | 文字为主 | 随文本 |
+| 图4 做与不做边界 | §2 | Mermaid 事实源 | 随文本 |
+| §0 门面 SVG 概览 | §0 | 派生视觉(给人看) | 待收口图层子代理据图1 派生 |
+
+> 图文冲突以文字为准;SVG 收口时由图层子代理据 §0/图1 派生,house style 照 architecture-diagram-atlas。本档收口需过 Codex + Opus 双评审(Codex 不可用回落 Opus 单评),并按 frontmatter `sot-impact` 在实施落地后回写《内网凭据与端点》。
diff --git a/game-runtime/tools/newapi_pool_provision.py b/game-runtime/tools/newapi_pool_provision.py
new file mode 100644
index 00000000..e48b03c1
--- /dev/null
+++ b/game-runtime/tools/newapi_pool_provision.py
@@ -0,0 +1,421 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""newapi_pool_provision.py -- 内测 new-api per-user ¥100 额度池离线预置脚本(WU2 · 接线点 B)
+
+权威设计:docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md §3.3(离线 ops 预置脚本)
+ + §3.5(池表 newapi_quota_pool 契约)。
+
+它做什么
+--------
+在 mini-infra 上一次性预建 N 个专属 (new-api user + token + ¥100 额度) 账户,
+把每个条目 emit 成可导入 game-cloud 池表 newapi_quota_pool 的 JSON + SQL,供 WU2 落地后灌数。
+provision 全部离线做(S0 实测坐实:new-api admin 令牌不能替他人建 token、access_token
+不可经 API 设,per-user token 必须直连其 postgres),运行时零 new-api admin 耦合。
+
+每个池条目四步建成(S0 坐实的唯一可行路径)
+------------------------------------------------
+ 1. POST /api/user/(root 令牌)建用户,username = 确定性 `<前缀><序号>`;从 postgres 查 uid。
+ 2. postgres 直写 UPDATE users SET access_token=<确定性 32 位串> WHERE id=uid
+ —— access_token 无法经 API 设,又是下一步「以该用户身份建 token」的前提。
+ 3. POST /api/token/(以该用户 access_token + New-Api-User:uid 建 token),
+ remain_quota=¥100 折算、unlimited_quota=false、expired_time=-1(不过期);从 postgres 查 token_id/key。
+ 4. PUT /api/user/(root 令牌)设 user.quota=¥100 折算(账户自洽;权威余额闸仍是 token.remain_quota)。
+
+幂等(可重跑补池)
+------------------
+按确定性 username 去重:重跑时若用户已存在则复用其 uid,逐步「补齐」缺失的 access_token /
+token / quota(ensure 语义),不重复建号。补池 = 提高 --count 重跑。单条失败只记该条、不污染后续。
+
+外部交互红线
+------------
+ - HTTP:连接/读超时(urlopen timeout=读10s,内部连粒度由 socket 兜),非 2xx / success=false 归错,
+ 5xx/超时重试 2 次(指数退避),4xx 不重试;全程绕系统代理(ProxyHandler({}),内网直连 100.64.x)。
+ - postgres:经 `docker exec infra-postgres psql` 执行(S0 验证路径),失败即抛、该条标记失败。
+ - 日志:token / access_token 一律脱敏(前后各留 4 位);可追溯(时间戳 + 步骤 + 条目序号)。
+
+凭据(不硬编码)
+----------------
+ - postgres 密码:环境变量 NEWAPI_PG_PASSWORD(必填;见 docs/内网凭据与端点.md)。
+ - root 管理令牌:环境变量 NEWAPI_ROOT_TOKEN(选填);未设则自动从 postgres users.id=1 读。
+
+用法(在 mini-infra 上跑,docker 本地可用)
+------------------------------------------
+ export NEWAPI_PG_PASSWORD=<见凭据文档>
+ # 预置 2 条测试条目(验证用,前缀明确可辨),dry-run 先看:
+ python3 newapi_pool_provision.py --count 2 --prefix neice_s0test_ --dry-run
+ python3 newapi_pool_provision.py --count 2 --prefix neice_s0test_
+ # 清理测试条目(删 tokens + users 行):
+ python3 newapi_pool_provision.py --cleanup 'neice_s0test_%'
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import logging
+import os
+import re
+import secrets
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+# ─── 常量 ────────────────────────────────────────────────────────────────
+NEWAPI_BASE = os.environ.get("NEWAPI_BASE", "http://localhost:3000") # 脚本宿主 mini-infra,本地直连
+PG_CONTAINER = os.environ.get("NEWAPI_PG_CONTAINER", "infra-postgres")
+PG_DB = os.environ.get("NEWAPI_PG_DB", "new-api")
+PG_USER = os.environ.get("NEWAPI_PG_USER", "root")
+ROOT_UID = 1 # new-api root 用户 id(qingse),持系统管理 access_token
+HTTP_CONNECT_TIMEOUT = 5.0
+HTTP_READ_TIMEOUT = 10.0
+HTTP_RETRIES = 2 # 5xx / 超时最多重试 2 次
+USERNAME_RE = re.compile(r"^[A-Za-z0-9_]+$") # 只允许安全 username,杜绝 SQL/命令注入
+POOL_TABLE = "newapi_quota_pool"
+
+log = logging.getLogger("newapi_pool")
+
+
+def _mask(token: str | None) -> str:
+ """脱敏敏感串:前后各留 4 位,中间打码。用于日志(红线:token/access_token 不整条打日志)。"""
+ if not token:
+ return ""
+ if len(token) <= 10:
+ return token[:2] + "***"
+ return f"{token[:4]}…{token[-4:]}(len={len(token)})"
+
+
+# ─── postgres 直连(经 docker exec psql,S0 验证路径) ──────────────────────
+class Postgres:
+ """封装对 new-api postgres 的只读/写操作。所有写走事务语义(单条 SQL 天然原子)。"""
+
+ def __init__(self, password: str):
+ if not password:
+ raise RuntimeError(
+ "缺少 NEWAPI_PG_PASSWORD 环境变量——无法连接 new-api postgres。"
+ "见 docs/内网凭据与端点.md new-api 段。"
+ )
+ self._password = password
+
+ def _run(self, sql: str) -> str:
+ """执行一条 SQL,返回 tuples-only、unaligned、以 | 分隔的原始 stdout。失败即抛。"""
+ cmd = [
+ "docker", "exec",
+ "-e", f"PGPASSWORD={self._password}",
+ PG_CONTAINER,
+ "psql", "-U", PG_USER, "-d", PG_DB,
+ "-t", "-A", "-F", "|", "-c", sql,
+ ]
+ try:
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ except subprocess.TimeoutExpired as e:
+ raise RuntimeError(f"postgres 执行超时:{sql[:80]}") from e
+ if proc.returncode != 0:
+ # 可追溯错误日志:暴露 psql stderr,但不含敏感值(SQL 里的 access_token 已在调用侧脱敏日志)
+ raise RuntimeError(f"postgres 执行失败 rc={proc.returncode}: {proc.stderr.strip()}")
+ return proc.stdout.strip()
+
+ def query_root_token(self) -> str:
+ out = self._run(f"SELECT access_token FROM users WHERE id={ROOT_UID};")
+ if not out:
+ raise RuntimeError("postgres 未查到 root(id=1) access_token")
+ return out.splitlines()[0].strip()
+
+ def query_user_id(self, username: str) -> int | None:
+ out = self._run(f"SELECT id FROM users WHERE username='{username}';")
+ line = out.splitlines()[0].strip() if out else ""
+ return int(line) if line else None
+
+ def query_access_token(self, uid: int) -> str:
+ out = self._run(f"SELECT access_token FROM users WHERE id={uid};")
+ return out.splitlines()[0].strip() if out else ""
+
+ def query_user_quota(self, uid: int) -> int | None:
+ out = self._run(f"SELECT quota FROM users WHERE id={uid};")
+ line = out.splitlines()[0].strip() if out else ""
+ return int(line) if line else None
+
+ def set_access_token(self, uid: int, access_token: str) -> None:
+ # access_token 无法经 API 设,必须 DB 直写(S0 坐实的关键步)
+ self._run(f"UPDATE users SET access_token='{access_token}' WHERE id={uid};")
+
+ def query_token(self, uid: int, name: str) -> tuple[int, str] | None:
+ """按 user_id + token name 查 token,返回 (token_id, token_key)。"""
+ out = self._run(
+ f"SELECT id, key FROM tokens WHERE user_id={uid} AND name='{name}' ORDER BY id LIMIT 1;"
+ )
+ line = out.splitlines()[0].strip() if out else ""
+ if not line or "|" not in line:
+ return None
+ tid, key = line.split("|", 1)
+ return int(tid), key.strip()
+
+ def delete_user_cascade(self, username_like: str) -> tuple[int, int]:
+ """清理:按 username LIKE 删 users + 其 tokens。返回 (删 tokens 数, 删 users 数)。
+ 安全护栏:拒绝任何可能命中 root(id<=1) 的模式;只允许显式 % 通配。"""
+ # 先查将被删的 uid 列表,护栏校验
+ uids_out = self._run(f"SELECT id FROM users WHERE username LIKE '{username_like}';")
+ uids = [int(x) for x in uids_out.splitlines() if x.strip()]
+ if not uids:
+ return (0, 0)
+ if any(u <= ROOT_UID for u in uids):
+ raise RuntimeError(f"清理护栏:模式 {username_like} 命中受保护用户 id<={ROOT_UID},拒绝执行")
+ uid_csv = ",".join(str(u) for u in uids)
+ tok_out = self._run(f"WITH d AS (DELETE FROM tokens WHERE user_id IN ({uid_csv}) RETURNING 1) SELECT count(*) FROM d;")
+ usr_out = self._run(f"WITH d AS (DELETE FROM users WHERE id IN ({uid_csv}) RETURNING 1) SELECT count(*) FROM d;")
+ return (int(tok_out.strip() or 0), int(usr_out.strip() or 0))
+
+
+# ─── new-api HTTP(stdlib urllib,绕系统代理,带超时+重试) ──────────────────
+_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # 空 ProxyHandler = 禁用系统代理
+
+
+def _http_json(method: str, path: str, token: str, newapi_user: int, body: dict | None = None) -> dict:
+ """对 new-api 发一个 JSON 请求。红线:绕代理 / 超时 / 非 2xx 归错 / 5xx 重试。
+
+ path 必须带尾斜杠(new-api gin RedirectTrailingSlash,否则 307)。
+ 鉴权头:Authorization: Bearer + New-Api-User: (缺后者即 401)。
+ """
+ url = NEWAPI_BASE.rstrip("/") + path
+ data = json.dumps(body).encode("utf-8") if body is not None else None
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "New-Api-User": str(newapi_user),
+ "Content-Type": "application/json",
+ }
+ last_err: Exception | None = None
+ for attempt in range(HTTP_RETRIES + 1):
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
+ raw = resp.read().decode("utf-8")
+ payload = json.loads(raw) if raw else {}
+ # new-api 标准返回体 {success,message,data};success=false 视为业务错
+ if isinstance(payload, dict) and payload.get("success") is False:
+ raise RuntimeError(f"{method} {path} 业务失败: {payload.get('message')}")
+ return payload
+ except urllib.error.HTTPError as e:
+ body_txt = e.read().decode("utf-8", "ignore")[:200]
+ last_err = RuntimeError(f"{method} {path} HTTP {e.code}: {body_txt}")
+ if 500 <= e.code < 600 and attempt < HTTP_RETRIES:
+ time.sleep(0.5 * (2 ** attempt))
+ log.warning("[http] %s %s 5xx 重试 %d/%d", method, path, attempt + 1, HTTP_RETRIES)
+ continue
+ raise last_err # 4xx 不重试
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
+ last_err = RuntimeError(f"{method} {path} 网络错误: {e}")
+ if attempt < HTTP_RETRIES:
+ time.sleep(0.5 * (2 ** attempt))
+ log.warning("[http] %s %s 网络错重试 %d/%d: %s", method, path, attempt + 1, HTTP_RETRIES, e)
+ continue
+ raise last_err
+ raise last_err or RuntimeError("unreachable")
+
+
+# ─── ¥ ↔ quota 折算(口径与 cost.py 同源,读网关而非硬编码) ─────────────────
+def fetch_conversion() -> tuple[int, float]:
+ """GET /api/status 拿 quota_per_unit / usd_exchange_rate(无需鉴权)。缺 usd 用 7.3 默认。"""
+ url = NEWAPI_BASE.rstrip("/") + "/api/status"
+ req = urllib.request.Request(url, method="GET")
+ with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
+ data = json.loads(resp.read().decode("utf-8")).get("data", {})
+ qpu = int(data.get("quota_per_unit") or 500000)
+ usd = data.get("usd_exchange_rate")
+ usd = float(usd) if usd else 7.3 # DB options 未覆盖时默认 7.3
+ return qpu, usd
+
+
+def compute_grant(yuan: float, qpu: int, usd: float) -> int:
+ """¥ → quota:round(yuan / usd × qpu)。¥100 @ 500000/7.3 ≈ 6,849,315。"""
+ return round(yuan / usd * qpu)
+
+
+# ─── 单条目预置(ensure 语义,幂等可补齐) ──────────────────────────────────
+def provision_one(pg: Postgres, root_token: str, username: str, grant: int,
+ qpu: int, usd: float, dry_run: bool) -> dict:
+ """预置/补齐一个池条目,返回条目 dict(对齐 §3.5 池表契约)。任一步失败即抛,由调用方记为失败。"""
+ if not USERNAME_RE.match(username):
+ raise RuntimeError(f"非法 username: {username}")
+
+ if dry_run:
+ log.info("[dry-run] 计划预置 %s(grant=%d, unlimited=false, expired=-1)", username, grant)
+ return {"username": username, "dry_run": True, "grant_quota": grant}
+
+ # 步骤 1:建用户(幂等——已存在则复用)
+ uid = pg.query_user_id(username)
+ if uid is None:
+ password = secrets.token_urlsafe(12) # 随机密码 16 位(new-api Password max=20);仅占位,从不密码登录
+ _http_json("POST", "/api/user/", root_token, ROOT_UID, {
+ "username": username,
+ "password": password,
+ "display_name": username[:20], # new-api DisplayName max=20,直接用 username(截断兜底)
+ })
+ uid = pg.query_user_id(username)
+ if uid is None:
+ raise RuntimeError(f"建用户 {username} 后 postgres 未查到 uid")
+ log.info("[1/4] 建用户 %s → uid=%d", username, uid)
+ else:
+ log.info("[1/4] 用户 %s 已存在 uid=%d(复用)", username, uid)
+
+ # 步骤 2:DB 直写 access_token(确定性,可重复 UPDATE 幂等)
+ access_token = hashlib.sha256(f"neice-quota-pool::v1::{username}".encode()).hexdigest()[:32]
+ existing_at = pg.query_access_token(uid)
+ if existing_at != access_token:
+ pg.set_access_token(uid, access_token)
+ log.info("[2/4] DB 写 access_token uid=%d → %s", uid, _mask(access_token))
+ else:
+ log.info("[2/4] access_token uid=%d 已就绪 %s(复用)", uid, _mask(access_token))
+
+ # 步骤 3:以该用户身份建 token(幂等——已存在同名 token 则复用)
+ tok = pg.query_token(uid, username)
+ if tok is None:
+ _http_json("POST", "/api/token/", access_token, uid, {
+ "name": username,
+ "remain_quota": grant,
+ "unlimited_quota": False,
+ "expired_time": -1, # 不过期,避免自伤式过期(设计 §3.9)
+ })
+ tok = pg.query_token(uid, username)
+ if tok is None:
+ raise RuntimeError(f"建 token 后 postgres 未查到 user={uid} name={username}")
+ log.info("[3/4] 建 token uid=%d → token_id=%d key=%s", uid, tok[0], _mask(tok[1]))
+ else:
+ log.info("[3/4] token uid=%d name=%s 已存在 token_id=%d(复用)", uid, username, tok[0])
+ token_id, token_key = tok
+
+ # 步骤 4:设 user.quota(root 令牌;账户自洽,权威余额闸仍是 token.remain_quota)
+ cur_quota = pg.query_user_quota(uid)
+ if cur_quota != grant:
+ _http_json("PUT", "/api/user/", root_token, ROOT_UID, {
+ "id": uid,
+ "username": username,
+ "display_name": username[:20],
+ "quota": grant,
+ "group": "default",
+ })
+ log.info("[4/4] 设 user.quota uid=%d → %d", uid, grant)
+ else:
+ log.info("[4/4] user.quota uid=%d 已=%d(复用)", uid, grant)
+
+ # 组装池表条目(§3.5 契约字段)
+ return {
+ "newapi_user_id": uid,
+ "newapi_token_id": token_id,
+ "newapi_token_key": token_key,
+ "grant_quota": grant,
+ "quota_per_unit_snapshot": qpu,
+ "usd_rate_snapshot": usd,
+ "status": "FREE",
+ "username": username, # 仅审计参考,非池表列
+ }
+
+
+# ─── emit:JSON + SQL(对齐 §3.5,供 WU2 落地后导入 game-cloud) ─────────────
+def emit_outputs(entries: list[dict], out_dir: Path, prefix: str) -> tuple[Path, Path]:
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
+ out_dir.mkdir(parents=True, exist_ok=True)
+ stem = f"newapi_pool_{prefix.rstrip('_')}_{ts}"
+ json_path = out_dir / f"{stem}.json"
+ sql_path = out_dir / f"{stem}.sql"
+
+ # 只保留池表列(去掉 username 审计字段)
+ pool_cols = ["newapi_user_id", "newapi_token_id", "newapi_token_key",
+ "grant_quota", "quota_per_unit_snapshot", "usd_rate_snapshot", "status"]
+ json_rows = [{k: e[k] for k in pool_cols} for e in entries]
+ json_path.write_text(json.dumps(json_rows, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ # SQL:INSERT ... ON DUPLICATE KEY UPDATE(去重键 uk(newapi_user_id),§3.3(a))
+ # 刻意不在 ON DUPLICATE 里改 status/claimed_*——重跑不把已 CLAIMED 条目刷回 FREE(防免费续杯 / 防误 unclaim)。
+ lines = [
+ f"-- 内测 new-api 额度池预置产物 {ts},共 {len(json_rows)} 条。",
+ f"-- 对齐 docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md §3.5 池表 {POOL_TABLE}(V32 待落)。",
+ f"-- 导入前提:game-cloud 已建 {POOL_TABLE} 表(含 uk(newapi_user_id)、审计列默认值)。",
+ f"-- ON DUPLICATE 只更 token/额度快照,不动 status/claimed_*(幂等补池不 unclaim 已绑条目)。",
+ ]
+ for r in json_rows:
+ key_sql = r["newapi_token_key"].replace("'", "''")
+ lines.append(
+ f"INSERT INTO {POOL_TABLE} "
+ f"(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, "
+ f"quota_per_unit_snapshot, usd_rate_snapshot, status) VALUES "
+ f"({r['newapi_user_id']}, {r['newapi_token_id']}, '{key_sql}', {r['grant_quota']}, "
+ f"{r['quota_per_unit_snapshot']}, {r['usd_rate_snapshot']}, '{r['status']}') "
+ f"ON DUPLICATE KEY UPDATE "
+ f"newapi_token_id=VALUES(newapi_token_id), newapi_token_key=VALUES(newapi_token_key), "
+ f"grant_quota=VALUES(grant_quota), quota_per_unit_snapshot=VALUES(quota_per_unit_snapshot), "
+ f"usd_rate_snapshot=VALUES(usd_rate_snapshot);"
+ )
+ sql_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ return json_path, sql_path
+
+
+# ─── 主流程 ──────────────────────────────────────────────────────────────
+def cmd_provision(args) -> int:
+ pg = Postgres(os.environ.get("NEWAPI_PG_PASSWORD", ""))
+ root_token = os.environ.get("NEWAPI_ROOT_TOKEN") or pg.query_root_token()
+ log.info("root 管理令牌就绪 %s", _mask(root_token))
+
+ qpu, usd = fetch_conversion()
+ grant = compute_grant(args.yuan, qpu, usd)
+ log.info("折算:¥%.0f @ quota_per_unit=%d usd=%.4f → grant_quota=%d", args.yuan, qpu, usd, grant)
+
+ entries: list[dict] = []
+ failures: list[dict] = []
+ for i in range(args.start, args.start + args.count):
+ username = f"{args.prefix}{i:03d}"
+ try:
+ entries.append(provision_one(pg, root_token, username, grant, qpu, usd, args.dry_run))
+ except Exception as e: # 单条失败不污染后续(红线)
+ log.error("[条目 %s] 预置失败:%s", username, e)
+ failures.append({"username": username, "error": str(e)})
+
+ log.info("完成:成功 %d / 失败 %d", len(entries), len(failures))
+ if args.dry_run:
+ log.info("dry-run 结束,未写 new-api、未 emit 文件。")
+ return 0 if not failures else 1
+
+ if entries:
+ json_path, sql_path = emit_outputs(entries, Path(args.out_dir), args.prefix)
+ log.info("emit JSON → %s", json_path)
+ log.info("emit SQL → %s", sql_path)
+ if failures:
+ log.warning("失败条目:%s", json.dumps(failures, ensure_ascii=False))
+ return 1
+ return 0
+
+
+def cmd_cleanup(args) -> int:
+ """清理:按 username LIKE 模式删 tokens + users(护栏拒绝命中 root)。"""
+ pg = Postgres(os.environ.get("NEWAPI_PG_PASSWORD", ""))
+ ntok, nusr = pg.delete_user_cascade(args.cleanup)
+ log.info("清理模式 %s:删 tokens %d 行 / users %d 行", args.cleanup, ntok, nusr)
+ return 0
+
+
+def main() -> int:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(message)s",
+ stream=sys.stderr,
+ )
+ p = argparse.ArgumentParser(description="内测 new-api per-user ¥100 额度池离线预置")
+ p.add_argument("--count", type=int, default=1, help="目标条目数 N(本次预置到 start..start+count)")
+ p.add_argument("--start", type=int, default=1, help="起始序号(默认 1)")
+ p.add_argument("--prefix", default="neice_", help="username 前缀(默认 neice_;测试用 neice_s0test_)")
+ p.add_argument("--yuan", type=float, default=100.0, help="每条目额度(元,默认 100)")
+ p.add_argument("--out-dir", default="./newapi-pool-out", help="JSON/SQL 产物目录")
+ p.add_argument("--dry-run", action="store_true", help="只打印计划,不写 new-api、不 emit")
+ p.add_argument("--cleanup", metavar="LIKE_PATTERN",
+ help="清理模式:删匹配 username LIKE 的 users+tokens(如 'neice_s0test_%%')")
+ args = p.parse_args()
+
+ if args.cleanup:
+ return cmd_cleanup(args)
+ return cmd_provision(args)
+
+
+if __name__ == "__main__":
+ sys.exit(main())