修复(会员契约): 响应 token 反序列化前向兼容

- shared-contracts 为会员响应 DTO 的 plan / status / cycle_kind / model_access 字段加 deserialize_with,未知取值 fail-open 兜底到已知最高档,避免 AGC Tauri shell 等旧 Rust 客户端因后端新增 token 整包解析失败
- 入参 DTO 保持严格拒绝,不改枚举自身 Deserialize,避免 Unknown 变体污染入参
- 补响应兼容与入参严格测试;同步设计文档 §2.5 与决策日志
This commit is contained in:
2026-10-05 15:11:47 +08:00
parent c85da3a983
commit 42c4cacf72
3 changed files with 160 additions and 8 deletions
@@ -9743,3 +9743,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:`module-runtime` 新增常量 `MODEL_UNAVAILABLE` 与 `MODEL_UNAVAILABLE_FOR_TIER`,`NoModelForTier.code()` 返回后者,`Unavailable` 仍返回 `MODEL_UNAVAILABLE`;HTTP 状态(422 / 503)与端点行为不变,仅把错误码拆细,`resolve_error_codes_are_stable` 同步断言三码两两不同。
- 影响范围:`server-rs/crates/module-runtime/src/agc_model_access.rs`、`server-rs/crates/api-server/src/llm/{mod,model_access}.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §11.4、本文件。
- 验证:`cargo test -p module-runtime --lib agc_model_access`;`cargo test -p api-server llm::tests`。
## 2026-10-03 会员响应 token 反序列化前向兼容
- 背景:`shared-contracts` 的 5 个会员 token 枚举同时被入参 / 响应 DTO 复用,此前响应侧也严格反序列化;后端一旦新增 plan / status / cycle 取值,AGC Tauri shell 的 `read_profile_recharge_center` / `read_profile_membership_upgrade_quote`(`account_api.rs` 用 `serde_json::from_value` 解析整份响应)就会得到 `响应格式无效`,整个钱包页不可用。
- 决策:只给**响应** DTO 的 token 字段挂 `#[serde(deserialize_with = "...")]` 做前向兼容,未知取值 fail-open 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`;真实权益 / 下单金额以后端重算为准,避免把付费会员显示成非会员后引导重复购买)。**入参** DTO 不改,非法 token 仍在反序列化即拒;不改枚举本身的 `Deserialize`,避免 `Unknown` 变体污染入参与穷尽 match。
- 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`(4 个 helper + `ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / `ProfileMembershipUpgradeQuoteResponse` 的 8 个字段)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §2.5。后台 DTO(`AdminProfileMembershipPayload` / `AdminMembershipOrderChangePayload`)只有 TS 消费方,不做 Rust 侧处理。
- 验证:`cargo test -p shared-contracts --lib` 106 passed(含 `membership_response_tokens_tolerate_unknown_values_from_newer_backend`、`membership_request_tokens_stay_strict_for_unknown_values`)。
@@ -153,8 +153,12 @@ pub struct ProfileMembershipPlan {
- `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` /
`ProfileMembershipStatusToken` / `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`,
把 DTO 里原来的裸 `String`(后台 upsert 请求、后台会员详情、订单变更快照、会员 / 档位 / 报价响应)
收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在
反序列化阶段被拒。这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]`
收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token。**入参** DTO
(`ProfileMembershipUpgradeQuoteRequest`、`AdminUpsertProfileMembershipPlanRequest`)反序列化保持严格,
未知 token 即拒;**响应** DTO 的 token 字段挂 `deserialize_with` 做前向兼容,未知取值按 fail-open
兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`),
避免 AGC Tauri shell 等 Rust 客户端因后端新增一个 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()`(会员侧零存量,用户已授权破坏性变更)。