diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index a38eb90b7..cedd299e1 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -30,10 +30,10 @@ pub use membership::{ RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, beijing_local_micros, build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, - membership_expires_at, parse_runtime_profile_membership_product_id, - quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, - runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, - runtime_profile_membership_product_id, + membership_expires_at, missing_runtime_profile_membership_plan_rows, + parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, + resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, + runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, validate_runtime_profile_membership_plan_row_against_catalog, }; diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 8d0bce005..828f14b55 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -140,16 +140,28 @@ pub fn resolve_runtime_profile_membership_plan_row( .find(|row| row.plan == plan) } +/// 目录里缺失的档位行(`existing` 中不存在的目录行),按目录顺序返回。 +/// +/// 用于把「只在空表时整表播种」改成「缺哪行补哪行」的幂等回填:老库可能只有部分档位, +/// 只在空表播种会让缺行的库永远缺下去。已存在的行不返回,调用方只插入缺失行、不覆盖后台改价。 +pub fn missing_runtime_profile_membership_plan_rows( + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Vec { + runtime_profile_membership_plan_catalog() + .into_iter() + .filter(|required| !existing.iter().any(|row| row.plan == required.plan)) + .collect() +} + /// 校验后台写入的档位不会破坏目录不变量。 /// /// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 -/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时 -/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。 +/// 先校验 `existing` 覆盖除 `candidate.plan` 外的全部目录档位,再逐行比较 `candidate` 与其余 +/// 每一行:这样任何单次改写都不能制造新的倒挂,也不会因为「漏传某一行」而静默放过倒挂。 /// -/// **前置条件**:`existing` 必须覆盖除 `candidate.plan` 外的全部在售档位。本函数只校验 -/// `candidate` 相对现存行的单调性,无法发现「缺失档位」导致的倒挂(例如漏传某一行时, -/// 月价 / 年价 / 每期泥点的反转关系会静默通过,之后才在报价或目录读取时暴露)。当前唯一 -/// 调用方传入完整目录表,新增调用方时必须保持这一约定。 +/// **调用方前置动作**:缺行的历史库必须先经 `ensure_default_profile_membership_plan` 回填 +/// 缺失档位(该函数按档位逐行补齐,不再只在空表时整表播种),否则本函数会直接拒绝写入。 +/// 这是有意的:缺行库继续允许任意单行改写会让目录长期残缺,报价时才暴露错误。 /// /// `model_access` 与 `concurrent_job_limit` 本期只落字段、只用于展示与人工判断,服务端不做拦截, /// 因此这里不校验它们随 rank 单调;若将来据其做准入判定,需要在此补充倒挂校验。 @@ -157,6 +169,16 @@ pub fn validate_runtime_profile_membership_plan_row_against_catalog( candidate: &RuntimeProfileMembershipPlanRecord, existing: &[RuntimeProfileMembershipPlanRecord], ) -> Result<(), RuntimeProfileFieldError> { + // 覆盖校验:缺一行就无法判断它与 candidate 的相对关系,必须显式拒绝而不是静默跳过。 + for required in runtime_profile_membership_plan_catalog() { + if required.plan == candidate.plan { + continue; + } + if !existing.iter().any(|row| row.plan == required.plan) { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + for row in existing.iter().filter(|row| row.plan != candidate.plan) { if row.rank == candidate.rank { return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); @@ -379,6 +401,38 @@ mod tests { } } + #[test] + fn admin_upsert_rejects_a_catalog_missing_a_plan() { + // 中文注释:缺行的库(例如老版本档位更少)必须先回填再校验。漏传其它档位时, + // 即使 candidate 本身与现存行单调,也必须拒绝,避免残缺目录继续扩大。 + let candidate = row(RuntimeProfileMembershipPlan::Plus); + let existing = vec![candidate.clone()]; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &existing), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn missing_plan_rows_backfills_only_absent_plans() { + // 老库只有 Plus:应补出目录里其余全部档位,且不重复返回已存在的 Plus。 + let existing = vec![row(RuntimeProfileMembershipPlan::Plus)]; + let missing = missing_runtime_profile_membership_plan_rows(&existing); + let plans = missing.iter().map(|row| row.plan).collect::>(); + let expected = runtime_profile_membership_plan_catalog() + .into_iter() + .map(|row| row.plan) + .filter(|plan| *plan != RuntimeProfileMembershipPlan::Plus) + .collect::>(); + assert_eq!(plans, expected); + + // 目录已补齐时不返回任何行:读取入口幂等、不再写库。 + assert!( + missing_runtime_profile_membership_plan_rows(&runtime_profile_membership_plan_catalog()) + .is_empty() + ); + } + #[test] fn admin_upsert_allows_a_monotonic_price_change() { let catalog = runtime_profile_membership_plan_catalog(); diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 68b4f0586..6b5cc12fe 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9373,12 +9373,19 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig /// 仍要靠读取 helper 懒播种。**代价**:报价 / 充值中心 / 后台查询等只读入口在目录为空的首次调用会写库; /// 表非空后本函数直接返回,后续调用保持只读。若将来改为发布期统一播种,再删除这里的懒播种与注释。 fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { - if ctx.db.profile_membership_plan().count() > 0 { - return; - } + // 中文注释:按档位逐行补齐,而不是只在空表时整表播种——增量发布不会重跑 `init`, + // 老库可能只有部分档位;只在空表播种会让缺行的库永远缺下去,目录覆盖校验与报价都会受影响。 + // 已存在的行保持后台改过的价格 / 权益不动,只补目录里没有的档位。 + let existing = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + let missing = module_runtime::missing_runtime_profile_membership_plan_rows(&existing); let now = ctx.timestamp; - for row in runtime_profile_membership_plan_catalog() { + for row in missing { ctx.db .profile_membership_plan() .insert(ProfileMembershipPlan { @@ -9399,12 +9406,12 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { } } -/// 模块初始化生命周期:只在数据库首次创建时把会员档位目录整表播种。 +/// 模块初始化生命周期:数据库首次创建时把会员档位目录整表播种。 /// /// 本 reducer **不覆盖**「目录表已存在但为空」的存放量库:增量发布不会重跑 `init`。这类库由 -/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 懒播种兜底,因此报价 / 充值中心 / -/// 后台查询等「只读」入口首次调用仍可能写库(表非空后幂等只读)。这是当前有意保留的播种策略, -/// 不是漏改;若改为发布期统一播种,需同步删除懒播种调用并更新本注释。 +/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 按档位逐行补齐兜底,因此报价 / +/// 充值中心 / 后台查询等「只读」入口首次调用仍可能写库(全部档位补齐后幂等无写)。这是当前 +/// 有意保留的播种策略,不是漏改;若改为发布期统一回填,需同步删除懒补齐调用并更新本注释。 #[spacetimedb::reducer(init)] pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { ensure_default_profile_membership_plan(ctx);