From 2b77fa08c301d0c39e7bb2d29b1962f7dea6e9a9 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 14:11:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BC=9A=E5=91=98=E8=A1=A5=E5=B7=AE=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 membership_upgrade.rs:quote_runtime_profile_membership_upgrade 与 RuntimeProfileMembershipUpgradeQuoteInput / RuntimeProfileMembershipUpgradeQuote - 月付按「新月价 − 当前月价」「新每期额度 − 当前每期额度」;年付按 (新年价 − 当前年价)/12×(后续完整月数 + 本期剩余比例) - check_runtime_profile_membership_upgrade_allowed 实现允许操作矩阵:非会员 / 周期变更 / 同档降档 / 目标不可购买一律拒绝 - membership_cycle_remaining_ratio_ppm 计算本期剩余比例(ppm,向下取整) - 金额向上取到分、补点向下取整;有效期 / 刷新日 / 期数保持不变 - 单测复现 spec 示例:Plus 月→Pro 月 ¥200 / 2500 点 / 余额 3115;Plus 年→Pro 年 ¥1083.34 / 1250 点 / 余额 1865 - 设计文档同步报价函数签名与「后续完整月数 = cycle_count − cycle_index」口径 Co-authored-by: Junie --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 10 +- server-rs/crates/module-runtime/src/lib.rs | 2 + .../module-runtime/src/membership_upgrade.rs | 393 ++++++++++++++++++ 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 server-rs/crates/module-runtime/src/membership_upgrade.rs diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index cdb9c9fbd..00e7ca41f 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -269,8 +269,14 @@ pub struct RuntimeProfileMembershipUpgradeQuote { `advance_beijing_months_clamped(anchor, months)`、`beijing_local_date_parts(micros) -> (年月日, 当日微秒)`、 `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 `membership_cycle_remaining_micros(resets_at, now)`。 -- `quote_membership_upgrade(current, target, now) -> quote`: - 金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- `quote_runtime_profile_membership_upgrade(input) -> Result`: + 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、 + 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: + §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 +- `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 +- **「后续完整月数」= `cycle_count − cycle_index`**,不从 `cycle_resets_at` 重新锚定推算: + 月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 - `is_unlimited_concurrency(limit) -> bool`:`limit >= 128` 视为不设上限(哨兵值集中在领域层一处)。 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3c905bf55..2cb68ce6b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -10,6 +10,8 @@ mod membership_limits; pub use membership_limits::*; mod membership_catalog; pub use membership_catalog::*; +mod membership_upgrade; +pub use membership_upgrade::*; mod application; pub use agc_models::*; mod commands; diff --git a/server-rs/crates/module-runtime/src/membership_upgrade.rs b/server-rs/crates/module-runtime/src/membership_upgrade.rs new file mode 100644 index 000000000..029f7274f --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_upgrade.rs @@ -0,0 +1,393 @@ +//! 会员补差升级报价(纯函数,无 IO)。 +//! +//! 规则来自实施计划 §3.3,并且**金额与取整只有这一处实现**:报价接口与下单重算必须调用同一个函数, +//! 逐字段一致,前端展示价不是真相源。 +//! +//! - 月付:`应付 = 新月价 − 当前月价`,`补点 = 新每期额度 − 当前每期额度`(不按时间折算)。 +//! - 年付:`应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余比例)`, +//! `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。 +//! - 取整:金额**向上**取到分,补点**向下**取整。 +//! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。 +//! +//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: +//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 +//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 + +use serde::{Deserialize, Serialize}; + +use crate::membership_catalog::resolve_runtime_profile_membership_plan_snapshot; +use crate::membership_cycle::membership_cycle_remaining_micros; +use crate::{RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipPlan}; + +/// 比例分母:百万分之一。 +const REMAINING_RATIO_PPM: u128 = 1_000_000; +/// 年付按 12 个自然月折算。 +const MONTHS_PER_YEAR: u128 = 12; + +/// 报价接口与下单重算的共用输入。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteInput { + pub current_plan: RuntimeProfileMembershipPlan, + pub current_cycle_kind: RuntimeProfileMembershipCycleKind, + pub target_plan: RuntimeProfileMembershipPlan, + pub target_cycle_kind: RuntimeProfileMembershipCycleKind, + /// 当前期序号(1-based)。 + pub cycle_index: u32, + /// 本次购买总期数(月付 1 / 年付 12)。 + pub cycle_count: u32, + pub cycle_started_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub expires_at_micros: i64, + pub cycle_remaining_points: u64, + pub now_micros: i64, +} + +/// 补差升级报价结果;有效期与刷新日与变更前一致。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuote { + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub cycle_index: u32, + pub remaining_full_months: u32, + pub remaining_ratio_ppm: u32, +} + +/// 升级被拒绝的原因;对外应映射为 4xx 与可读文案,而不是静默算出金额。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipUpgradeRejection { + /// 当前不是会员:应走下单购买流程,而不是补差升级。 + NotAMember, + /// 月转年 / 年转月:本期不支持即时转换,到期后按完整价格重新选择。 + CycleKindChangeUnsupported, + /// 同档位(重复购买)或降档:不允许,到期后再重新选择。 + NotUpgrade, + /// 目标档位不可购买(非会员占位或已下架)。 + TargetPlanNotPurchasable, + /// 目录缺少对应档位 / 周期的价格。 + MissingCatalogEntry, +} + +impl RuntimeProfileMembershipUpgradeRejection { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotAMember => "not_a_member", + Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", + Self::NotUpgrade => "not_upgrade", + Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", + Self::MissingCatalogEntry => "missing_catalog_entry", + } + } +} + +/// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 +pub fn membership_cycle_remaining_ratio_ppm( + cycle_started_at_micros: i64, + cycle_resets_at_micros: i64, + now_micros: i64, +) -> u32 { + let total_micros = cycle_resets_at_micros.saturating_sub(cycle_started_at_micros); + if total_micros <= 0 { + return 0; + } + let remaining_micros = membership_cycle_remaining_micros(cycle_resets_at_micros, now_micros); + let remaining_micros = remaining_micros.min(total_micros); + (u128::from(remaining_micros as u64) * REMAINING_RATIO_PPM / u128::from(total_micros as u64)) + as u32 +} + +/// 允许操作矩阵(实施计划 §3.4):只有「已是会员 + 同周期 + 升更高档位 + 目标可购买」才允许升级。 +pub fn check_runtime_profile_membership_upgrade_allowed( + current_plan: RuntimeProfileMembershipPlan, + target_plan: RuntimeProfileMembershipPlan, + current_cycle_kind: RuntimeProfileMembershipCycleKind, + target_cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if !current_plan.is_purchasable() { + return Err(RuntimeProfileMembershipUpgradeRejection::NotAMember); + } + if current_cycle_kind != target_cycle_kind { + return Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported); + } + if crate::runtime_profile_membership_plan_rank(target_plan) + <= crate::runtime_profile_membership_plan_rank(current_plan) + { + return Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade); + } + if !crate::is_runtime_profile_membership_plan_purchasable(target_plan) { + return Err(RuntimeProfileMembershipUpgradeRejection::TargetPlanNotPurchasable); + } + Ok(()) +} + +/// 计算补差升级报价;报价与下单重算必须共用本函数。 +pub fn quote_runtime_profile_membership_upgrade( + input: RuntimeProfileMembershipUpgradeQuoteInput, +) -> Result { + check_runtime_profile_membership_upgrade_allowed( + input.current_plan, + input.target_plan, + input.current_cycle_kind, + input.target_cycle_kind, + )?; + + let current = resolve_runtime_profile_membership_plan_snapshot( + input.current_plan, + input.current_cycle_kind, + ) + .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; + let target = resolve_runtime_profile_membership_plan_snapshot( + input.target_plan, + input.target_cycle_kind, + ) + .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; + + let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index); + let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + 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); + + let (amount_cents, granted_points_delta) = match input.target_cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), + RuntimeProfileMembershipCycleKind::Yearly => { + let weighted_months_ppm = u128::from(remaining_full_months) * REMAINING_RATIO_PPM + + u128::from(remaining_ratio_ppm); + let amount_cents = ceil_div( + u128::from(price_delta_cents) * weighted_months_ppm, + MONTHS_PER_YEAR * REMAINING_RATIO_PPM, + ); + let granted_points_delta = (u128::from(points_delta) * u128::from(remaining_ratio_ppm) + / REMAINING_RATIO_PPM) as u64; + (amount_cents, granted_points_delta) + } + }; + + Ok(RuntimeProfileMembershipUpgradeQuote { + amount_cents, + granted_points_delta, + monthly_balance_after: input + .cycle_remaining_points + .saturating_add(granted_points_delta), + expires_at_micros: input.expires_at_micros, + cycle_resets_at_micros: input.cycle_resets_at_micros, + cycle_index: input.cycle_index, + remaining_full_months, + remaining_ratio_ppm, + }) +} + +/// 向上取整的整数除法(金额取整到分)。 +fn ceil_div(numerator: u128, denominator: u128) -> u64 { + if denominator == 0 { + return 0; + } + ((numerator + denominator - 1) / denominator) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::membership_cycle::beijing_local_micros; + + const DAY_MICROS: i64 = 86_400_000_000; + + fn beijing(year: i32, month: u8, day: u8, hour: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000) + } + + fn base_input() -> RuntimeProfileMembershipUpgradeQuoteInput { + let cycle_started_at_micros = beijing(2026, 6, 20, 9); + let cycle_resets_at_micros = beijing(2026, 7, 20, 9); + RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Plus, + current_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + target_plan: RuntimeProfileMembershipPlan::Pro, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + cycle_index: 3, + cycle_count: 9, + cycle_started_at_micros, + cycle_resets_at_micros, + expires_at_micros: beijing(2027, 3, 20, 9), + cycle_remaining_points: 615, + now_micros: cycle_started_at_micros + + (cycle_resets_at_micros - cycle_started_at_micros) / 2, + } + } + + #[test] + fn monthly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 月 → Pro 月:补付 ¥200、补点 2500、余额 615 + 2500 = 3115。 + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + cycle_index: 1, + cycle_count: 1, + cycle_remaining_points: 615, + ..base_input() + }) + .expect("月付升级应可报价"); + + assert_eq!(quote.amount_cents, 20_000); + assert_eq!(quote.granted_points_delta, 2_500); + assert_eq!(quote.monthly_balance_after, 3_115); + assert_eq!(quote.remaining_full_months, 0); + } + + #[test] + fn yearly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。 + let quote = + quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价"); + + assert_eq!(quote.amount_cents, 108_334); + assert_eq!(quote.granted_points_delta, 1_250); + assert_eq!(quote.monthly_balance_after, 1_865); + assert_eq!(quote.remaining_full_months, 6); + assert_eq!(quote.remaining_ratio_ppm, 500_000); + } + + #[test] + fn upgrade_keeps_expiry_reset_and_cycle_index() { + let input = base_input(); + let quote = quote_runtime_profile_membership_upgrade(input).expect("年付升级应可报价"); + assert_eq!(quote.expires_at_micros, input.expires_at_micros); + assert_eq!(quote.cycle_resets_at_micros, input.cycle_resets_at_micros); + assert_eq!(quote.cycle_index, input.cycle_index); + } + + #[test] + fn remaining_ratio_is_zero_at_or_after_reset() { + let input = base_input(); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + ), + 0 + ); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + DAY_MICROS + ), + 0 + ); + + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros + DAY_MICROS, + ..input + }) + .expect("年付升级应可报价"); + assert_eq!(quote.remaining_ratio_ppm, 0); + assert_eq!(quote.granted_points_delta, 0); + // 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。 + assert_eq!(quote.amount_cents, 100_000); + } + + #[test] + fn non_member_must_purchase_instead_of_upgrading() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Normal, + target_plan: RuntimeProfileMembershipPlan::Starter, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotAMember) + ); + } + + #[test] + fn same_plan_and_downgrade_are_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + target_plan: RuntimeProfileMembershipPlan::Plus, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Pro, + target_plan: RuntimeProfileMembershipPlan::Plus, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + } + + #[test] + fn cycle_kind_change_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported) + ); + } + + #[test] + fn continuing_upgrade_compares_against_the_current_plan() { + // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34), + // 而不是按 Starter 直接跳 Pro(¥1408.34)。 + let starter_to_plus = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Starter, + target_plan: RuntimeProfileMembershipPlan::Plus, + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Plus 应可报价"); + let starter_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Starter, + target_plan: RuntimeProfileMembershipPlan::Pro, + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Pro 应可报价"); + let plus_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Plus → Pro 应可报价"); + + assert_eq!(starter_to_plus.amount_cents, 32_500); + assert_eq!(starter_to_pro.amount_cents, 140_834); + assert_eq!(plus_to_pro.amount_cents, 108_334); + assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents); + } + + #[test] + fn yearly_amount_rounds_up_to_cent_and_points_round_down() { + // 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整); + // 金额 200000 × (6 + 0.333333) / 12 = 105555.55 分 → 向上到 105556; + // 补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 + let started = beijing(2026, 6, 20, 9); + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_started_at_micros: started, + cycle_resets_at_micros: started + 3 * DAY_MICROS, + now_micros: started + 2 * DAY_MICROS, + ..base_input() + }) + .expect("年付升级应可报价"); + + assert_eq!(quote.remaining_ratio_ppm, 333_333); + assert_eq!(quote.amount_cents, 105_556); + assert_eq!(quote.granted_points_delta, 833); + } +}