diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index df71b8e1c..1c44fce52 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -13,6 +13,9 @@ //! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: //! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 //! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 +//! +//! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期, +//! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。 use serde::{Deserialize, Serialize}; @@ -69,7 +72,7 @@ pub enum RuntimeProfileMembershipUpgradeRejection { NotUpgrade, /// 目标档位不可购买(非会员占位或已下架)。 TargetPlanNotPurchasable, - /// 报价入参的数值不变量被破坏(期号越界或账期窗口非法):拒绝,而不是静默夹取。 + /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, } @@ -125,19 +128,24 @@ pub fn check_runtime_profile_membership_upgrade_allowed( /// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 /// /// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 -/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按 -/// 「剩余比例为 0」返回,属既有契约。 +/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。 /// /// 账期窗口要求严格递增(`cycle_started_at < cycle_resets_at`):零时长窗口没有有效分母, /// 按非法入参拒绝,而不是让它退化成「剩余比例为 0」。 +/// +/// `now` 必须落在账期窗口内(`cycle_started_at <= now < cycle_resets_at`):本期剩余比例与 +/// 期号是同一期账期的两个口径,账期已刷新仍按旧 `cycle_index` 报价会多收整整一个完整月 +/// (年付还会把补点算成 0)。生产入口先用 `project_profile_membership_cycle_at` 把会员行 +/// 投射到当前账期,因此这里拒绝的只会是「拿过期账期直接报价」的错误调用方。 fn validate_runtime_profile_membership_upgrade_quote_input( input: &RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { 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 { + let now_outside_window = input.now_micros < input.cycle_started_at_micros + || input.now_micros >= input.cycle_resets_at_micros; + if cycle_index_out_of_range || window_out_of_order || now_outside_window { return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); } Ok(()) @@ -325,18 +333,27 @@ mod tests { ), 0 ); + } - let quote = + #[test] + fn quote_rejects_now_at_or_after_cycle_reset() { + let input = base_input(); + // 中文注释:账期已刷新仍按旧 `cycle_index` 报价会多收一个完整月(年付还把补点算成 0), + // 因此窗口上界与下界一样按非法入参拒绝;调用方应先投射账期到当前时刻。 + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros, + ..input.clone() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { now_micros: input.cycle_resets_at_micros + DAY_MICROS, ..input - }) - .expect("年付升级应可报价"); - // 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。 - assert_eq!(quote.remaining_ratio_ppm, 0); - assert_eq!(quote.granted_points_delta, 0); - // 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。 - assert_eq!(quote.amount_cents, 150_000); + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); } #[test]