From 21f69ef13bf932dcb475bc17af375b5193606340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:11:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=BF=81=E7=A7=BB):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E9=87=8D=E6=9E=84=E7=9A=84=E7=A0=B4=E5=9D=8F?= =?UTF-8?q?=E6=80=A7=E8=BF=81=E7=A7=BB=E5=AF=BC=E5=85=A5=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration.rs 为旧迁移包回填 profile_membership 的 plan / cycle_index / cycle_count / cycle_kind 默认值。 - migration.rs 为旧迁移包回填 profile_recharge_order 的 14 个 membership_* 快照默认值,避免回灌导入因缺列失败。 - 新增两条单元测试守护会员表与订单快照的旧包默认值。 - 同步后端架构文档的 profile_membership / profile_recharge_product_config / profile_recharge_order 表目录。 - 记录 SpacetimeDB 2.8.3 持久表删列需显式迁移的实测结论与既有 run_conflict_migration_publish 路径。 --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 10 ++ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- .../crates/spacetime-module/src/migration.rs | 139 ++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d837d45b2..b8a7f00ac 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -181,6 +181,16 @@ pub struct ProfileMembershipPlan { **回退为「保留列 + 写死默认值 + 运行时不读」**,并在生成绑定与表目录里明确标注为废弃残留。 两种路径都必须先让 schema 门禁通过,不得使用 `--delete-data=always` 绕过。 +**实测结论(2026-10-03,SpacetimeDB 2.8.3 本地 standalone)**:持久表删列会被发布检查直接拒绝, +报 `Removing a column ... requires a manual migration`,仅传 `--delete-data=never --yes=migrate,break-clients` +不足以放行;`--delete-data=on-conflict` 则会清空整库。保留列的回退未采用,改用仓库既有的显式迁移路径: +`scripts/deploy-rust-remote.sh` 的 `run_conflict_migration_publish`(默认 `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=true`) +会 导出旧库 → 清库发布新 wasm → 回灌迁移 JSON,因此不需要新增独立迁移脚本。 +旧迁移包缺少新增列的导入兼容已在 `server-rs/crates/spacetime-module/src/migration.rs` 的 +`normalize_migration_row` 补齐(`profile_membership` 回填 `plan` / 期数默认值;`profile_recharge_order` +回填 14 个 `membership_*` 快照默认值),并由同名单元测试守护。删列本身仍受本分支的 +`SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` schema 门禁确认。 + ### 2.8 会员侧遗留清理清单 实施时**删除**(不是废弃),并同步修掉引用它们的测试: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index eeb525c85..93f08ce41 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -727,7 +727,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileMembership` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at/cycle_period_days` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点。 +- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点;`plan` / `cycle_index` / `cycle_count` / `cycle_kind` 表示会员档位(`RuntimeProfileMembershipPlan` 枚举)与自然月期数。旧 `tier` 与固定 30 天的 `cycle_period_days` 已随会员档位枚举化删除(会员未上线,属经用户授权的破坏性变更;迁移导入按 `migration.rs` 的 `profile_membership` 兼容分支回填默认值)。 ### `profile_membership_plan` @@ -742,8 +742,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileRechargeProductConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。 -- 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。 +- 作用:泥点充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心展示六档泥点商品,会员档位与价格改由 `profile_membership_plan` 目录驱动。 +- 字段补充:原先给会员商品用的 `tier` 与 `membership_period_points` / `membership_period_days` / `membership_queue_limit` / `membership_discount_bps` 已随会员档位枚举化删除;旧迁移包里的这些列在导入时按未知字段丢弃。 ### `profile_played_world` @@ -754,7 +754,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileRechargeOrder` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。 +- 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。末位追加的 `membership_*` 字段保存会员购买 / 升级前后的档位、周期、每期额度、到期与刷新时间快照、补点差额和报价明细,供人工复核退款;旧迁移包缺少这些列时由 `migration.rs` 的 `profile_recharge_order` 兼容分支回填为空 / `0`。 ### `profile_recharge_refund` diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 5018f86d8..eeb4dcf00 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1284,6 +1284,67 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("expiration_last_error".to_string()) .or_insert(serde_json::Value::Null); + // 中文注释:会员购买 / 升级变更快照晚于订单表加入,旧迁移包按普通充点订单兼容。 + object + .entry("membership_change_kind".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_plan_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_plan_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_kind_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_kind_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_period_points_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_period_points_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_granted_points_delta".to_string()) + .or_insert_with(|| serde_json::Value::from(0i64)); + object + .entry("membership_cycle_index".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_expires_at_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_expires_at_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_resets_at_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_resets_at_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_price_breakdown_json".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "profile_membership" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:会员档位改枚举、账期改自然月后新增 plan / cycle_index / cycle_count / cycle_kind。 + // 会员侧未上线、无存量行;旧迁移包若带旧 `tier` / `cycle_period_days`,反序列化时会按未知字段丢弃。 + object + .entry("plan".to_string()) + .or_insert_with(|| serde_json::json!({ "Normal": [] })); + object + .entry("cycle_index".to_string()) + .or_insert_with(|| serde_json::Value::from(0u32)); + object + .entry("cycle_count".to_string()) + .or_insert_with(|| serde_json::Value::from(1u32)); + object + .entry("cycle_kind".to_string()) + .or_insert_with(|| serde_json::json!({ "Monthly": [] })); } } if table_name == "external_generation_job_summary" { @@ -1551,6 +1612,84 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["asset_kind_override"], serde_json::Value::Null); } + #[test] + fn old_profile_membership_rows_default_to_normal_plan_and_first_month() { + let normalized = normalize_migration_row( + "profile_membership", + &serde_json::json!({ "user_id": "user-1", "tier": { "Month": [] } }), + ); + + assert_eq!(normalized["plan"], serde_json::json!({ "Normal": [] })); + assert_eq!(normalized["cycle_index"], serde_json::json!(0)); + assert_eq!(normalized["cycle_count"], serde_json::json!(1)); + assert_eq!( + normalized["cycle_kind"], + serde_json::json!({ "Monthly": [] }) + ); + } + + #[test] + fn old_profile_recharge_order_rows_default_to_plain_point_order_snapshot() { + let normalized = normalize_migration_row( + "profile_recharge_order", + &serde_json::json!({ "order_id": "order-1" }), + ); + + assert_eq!( + normalized["membership_change_kind"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_plan_before"], + serde_json::Value::Null + ); + assert_eq!(normalized["membership_plan_after"], serde_json::Value::Null); + assert_eq!( + normalized["membership_cycle_kind_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_kind_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_period_points_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_period_points_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_granted_points_delta"], + serde_json::json!(0) + ); + assert_eq!( + normalized["membership_cycle_index"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_expires_at_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_expires_at_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_resets_at_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_resets_at_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_price_breakdown_json"], + serde_json::Value::Null + ); + } + #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";