修复(会员档位): 缺行目录改为逐档回填并在写入前校验覆盖

- ensure_default_profile_membership_plan 从「空表整表播种」改为按目录逐档补齐缺失行,已存在行不覆盖后台改价
- 新增 missing_runtime_profile_membership_plan_rows 纯函数并在 module-runtime 根导出,回填逻辑可单测
- validate_runtime_profile_membership_plan_row_against_catalog 增加「existing 必须覆盖除 candidate 外全部档位」校验,缺行直接拒绝写入
- 更新 init 注释:懒补齐而非懒播种,全部档位补齐后幂等无写
- 补缺行拒绝与回填幂等两条目录单测
This commit is contained in:
2026-10-05 20:22:37 +08:00
parent a938a4ef9e
commit e27ce5573f
3 changed files with 79 additions and 18 deletions
+4 -4
View File
@@ -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,
};
@@ -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<RuntimeProfileMembershipPlanRecord> {
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::<Vec<_>>();
let expected = runtime_profile_membership_plan_catalog()
.into_iter()
.map(|row| row.plan)
.filter(|plan| *plan != RuntimeProfileMembershipPlan::Plus)
.collect::<Vec<_>>();
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();
@@ -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::<Vec<_>>();
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);