diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index f74a5a65b..db3474798 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -158,8 +158,15 @@ pub fn quote_runtime_profile_membership_upgrade( input.now_micros, ); - let price_delta_cents = target.price_cents.saturating_sub(current.price_cents); - let points_delta = target.period_points.saturating_sub(current.period_points); + // 目录价格 / 每期泥点必须严格随 rank 递增;倒挂时宁可报错,也不算出 0 元升级。 + let price_delta_cents = target + .price_cents + .checked_sub(current.price_cents) + .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; + let points_delta = target + .period_points + .checked_sub(current.period_points) + .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; let (amount_cents, granted_points_delta) = match target.cycle_kind { RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), @@ -401,6 +408,41 @@ mod tests { ); } + #[test] + fn inverted_catalog_price_and_points_are_rejected() { + // 中文注释:后台把更高档位定得更便宜 / 给更少每期泥点时,报价必须报错, + // 而不是被 saturating_sub 静默算成 0 元升级。 + let current = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + let mut target = snapshot( + RuntimeProfileMembershipPlan::Max, + RuntimeProfileMembershipCycleKind::Yearly, + ); + + target.price_cents = current.price_cents - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + + target.price_cents = current.price_cents + 1; + target.period_points = current.period_points - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current, + target, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + #[test] fn cycle_kind_change_is_rejected() { assert_eq!(