兼容(契约): 响应 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:
@@ -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 不做兜底:非法目标档位必须在反序列化即被拒绝。
|
||||
|
||||
Reference in New Issue
Block a user