兼容(契约): 响应 DTO 新增字段一律可选默认

- ProfileMudPointBalanceResponse.monthly_points 缺省为 0
- ProfileMembershipResponse 的 cycleIndex / cycleCount / cycleKind 缺省 1 / 1 / yearly
- ProfileMembershipPlanResponse 的 recommended / unlimitedConcurrency 缺省 false
- ProfileRechargeCenterResponse.membershipPlans 缺省空数组
- 补「旧服务端缺字段仍可整包解析」契约测试,身份与状态字段仍保持必填
- 后端架构文档新增 shared-contracts DTO 变更规则,并同步团队约定
This commit is contained in:
2026-10-05 19:37:48 +08:00
parent 00bb2a7b15
commit a938a4ef9e
3 changed files with 125 additions and 2 deletions
@@ -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`:只输出枚举、数值、布尔、有界标识与 `<redacted>` 占位,凭据、私钥、路径与 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 跳过完整参数,不隐藏计费、重试、幂等或事务规则。
@@ -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<T>`,或非 `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 只作为空库种子和兼容入口,不再作为运行期业务真相。
@@ -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<String>,
@@ -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<ProfileMembershipPlanToken, D::Error>
@@ -460,11 +465,26 @@ where
Ok(
match Option::<String>::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<ProfileMembershipModelAccessToken, D::Error>
@@ -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<ProfileRechargeProductResponse>,
/// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。
#[serde(default)]
pub membership_plans: Vec<ProfileMembershipPlanResponse>,
pub latest_order: Option<ProfileRechargeOrderResponse>,
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 不做兜底:非法目标档位必须在反序列化即被拒绝。