修复(计费): 升级报价拒绝越界数值入参

- 新增 RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput 及 wire token

- 报价前校验期号范围与账期窗口,避免 saturating_sub 静默夹取

- 新增 invalid_numeric_input_is_rejected 回归测试

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:57:04 +08:00
parent f09701f6d4
commit 83dbd0cc77
@@ -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<RuntimeProfileMembershipUpgradeQuote, RuntimeProfileMembershipUpgradeRejection> {
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!(