diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8fb88c73c..a584c0285 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -57,6 +57,7 @@ - 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。 - 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 - 修改 SpacetimeDB schema 时遵守字段追加/default 约束,同步 migration、表目录、生成绑定,并运行 schema 检查;删除、改名、重排或改类型前先确认迁移计划。 +- `shared-contracts` 响应 DTO 只增不删:新增字段一律可选(`Option`,或带安全 `#[serde(default ...)]`),身份 / 状态字段保持必填;删除、改名、改类型或改必填属性前先确认版本化计划,不保留仅服务旧客户端的 legacy shim。详见后端架构文档「shared-contracts DTO 变更规则」。 - 日志不递归输出完整配置、应用状态或 provider client;新增字段默认不进入安全摘要。所有 provider 配置类型(`LlmConfig`、`OssConfig`、`WechatConfig`、`WechatPayConfig`、`MattingConfig`、`VolcengineSpeechConfig`、`Hyper3dSettings`、`VectorEngineImageSettings`、`VectorEngineAudioSettings`)都必须手写脱敏 `Debug`:只输出枚举、数值、布尔、有界标识与 `` 占位,凭据、私钥、路径与 URL 一律不递归格式化,并各带一条哨兵值回归用例(`cargo test -p platform-* debug`)。新增同类配置必须照此办理,不允许直接 `derive(Debug)`。 - HTTP 横切能力集中在 Axum/Tower 中间件:正常与降级路由复用追踪层;指标与 trace 使用 `MatchedPath` 模板及固定兜底,不把请求 ID、实际资源 ID 或 query 放入指标标签。在途请求通过 RAII guard 覆盖 Future 取消与 panic unwind;请求执行和响应体存活分别计量,不能把 handler 耗时当作 SSE 全生命周期。 - 业务依赖在组合根显式装配,Axum `FromRef` 只抽取可浅拷贝的窄能力。项目元数据与 External API 鉴权不持有完整 `AppState`,测试经相同接口注入替代依赖。集中鉴权仍保留方法级 fallback、公开入口、MCP 和 body limit 顺序;Provider span 跳过完整参数,不隐藏计费、重试、幂等或事务规则。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d126fdf18..c4098f335 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -151,6 +151,14 @@ npm run check:server-rs-ddd 本地联调和人工排障不要使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`;发布目标显式传 `--server` / `--server-url`。 +## shared-contracts DTO 变更规则 + +1. 面向前端 / AGC 壳的**响应** DTO 只增不删:新增字段一律可选——`Option`,或非 `Option` 时带 `#[serde(default ...)]` 且默认值安全(空集合 / `0` / `false` / 最小合法值)。这样「客户端先于服务端发布」的滚动升级不会因为旧服务端缺字段而整包解析失败;这是兼容未来版本的手段,不是给当前旧客户端保留 legacy shim。 +2. 身份与状态类字段(例如 `ProfileMembershipResponse.plan` / `status`、`enabled`)保持必填,不给默认值:fail-open 会把付费会员误显示成非会员或反过来,静默错误比整包失败更难排查。 +3. 未知 token 值继续走 `deserialize_forward_compat_*` 兜底;它与「缺字段默认值」是两条独立路径,都要有契约测试(旧服务端缺字段、新服务端多发未知取值各一条)。 +4. 删除字段、改名、改类型或改必填属性属于破坏性变更,必须先确认版本化 / 迁移计划并更新权威文档;除非明确要求新旧客户端混跑,否则不为旧客户端保留 legacy shim。 +5. 响应 DTO 的 serde 属性不影响 ts-rs 绑定;字段集合或类型变化必须重新生成绑定并运行 `npm run check:generated-bindings`。 + ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 8e57dd995..3b869ca74 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -234,6 +234,7 @@ pub struct ProfileMudPointBalanceResponse { pub daily_free_reset_points: u64, pub daily_free_resets_at: String, /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub monthly_points: u64, pub monthly_resets_at: Option, @@ -419,6 +420,10 @@ impl ProfileMembershipChangeKindToken { /// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 /// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, /// 避免把付费会员误显示成非会员后引导重复购买。 +/// +/// 字段层面同样做前向兼容:本模块新增的响应字段一律带 `#[serde(default ...)]`,让「客户端先于 +/// 服务端发布」的滚动升级不会因为旧服务端缺字段而整包失败。身份 / 状态字段(`plan` / `status` +/// / `enabled`)保持必填:给它们兜底默认值会造成 fail-open 或 fail-closed 的静默误判。 fn deserialize_forward_compat_membership_plan<'de, D>( deserializer: D, ) -> Result @@ -460,11 +465,26 @@ where Ok( match Option::::deserialize(deserializer)?.as_deref() { Some("monthly") => ProfileMembershipCycleKindToken::Monthly, - _ => ProfileMembershipCycleKindToken::Yearly, + _ => default_forward_compat_membership_cycle_kind(), }, ) } +/// `cycle_kind` 缺失时的兜底(旧服务端还没下发该字段):与未知 token 一样落到 `yearly`。 +fn default_forward_compat_membership_cycle_kind() -> ProfileMembershipCycleKindToken { + ProfileMembershipCycleKindToken::Yearly +} + +/// `cycle_index` 缺失时的兜底:1-based,最小合法期号是 1。 +fn default_membership_cycle_index() -> u32 { + 1 +} + +/// `cycle_count` 缺失时的兜底:月付 1 期,是与 1-based 期号自洽的最小值。 +fn default_membership_cycle_count() -> u32 { + 1 +} + fn deserialize_forward_compat_model_access<'de, D>( deserializer: D, ) -> Result @@ -538,8 +558,10 @@ pub struct ProfileMembershipPlanResponse { /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + #[serde(default)] pub recommended: bool, /// 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + #[serde(default)] pub unlimited_concurrency: bool, pub enabled: bool, } @@ -574,11 +596,16 @@ pub struct ProfileMembershipResponse { #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_remaining_points: u64, /// 当前期序号(1-based)。 + #[serde(default = "default_membership_cycle_index")] pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 + #[serde(default = "default_membership_cycle_count")] pub cycle_count: u32, /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 - #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] + #[serde( + default = "default_forward_compat_membership_cycle_kind", + deserialize_with = "deserialize_forward_compat_membership_cycle_kind" + )] pub cycle_kind: ProfileMembershipCycleKindToken, } @@ -675,6 +702,7 @@ pub struct ProfileRechargeCenterResponse { pub membership: ProfileMembershipResponse, pub point_products: Vec, /// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + #[serde(default)] pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, @@ -2431,6 +2459,92 @@ mod tests { assert_eq!(known, ProfileMembershipStatusToken::Normal); } + #[test] + fn membership_response_tolerates_fields_missing_from_older_backend() { + // 客户端先于服务端发布(滚动升级)时,旧服务端缺少本轮新增字段不能整包失败; + // 新增字段一律带默认值,这里锁住默认值的具体取值。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "active", + "plan": "plus", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0 + })) + .expect("older membership payload must still deserialize"); + assert_eq!(membership.cycle_index, 1); + assert_eq!(membership.cycle_count, 1); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let balance: ProfileMudPointBalanceResponse = serde_json::from_value(json!({ + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyResetsAt": null, + "permanentPoints": 0 + })) + .expect("older balance payload must still deserialize"); + assert_eq!(balance.monthly_points, 0); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "plus", + "title": "Plus", + "rank": 3, + "monthPriceCents": 100, + "yearPriceCents": 1000, + "periodPoints": 100, + "modelAccess": "full", + "concurrentJobLimit": 8, + "enabled": true + })) + .expect("older plan payload must still deserialize"); + assert!(!plan.recommended); + assert!(!plan.unlimited_concurrency); + + let center: ProfileRechargeCenterResponse = serde_json::from_value(json!({ + "mudPointBalance": { + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyPoints": 0, + "monthlyResetsAt": null, + "permanentPoints": 0 + }, + "membership": { + "status": "normal", + "plan": "normal", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "monthly" + }, + "pointProducts": [], + "latestOrder": null, + "hasPointsRecharged": false, + "dailyFreePoints": { + "dayKey": 0, + "grantedPoints": 20, + "remainingPoints": 20, + "resetsAt": "2026-07-12T16:00:00Z", + "updatedAt": "2026-07-12T00:00:00Z" + } + })) + .expect("older recharge center payload must still deserialize"); + assert!(center.membership_plans.is_empty()); + } + #[test] fn membership_request_tokens_stay_strict_for_unknown_values() { // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。