diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 38420eef9..dc08098a8 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -271,15 +271,17 @@ pub struct RuntimeProfileMembershipUpgradeQuote { `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 `membership_cycle_remaining_micros(resets_at, now)`。 - `quote_runtime_profile_membership_upgrade(input) -> Result`: - 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、 + 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号、 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 -- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`、`cycle_started_at <= now`、 - `cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input`(`InvalidQuoteInput`), - 不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 + 本次购买总期数**不是入参**:由目标周期类型推导(`target.cycle_kind.cycle_count()`,月付 1 / 年付 12), + 杜绝「入参期数与周期类型不一致」导致按错误期数计费。 +- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`(`cycle_count` 由上一步推导)、 + `cycle_started_at <= now`、`cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input` + (`InvalidQuoteInput`),不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 - `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 - `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 -- **「后续完整月数」= `cycle_count − cycle_index`**,不从 `cycle_resets_at` 重新锚定推算: +- **「后续完整月数」= `cycle_count − cycle_index`**(`cycle_count` 由周期类型推导),不从 `cycle_resets_at` 重新锚定推算: 月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 @@ -365,12 +367,12 @@ flowchart TD "cycleKind": "yearly", "cycleIndex": 3, "cycleCount": 12, - "amountCents": 108334, + "amountCents": 158334, "grantedPointsDelta": 1250, "monthlyBalanceAfter": 1865, "expiresAt": "...", "cycleResetsAt": "...", - "remainingFullMonths": 6 + "remainingFullMonths": 9 } ``` diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 175e77a06..f74a5a65b 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -9,6 +9,7 @@ //! - 取整:金额**向上**取到分,补点**向下**取整。 //! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。 //! +//! 「本次购买总期数」由周期类型推导(`cycle_kind.cycle_count()`:月付 1 / 年付 12),不作为入参; //! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: //! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 //! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 @@ -28,14 +29,15 @@ const MONTHS_PER_YEAR: u128 = 12; /// /// 两个套餐快照由调用方从 `profile_membership_plan` 目录表解析后传入, /// 这样价格与权益的唯一真相源始终是那张可被后台改价的表,而不是编译进代码的种子。 +/// +/// 本次购买总期数**不是**入参:它由目标周期类型推导(`target.cycle_kind.cycle_count()`, +/// 月付 1 / 年付 12),避免调用方传入与周期类型不一致的期数而算出错误金额。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipUpgradeQuoteInput { pub current: RuntimeProfileMembershipPlanSnapshot, pub target: RuntimeProfileMembershipPlanSnapshot, /// 当前期序号(1-based)。 pub cycle_index: u32, - /// 本次购买总期数(月付 1 / 年付 12)。 - pub cycle_count: u32, pub cycle_started_at_micros: i64, pub cycle_resets_at_micros: i64, pub expires_at_micros: i64, @@ -122,12 +124,14 @@ pub fn check_runtime_profile_membership_upgrade_allowed( /// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立; /// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 /// -/// 刻意不做的两项(见 review 第 12 项):`cycle_count` 与周期类型的对齐(月付 1 / 年付 12)、 -/// `now` 的窗口上界——已过期报价按「剩余比例为 0」返回,属既有契约。 +/// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 +/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按 +/// 「剩余比例为 0」返回,属既有契约。 fn validate_runtime_profile_membership_upgrade_quote_input( input: &RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { - let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > input.cycle_count; + let cycle_count = input.target.cycle_kind.cycle_count(); + let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count; let window_out_of_order = input.cycle_resets_at_micros < input.cycle_started_at_micros; let now_before_window = input.now_micros < input.cycle_started_at_micros; if cycle_index_out_of_range || window_out_of_order || now_before_window { @@ -146,7 +150,8 @@ pub fn quote_runtime_profile_membership_upgrade( let current = input.current; let target = input.target; - let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index); + let cycle_count = target.cycle_kind.cycle_count(); + let remaining_full_months = cycle_count.saturating_sub(input.cycle_index); let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( input.cycle_started_at_micros, input.cycle_resets_at_micros, @@ -227,7 +232,6 @@ mod tests { RuntimeProfileMembershipCycleKind::Yearly, ), cycle_index: 3, - cycle_count: 9, cycle_started_at_micros, cycle_resets_at_micros, expires_at_micros: beijing(2027, 3, 20, 9), @@ -251,7 +255,6 @@ mod tests { RuntimeProfileMembershipCycleKind::Monthly, ), cycle_index: 1, - cycle_count: 1, cycle_remaining_points: 615, ..base_input() }) @@ -265,14 +268,15 @@ mod tests { #[test] fn yearly_upgrade_matches_the_spec_example() { - // 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。 + // 中文注释:Plus 年 → Pro 年,年付 12 期、当前第 3 期 → 剩 9 个完整月 + 半个当前月, + // 补付 ¥1583.34、补点 1250。 let quote = quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价"); - assert_eq!(quote.amount_cents, 108_334); + assert_eq!(quote.amount_cents, 158_334); assert_eq!(quote.granted_points_delta, 1_250); assert_eq!(quote.monthly_balance_after, 1_865); - assert_eq!(quote.remaining_full_months, 6); + assert_eq!(quote.remaining_full_months, 9); assert_eq!(quote.remaining_ratio_ppm, 500_000); } @@ -315,8 +319,8 @@ mod tests { // 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。 assert_eq!(quote.remaining_ratio_ppm, 0); assert_eq!(quote.granted_points_delta, 0); - // 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。 - assert_eq!(quote.amount_cents, 100_000); + // 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。 + assert_eq!(quote.amount_cents, 150_000); } #[test] @@ -376,7 +380,7 @@ mod tests { ); assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - cycle_index: base_input().cycle_count + 1, + cycle_index: RuntimeProfileMembershipCycleKind::Yearly.cycle_count() + 1, ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) @@ -417,8 +421,8 @@ mod tests { #[test] fn continuing_upgrade_compares_against_the_current_plan() { - // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34), - // 而不是按 Starter 直接跳 Pro(¥1408.34)。 + // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1583.34), + // 而不是按 Starter 直接跳 Pro(¥2058.34)。 let starter_to_plus = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { current: snapshot( @@ -454,17 +458,17 @@ mod tests { }) .expect("Plus → Pro 应可报价"); - assert_eq!(starter_to_plus.amount_cents, 32_500); - assert_eq!(starter_to_pro.amount_cents, 140_834); - assert_eq!(plus_to_pro.amount_cents, 108_334); + assert_eq!(starter_to_plus.amount_cents, 47_500); + assert_eq!(starter_to_pro.amount_cents, 205_834); + assert_eq!(plus_to_pro.amount_cents, 158_334); assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents); } #[test] fn yearly_amount_rounds_up_to_cent_and_points_round_down() { // 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整); - // 金额 200000 × (6 + 0.333333) / 12 = 105555.55 分 → 向上到 105556; - // 补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 + // 年付 12 期、当前第 3 期 → 剩 9 个完整月,金额 200000 × (9 + 0.333333) / 12 + // = 155555.55 分 → 向上到 155556;补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 let started = beijing(2026, 6, 20, 9); let quote = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { @@ -476,7 +480,7 @@ mod tests { .expect("年付升级应可报价"); assert_eq!(quote.remaining_ratio_ppm, 333_333); - assert_eq!(quote.amount_cents, 105_556); + assert_eq!(quote.amount_cents, 155_556); assert_eq!(quote.granted_points_delta, 833); } } 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 7a1867ce7..86aa6956b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9067,7 +9067,6 @@ fn membership_upgrade_quote_input( current: current.clone(), target: target.clone(), cycle_index: row.cycle_index.max(1), - cycle_count: row.cycle_count.max(1), cycle_started_at_micros: row .cycle_started_at .map(|value| value.to_micros_since_unix_epoch())