docs(会员): 定案模型权限与并发上限的服务端强制边界

- 新增 ADR「模型权限与并发上限的强制边界」:并发只在认领事务按账号过滤 running job、模型档缺省 Basic、直连同步生成暂不计入
- 技术设计新增 §11,承接并作废 §9/§10 的「只落字段、不拦截」表述
- CONTEXT 修订「模型权限」「并发上限」词条,新增「独立生成任务」
- docs/README 与 decision-log 登记本 ADR 与拷问定案
This commit is contained in:
2026-10-03 16:08:38 +08:00
parent 65b9821d25
commit cbe6697d03
5 changed files with 83 additions and 5 deletions
+1
View File
@@ -112,6 +112,7 @@
- [会员账期按开通日自然月](./adr/【ADR】会员账期按开通日自然月-2026-10-02.md):北京时间开通日锚点、自然月推进与月末夹取、年付 12 期的期数表达与取整口径。
- [会员订单复用充值订单与补差升级幂等锚点](./adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md):会员订单复用 `profile_recharge_order`、`MembershipUpgradeGrant` 账本幂等、后端只读报价与退款人工复核边界。
- [会员档位以枚举为权威](./adr/【ADR】会员档位以枚举为权威-2026-10-02.md):档位身份改为 Rust 枚举、目录表 `profile_membership_plan` 以枚举为主键、删除两处 `tier` 列与旧档位枚举、并发用哨兵值 `128` 表示不设上限。
- [模型权限与并发上限的强制边界](./adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md):并发上限只在认领事务按账号过滤队列 job、只算 `running`;模型权限只约束 AGC LLM 模型、缺省 `Basic` 由后台人工标 `Full`;raw gpt-image-2 等直连同步生成暂不计入并发。
## 后端、运维与测试
@@ -0,0 +1,26 @@
# 模型权限与并发上限的强制边界
## Status
accepted(2026-10-03)
## 背景与决策
`profile_membership_plan` 的 `model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端不做拦截。2026-10-03 决定升级为服务端强制,并划清本期边界:
- **并发上限**只在已有的 `claim_external_generation_jobs_tx`(唯一的 `pending → running` 事务点)按账号过滤,只统计 `status = running` 的 `external_generation_job`;不新建队列、不设排队深度上限、不新增计数表(加 `(owner_user_id, status)` 组合索引现算)。上限读后台目录 `profile_membership_plan`,缺行失败关闭到 `Normal`(=1),`128` 哨兵表示不设上限。
- **模型权限**只约束 AGC 的 LLM 模型:`AgcModel` 末位加权限档,**缺省 `Basic`(失败开放)**,由人工在后台「AGC 模型」页手动标 `Full`;在 `GET /api/llm/models` 与代理解析处校验,越权返回 4xx `MODEL_NOT_AVAILABLE_FOR_PLAN`。
- **直连同步生成暂不计入并发**:`/api/raw/v1/images/edit`(gpt-image-2)、`editor_project` 直连图片 / 图标 / 背景、角色资源与音频等路径不产生 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径留作后续设计。
- 不设灰度开关,直接对所有账号生效。
## 考虑过的替代方案
- 新增 SpacetimeDB「在飞租约表」统一统计队列 job 与直连同步生成:更完整,但需要租约、心跳与回收语义,本期未做。
- 把直连同步生成统一改走 `external_generation_job`:最一致,但要重做 raw / editor 直连接口,风险最大。
- 模型档位缺省 `Full`(失败关闭):更安全,但上线即需先标注全部基础模型,运营成本高。
## 后果
- 模型限制在人工标注 `Full` 之前不会拦截任何请求。
- `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。
- 无灰度开关;出问题只能回滚发版。
@@ -9621,6 +9621,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。
- 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。
## 2026-10-03 模型权限与并发上限的强制边界(拷问定案)
- 背景:`model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端零拦截;用户点出 raw gpt-image-2 这类同步生成,确认「只统计 job 的 `running`」覆盖不到它,因此先把「并发单位」定义清楚再强制。
- 决策(并发上限只约束队列 job):强制点放在唯一的 `pending → running` 事务点 `claim_external_generation_jobs_tx`,认领时按账号统计 `running` 数、达上限则跳过(任务留在 `pending`,天然排队,不新建队列)。只算 `running`(含过期待回收的 `expired_running`);失败退回 `pending` 重试释放名额;自身过期 `running` 回收豁免;上限读 `profile_membership_plan.concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 哨兵为不设上限;计数用新增 `(owner_user_id, status)` 组合索引现算,不建计数表;`/api/external/v1` 任务同样计入;不设排队深度上限。
- 决策(模型权限只约束 AGC LLM 模型):`AgcModel` 末位加权限档、缺省 `Basic`(失败开放),由人工在后台「AGC 模型」页手动标 `Full`;`GET /api/llm/models` 按档过滤并把 `defaultModelId` 取可用集合首个,`/api/llm/responses` 经 `resolve_requested` 后校验、越权返 `MODEL_NOT_AVAILABLE_FOR_PLAN`;本地残留旧选择明确 4xx,不静默回退;不设灰度开关。
- 边界(本轮不做):直连同步生成(raw gpt-image-2、`editor_project` 直连图片 / 图标 / 背景、角色资源、音频)没有 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续 TODO。
- 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。
- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error)
- 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。
@@ -511,6 +511,8 @@ flowchart TD
| 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` |
| 15 | `walletBalance` | 用户面不暴露 |
> **2026-10-03 更新**:上表第 6、7 行的「本期不实现限制」由 §11 取代——`model_access` 与 `concurrent_job_limit` 进入服务端强制。
## 10. 评审输入与实施清单
### 10.1 已确认输入(本轮拍板)
@@ -558,10 +560,45 @@ flowchart TD
未做(依决策保留,不是遗漏):
- 模型权限与并发上限**只落字段**,服务端不做拦截。
- 模型权限与并发上限**只落字段**,服务端不做拦截。(2026-10-03 改:已由 §11 升级为服务端强制,本条作废。)
- 不提供 `availableTotalPoints`;合计由前端派生。
- 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。
- 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见
[`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。
## 11. 模型权限与并发上限的强制(2026-10-03 定案)
> 承接 §9 第 6/7 行与 §10「未做」:把 `model_access` 与 `concurrent_job_limit` 从「只落字段、只用于展示」升级为**服务端强制**。
> 范围:`module-runtime`(目录与判定纯函数)、`spacetime-module`(认领事务 + schema 索引)、`api-server`(模型代理)、`shared-contracts`、后台「AGC 模型」页。**不含 C 端 UI、不含灰度开关。**
### 11.1 并发上限(只约束队列 job)
- **强制点唯一**:`claim_external_generation_jobs_tx`(`spacetime-module/src/external_generation.rs`)是唯一的 `pending → running` 事务点。认领时按账号统计在飞数,未达上限才置 `running`;达上限则**跳过**该账号的候选任务——任务保持 `pending`,天然形成排队,无需新建队列。
- **计入口径**:只算 `status = running`(含 lease 过期待回收的 `expired_running`)。失败退回 `pending` 等待重试的任务**释放**名额;`pending`(含 `available_at` 延时重试)不计入。
- **回收豁免**:同一账号 lease 已过期的 `running` 被回收重认领时**不受自身上限限制**,否则超限账号的卡死任务永远无法回收。
- **上限来源**:`profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`(后台权威);缺会员行 / 缺目录行**失败关闭**到 `Normal`(=1);`128` 哨兵 = 不设上限(复用 `is_unlimited_concurrency`)。
- **计数实现**:给 `external_generation_job` 追加 `(owner_user_id, status)` 组合索引,事务内现算 `running` 行数;**不**新增计数表(`running` 行即真相)。
- **覆盖范围**:本期只约束 `external_generation_job`;`/api/external/v1` 触发、挂在账号下的任务**同样计入**。
- **排队深度**:**不设**单账号 `pending` 深度上限。
- **⚠️ TODO(用户保留,后续单独设计)**:**直连同步生成不计入本期并发**——`/api/raw/v1/images/edit`(gpt-image-2,见 [`【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07`](./【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md))、`editor_project` 直连图片 / 图标 / 背景、`character_visual_assets` / `character_animation_assets`、`vector_engine_audio_generation` 等路径**没有** `external_generation_job` 行,`running` 计数覆盖不到。统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续设计。当前全局护栏仍只有 worker 并发、`max_concurrent_requests` 与 raw 解码信号量。
### 11.2 模型权限(只约束 AGC LLM 模型)
- **字段**:`AgcModel`(`module-runtime/src/agc_models.rs`)末位追加权限档字段(`Basic` / `Full`),**缺省 `Basic`(失败开放)**;存量目录 JSON 缺该字段时按 `Basic` 解析。目录仍是一行 JSON + `revision` 乐观锁,非表。
- **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。
- **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。
- **生效点**:
- `GET /api/llm/models`:按账号档位过滤(`Basic` 档只看到非 `Full` 模型),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。
- `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。
- `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。
- 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。
- **覆盖范围**:只约束 AGC 对话 / Agent 的 LLM 模型;图片 / 视频 / 音频等生成模型不纳入;`/api/external/v1` 不纳入。
### 11.3 共同约定
- **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。
- 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。
---
> 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。