diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 65088e2f8..175e77a06 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -67,6 +67,8 @@ pub enum RuntimeProfileMembershipUpgradeRejection { NotUpgrade, /// 目标档位不可购买(非会员占位或已下架)。 TargetPlanNotPurchasable, + /// 报价入参的数值不变量被破坏(期号越界或账期窗口非法):拒绝,而不是静默夹取。 + InvalidQuoteInput, } impl RuntimeProfileMembershipUpgradeRejection { @@ -76,6 +78,7 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", + Self::InvalidQuoteInput => "invalid_quote_input", } } } @@ -116,10 +119,28 @@ pub fn check_runtime_profile_membership_upgrade_allowed( Ok(()) } +/// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立; +/// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 +/// +/// 刻意不做的两项(见 review 第 12 项):`cycle_count` 与周期类型的对齐(月付 1 / 年付 12)、 +/// `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 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 { + return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + } + Ok(()) +} + /// 计算补差升级报价;报价与下单重算必须共用本函数。 pub fn quote_runtime_profile_membership_upgrade( input: RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result { + validate_runtime_profile_membership_upgrade_quote_input(&input)?; check_runtime_profile_membership_upgrade_allowed(&input.current, &input.target)?; let current = input.current; @@ -344,6 +365,38 @@ mod tests { ); } + #[test] + fn invalid_numeric_input_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: 0, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: base_input().cycle_count + 1, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_resets_at_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + #[test] fn cycle_kind_change_is_rejected() { assert_eq!(