diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c81ab03c1..9fe895771 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`)。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index ae6b19de4..49bb7b3b7 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -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()`(会员侧零存量,用户已授权破坏性变更)。 diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index be73898d7..927446144 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -395,6 +395,76 @@ impl ProfileMembershipChangeKindToken { } } +/// 响应侧前向兼容反序列化:后端新增 token 取值时,旧 Rust 客户端(AGC Tauri shell 的 +/// `account_api` 会把整份 `ProfileRechargeCenterResponse` / `...QuoteResponse` 反序列化) +/// 不应因为一个枚举取值而整包失败。 +/// +/// 只用于**响应** DTO 字段;入参 DTO(`ProfileMembershipUpgradeQuoteRequest`、 +/// `AdminUpsertProfileMembershipPlanRequest`)继续用严格 enum,非法 token 仍然拒绝。 +/// +/// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 +/// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, +/// 避免把付费会员误显示成非会员后引导重复购买。 +fn deserialize_forward_compat_membership_plan<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipPlanToken::Normal, + Some("starter") => ProfileMembershipPlanToken::Starter, + Some("plus") => ProfileMembershipPlanToken::Plus, + Some("pro") => ProfileMembershipPlanToken::Pro, + Some("max") => ProfileMembershipPlanToken::Max, + _ => ProfileMembershipPlanToken::Max, + }, + ) +} + +fn deserialize_forward_compat_membership_status<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipStatusToken::Normal, + _ => ProfileMembershipStatusToken::Active, + }, + ) +} + +fn deserialize_forward_compat_membership_cycle_kind<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("monthly") => ProfileMembershipCycleKindToken::Monthly, + _ => ProfileMembershipCycleKindToken::Yearly, + }, + ) +} + +fn deserialize_forward_compat_model_access<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("basic") => ProfileMembershipModelAccessToken::Basic, + _ => ProfileMembershipModelAccessToken::Full, + }, + ) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeProductResponse { @@ -412,14 +482,16 @@ pub struct ProfileRechargeProductResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipPlanResponse { - /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`。 + /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub plan: ProfileMembershipPlanToken, pub title: String, pub rank: u8, pub month_price_cents: u64, pub year_price_cents: u64, pub period_points: u64, - /// 模型权限 token:`basic` / `full`。 + /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_model_access")] pub model_access: ProfileMembershipModelAccessToken, /// 并发上限;`128` 表示不设上限。 pub concurrent_job_limit: u32, @@ -433,9 +505,11 @@ pub struct ProfileMembershipPlanResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipResponse { - /// 会员状态 token:`normal` / `active`。 + /// 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] pub status: ProfileMembershipStatusToken, - /// 当前档位 token;非会员与已到期都是 `normal`。 + /// 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub plan: ProfileMembershipPlanToken, pub started_at: Option, pub expires_at: Option, @@ -448,7 +522,8 @@ pub struct ProfileMembershipResponse { pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 pub cycle_count: u32, - /// 周期 token:`monthly` / `yearly`。 + /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] pub cycle_kind: ProfileMembershipCycleKindToken, } @@ -842,9 +917,15 @@ pub struct ProfileMembershipUpgradeQuoteRequest { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipUpgradeQuoteResponse { + /// 当前档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub current_plan: ProfileMembershipPlanToken, + /// 目标档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub target_plan: ProfileMembershipPlanToken, - /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致。 + /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致; + /// 未知取值按 `yearly` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] pub cycle_kind: ProfileMembershipCycleKindToken, pub cycle_index: u32, pub cycle_count: u32, @@ -2171,4 +2252,64 @@ mod tests { json!("treasure_reward") ); } + + #[test] + fn membership_response_tokens_tolerate_unknown_values_from_newer_backend() { + // 旧 Rust 客户端(AGC Tauri shell)反序列化响应时,遇到后端新增的 token 取值 + // 不能整包失败;未知取值按 fail-open 兜底到最高已知档。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "paused", + "plan": "ultra", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "quarterly" + })) + .expect("unknown membership tokens must not fail the whole response"); + assert_eq!(membership.status, ProfileMembershipStatusToken::Active); + assert_eq!(membership.plan, ProfileMembershipPlanToken::Max); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "ultra", + "title": "Ultra", + "rank": 9, + "monthPriceCents": 0, + "yearPriceCents": 0, + "periodPoints": 0, + "modelAccess": "unlimited", + "concurrentJobLimit": 128, + "recommended": false, + "unlimitedConcurrency": true, + "enabled": true + })) + .expect("unknown plan tokens must not fail the whole response"); + assert_eq!(plan.plan, ProfileMembershipPlanToken::Max); + assert_eq!(plan.model_access, ProfileMembershipModelAccessToken::Full); + + // 已知取值仍然原样解析(兜底不吞掉合法值)。 + let known: ProfileMembershipStatusToken = + serde_json::from_value(json!("normal")).expect("known token should deserialize"); + assert_eq!(known, ProfileMembershipStatusToken::Normal); + } + + #[test] + fn membership_request_tokens_stay_strict_for_unknown_values() { + // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。 + assert!( + serde_json::from_value::(json!({ + "targetPlan": "ultra" + })) + .is_err() + ); + } }