From f9b0cf786dcd3324bdd66356deb540a3cd699518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:06:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7=E8=A1=A5?= =?UTF-8?q?=E5=B7=AE=E8=AE=A2=E5=8D=95=E5=88=B0=E6=9C=9F=E5=90=8E=E6=8C=89?= =?UTF-8?q?=E6=96=B0=E8=B4=AD=E4=BB=B7=E6=A0=A1=E9=AA=8C=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 check_expired_membership_upgrade_settlement:补差订单在支付前会员已到期时,订单金额必须达到目标周期当前目录价才允许按新购结算,否则拒绝 新增 RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement 及错误码字符串 module-runtime 导出该判定函数供结算侧复用 spacetime-module 结算分支调用该判定,目录缺档位时返回明确错误 api-server 把「会员已到期,请按新购价格重新下单」映射为 400 业务错误 补充过期补差订单必须覆盖整期新购价的单元用例 --- .../crates/api-server/src/runtime_profile.rs | 1 + server-rs/crates/module-runtime/src/lib.rs | 5 +- .../module-runtime/src/membership/upgrade.rs | 48 +++++++++++++++++++ .../src/runtime/active/profile.rs | 12 ++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 4854fff94..8b3964b82 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1521,6 +1521,7 @@ fn is_runtime_profile_membership_domain_error(message: &str) -> bool { "当前不是会员,请直接选择套餐购买" | "当前套餐已生效,无需重复购买" | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" + | "会员已到期,请按新购价格重新下单" ) } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 4a1381e8b..fe77d4f1e 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -23,8 +23,9 @@ pub use llm_billing::*; pub use membership::{ MEMBERSHIP_UNLIMITED_CONCURRENCY, RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, RuntimeProfileMembershipUpgradeQuoteInput, - beijing_local_micros, build_runtime_profile_membership_plan_snapshot, is_unlimited_concurrency, - membership_cycle_window, membership_expires_at, parse_runtime_profile_membership_product_id, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 1c44fce52..30545a1d9 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -16,6 +16,9 @@ //! //! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期, //! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。 +//! +//! 报价冻结后会员可能在支付前到期,此时补差价不再覆盖整期新购价, +//! 由 [`check_expired_membership_upgrade_settlement`] 在结算侧判定。 use serde::{Deserialize, Serialize}; @@ -74,6 +77,8 @@ pub enum RuntimeProfileMembershipUpgradeRejection { TargetPlanNotPurchasable, /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, + /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 + MembershipExpiredBeforeSettlement, } impl RuntimeProfileMembershipUpgradeRejection { @@ -84,6 +89,7 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", Self::InvalidQuoteInput => "invalid_quote_input", + Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", } } } @@ -210,6 +216,21 @@ pub fn quote_runtime_profile_membership_upgrade( }) } +/// 补差价订单在「会员已到期」时是否仍可按新购结算。 +/// +/// 补差价只覆盖升级当期剩余权益的差额,前提是升级时会员仍在有效期内;一旦拖到到期后才支付, +/// 同一笔冻结金额就不再覆盖整期新购价。只有订单金额达到目标周期当前目录价才继续按新购结算, +/// 否则拒绝,避免用零头价换一整个周期。 +pub fn check_expired_membership_upgrade_settlement( + order_amount_cents: u64, + target_price_cents: u64, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if order_amount_cents < target_price_cents { + return Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement); + } + Ok(()) +} + /// 向上取整的整数除法(金额取整到分)。 /// /// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; @@ -551,4 +572,31 @@ mod tests { assert_eq!(quote.amount_cents, 155_556); assert_eq!(quote.granted_points_delta, 833); } + + #[test] + fn expired_settlement_needs_the_full_target_price() { + // 中文注释:年付 Pro 目录价 299_000 分;补差价 158_334 分覆盖不了整期新购价,必须拒绝, + // 否则用户可在到期前用补差价建单、到期后再支付,等于用零头价换一个完整周期。 + let pro_yearly = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + assert_eq!(pro_yearly.price_cents, 299_000); + assert_eq!( + check_expired_membership_upgrade_settlement(158_334, pro_yearly.price_cents), + Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement) + ); + // 中文注释:订单金额本身已覆盖整期新购价(改价后挡位降价的容错路径)时按新购结算。 + assert_eq!( + check_expired_membership_upgrade_settlement(pro_yearly.price_cents, 299_000), + Ok(()) + ); + assert_eq!( + check_expired_membership_upgrade_settlement( + pro_yearly.price_cents + 1, + pro_yearly.price_cents + ), + Ok(()) + ); + } } diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index a4891d3d3..2ffc40acc 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9832,7 +9832,9 @@ fn build_profile_membership_snapshot( /// - 结算口径以订单建单时冻结的快照为准:档位、每期泥点、补点与价格都不再回查目录, /// 因此下单后改价 / 改每期泥点 / 下架档位都不会影响已受理订单;历史 Pending 订单回退到按目录重算。 /// - 有效期内:只允许「同周期升更高档位」,按快照补点,刷新日与到期日不变。 -/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻。 +/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻; +/// 但补差价订单结算时会员已到期,必须先满足 [`check_expired_membership_upgrade_settlement`], +/// 否则拒绝——补差价只覆盖当期剩余权益的差额,不覆盖整期新购价。 /// /// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( @@ -9872,7 +9874,13 @@ fn apply_profile_membership_purchase( purchased_at, ); } - // 中文注释:会员在支付前已到期,退化为按冻结档位重新起算,避免已付款订单卡死。 + // 中文注释:会员在支付前已到期,冻结的补差价只覆盖当期剩余权益的差额, + // 不再覆盖整期新购价;只有订单金额达到目标周期当前目录价才按新购结算, + // 否则拒绝,避免用户用零头补差价换到一个完整周期。 + let target = membership_plan_snapshot(ctx, settlement.plan, settlement.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", settlement.plan.as_str()))?; + check_expired_membership_upgrade_settlement(order.amount_cents, target.price_cents) + .map_err(|_| "会员已到期,请按新购价格重新下单".to_string())?; } apply_profile_membership_fresh_purchase(