docs(会员计费): 同步评审第 13–20 项的契约与实现口径
Project CI / Backend tests (pull_request) Failing after 28s
Project CI / Native shell tests (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Failing after 41s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m19s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m55s

- 后端架构文档:充点商品写入口收敛为 9 个字段,退役列保留且写死 0;首充赠送统一为 0
- 技术设计 §2.5/§5.7:补 ProfileMembershipStatusToken 与 5 个 token 枚举的 ts-rs 生成说明
- 技术设计 §11.4:AgcModelResolveError 增 NoModelForTier,模型解析错误统一走 typed AppError
- 决策记录:新增第 13–20 项收口条目,含影响范围与验证结论
This commit is contained in:
2026-10-04 21:56:26 +08:00
parent 14c147722a
commit 264f214fe5
3 changed files with 27 additions and 9 deletions
@@ -9683,3 +9683,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(行为变化):兑换码的 `兑换码不存在` / `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 由原来的 502 变为 400 且带机器码;procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。
- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs}`、生成绑定、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。
- 验证:`npm run spacetime:generate`;`cargo check -p api-server`;`cargo test -p api-server runtime_profile::tests` 41 passed;`cargo test -p module-runtime`、`cargo test -p spacetime-client`、`cargo test -p spacetime-module` 全通过;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。
## 2026-10-03 会员计费评审第 13–20 项收口:契约枚举化、生成类型、计费与结算防御
- 背景:评审剩下一批 medium / low 的契约与防御性缺陷,用户逐项确认后一次性修复;每项单独提交,便于回溯。
- 决策(契约枚举化,13/14/18):`shared-contracts` 新增 `ProfileMembershipStatusToken`(`normal` / `active`),`AdminProfileMembershipPayload` 的 `plan` / `status` / `cycle_kind` 改用 token 枚举;5 个会员 token 枚举补 `ts_rs::TS` + `ts(export, ...)`,前端 `packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` 改为生成类型别名 / re-export,禁止手写联合类型;`AdminAgcModel.access` 由裸 `String` 改为 `ProfileMembershipModelAccessToken`,未知值反序列化即拒。
- 决策(计费防御,15/16):`quote_runtime_profile_membership_upgrade` 显式拒绝价格 / 每期泥点 `<=`,堵住 0 元升级;`advance_beijing_months_clamped` 去掉 `expect`,超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`,不改签名以免牵动 `membership_cycle_window` / `membership_expires_at`。
- 决策(错误链 typed,17):`resolve_llm_router_client` 返回 `Result<_, AppError>`,`load_owner_llm_catalog` 的 `503 + MODEL_ACCESS_UNAVAILABLE` 与 `resolve_requested_for` 的 `MODEL_UNAVAILABLE` 不再被拍平成中文,Chat Completions 与 `/api/llm/models`、代理端点错误码对齐。
- 决策(去掉退役入参,19):后台 upsert 请求 DTO 与 SpacetimeDB procedure 入参移除 `bonus_points` / `duration_days`,`profile_recharge_product_config` 退役列保留、写死 0;删除 `RuntimeProfileFieldError::InvalidRechargeProductFields`;后台表单去掉「首充赠送」,假数据同步。
- 决策(结算归一,20):`frozen_membership_cycle_matches` 比较双方都取 `max(1)`,修复 `cycle_index == 0` 迁移行被永久判成「会员状态已变化」的问题;不新增写库副作用。
- 影响范围:`shared-contracts/{runtime,admin}.rs`、`module-runtime/{domain,commands,errors}.rs` 与 `module-runtime/src/{membership/upgrade,membership/cycle,agc_model_access}.rs`、`spacetime-client/{active,active/mapper/runtime_profile,module_bindings}`、`spacetime-module/src/runtime/active/profile.rs`、`api-server/src/{runtime_profile,llm/mod,agc_models}.rs`、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminRechargeProductPage.tsx`、`scripts/admin-web-fake-api.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件。
- 验证:`cargo test -p api-server` 1206 passed;`-p module-runtime` / `-p spacetime-client` / `-p spacetime-module` 全通过;`npm run spacetime:generate`、`npm run check:spacetime-schema`(92 表)、`npm run check:generated-bindings`(117 文件)、`npm run admin-web:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。
@@ -151,9 +151,11 @@ pub struct ProfileMembershipPlan {
- 新增 `RuntimeProfileMembershipModelAccess { Basic, Full }`(仅新增字段,本期不执行)。
- 新增 `RuntimeProfileMembershipChangeKind { Purchase, Upgrade }`(订单快照用,替代字符串)。
- `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` /
`ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`,把 DTO 里原来的裸
`String`(后台 upsert 请求、订单变更快照、会员 / 档位 / 报价响应)收口为 typed enum;
与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在反序列化阶段被拒。
`ProfileMembershipStatusToken` / `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`,
把 DTO 里原来的裸 `String`(后台 upsert 请求、后台会员详情、订单变更快照、会员 / 档位 / 报价响应)
收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在
反序列化阶段被拒。这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]`
导出到 `packages/shared/src/contracts/generated/`,前端只 re-export,不再手写字符串联合类型。
- **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }`
及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。
- `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant`
@@ -429,6 +431,11 @@ flowchart TD
`cargo test -p shared-contracts --features ts-bindings export_bindings` 生成
`packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts`,
`packages/shared/src/contracts/runtime.ts` 只 re-export 成 `ProfileMudPointBalance`。
- 会员 token(`profileMembershipPlanToken` / `profileMembershipCycleKindToken` /
`profileMembershipStatusToken` / `profileMembershipModelAccessToken` /
`profileMembershipChangeKindToken`)同样由 ts-rs 导出为字符串联合(5 个生成文件),
`packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts`
改为生成类型别名 / re-export,删掉本地手写字面量。
- `u64` 字段加 `ts(type = "number")`:JSON 走数字,不用 `bigint`,避免序列化与前端类型分叉。
- `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。
- 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。
@@ -610,12 +617,12 @@ flowchart TD
### 11.4 实现落点(2026-10-03 编码约定)
- **`module-runtime`**:
- 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NotAvailableForPlan}`,以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。
- 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(`Unavailable` 与 `NoModelForTier` 复用 `MODEL_UNAVAILABLE` 码,分别映射 422 / 503),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。
- `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。
- **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。
- **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。
- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。
- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。
- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,该档无可用模型 / 所选模型不可用分别返回 503 / 422 + `MODEL_UNAVAILABLE`。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。
- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;`AdminAgcModel.access` 与 `AdminUpsertProfileMembershipPlanRequest.model_access` 一样用 `ProfileMembershipModelAccessToken`,未知 token 反序列化即拒;**不做**自动播种或代码内清单。
- **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。
- **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。
- **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。
@@ -154,10 +154,10 @@ npm run check:server-rs-ddd
## 账户充值数据契约
1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。
2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 + 90 泥点 / 1800 分`、`points_300 = 300 + 150 泥点 / 3000 分`、`points_680 = 680 + 340 泥点 / 6800 分`。`points_60` 的首充赠送为 `0`;后三档首次购买分别赠送基础泥点的 `50%`。
3. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。
2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 泥点 / 1800 分`、`points_300 = 300 泥点 / 3000 分`、`points_680 = 680 泥点 / 6800 分`。所有档位的首充赠送一律为 `0`(已取消首充加赠)。
3. 后台通过 `/admin/api/profile/recharge-products` 读写充点商品配置;写入口只接受 `productId`、标题、商品类型(仅 `points`)、金额分、基础泥点、徽标、说明、启用状态和排序。`bonus_points` / `duration_days` 以及旧的会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率列都是退役列:表中保留以兼容存量 schema,回读固定为 `0`,不再进入 upsert 入参。
4. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。充值中心 BFF 还必须在 `mudPointBalance` 下发 `totalPoints`、`permanentPoints`、`limitedPoints`、`limitedExpiresAt`、`dailyFreePoints`、`dailyFreeResetPoints` 和 `dailyFreeResetsAt`;前端以该 read model 为真相源,不得自行用总额相减推算余额桶。当前版本公开 UI 只渲染不限时泥点和每日免费泥点,`limitedPoints` 与 `limitedExpiresAt` 仅保留给存量兼容和后端结算。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。首充赠送当前恒为 `0`,因此该判定不改变前台展示和结算金额;兼容分支保留,待后续彻底移除首充赠送概念时一并删除。
6. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
7. 当前版本公开充值 UI 只展示泥点商品,不渲染会员购买页签、会员商品、购买会员或升级会员入口。充值中心响应中的会员商品兼容字段、默认会员商品、`profile_membership` 和周期刷新逻辑继续保留;存量会员的 `cycle_remaining_points` 仍通过充值中心 read model 下发用于兼容和结算,但不作为限时泥点在当前版本前台展示。
8. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。