From 83dbd0cc776843b1b89187386df893f3ea9de44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 19:57:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=8B=92=E7=BB=9D=E8=B6=8A?= =?UTF-8?q?=E7=95=8C=E6=95=B0=E5=80=BC=E5=85=A5=E5=8F=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput 及 wire token - 报价前校验期号范围与账期窗口,避免 saturating_sub 静默夹取 - 新增 invalid_numeric_input_is_rejected 回归测试 Co-authored-by: Junie --- .../module-runtime/src/membership/upgrade.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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!(