From 969ee329d7d431ddfef40aa0833e7636cea4f68e 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:09:04 +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=87=AA=E7=84=B6=E6=9C=88=E8=B4=A6?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membership_cycle.rs:北京时间锚点解析、自然月推进与月末夹取(1/31 → 2/28 → 3/31,从原始锚点重算)、期窗口、到期日、本期剩余微秒 - membership_limits.rs:并发上限哨兵 MEMBERSHIP_UNLIMITED_CONCURRENCY = 128 与 is_unlimited_concurrency - lib.rs 注册并导出两个模块 - 单测覆盖闰年、跨年、窗口连续性、零索引归一、哨兵判定 - 设计文档同步:membership_cycle_window 去掉无用的 cycle_kind 形参并列出账期推进基元 Co-authored-by: Junie --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 9 +- server-rs/crates/module-runtime/src/lib.rs | 4 + .../module-runtime/src/membership_cycle.rs | 185 ++++++++++++++++++ .../module-runtime/src/membership_limits.rs | 31 +++ 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 server-rs/crates/module-runtime/src/membership_cycle.rs create mode 100644 server-rs/crates/module-runtime/src/membership_limits.rs diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index a42613790..d1a41128d 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -252,8 +252,13 @@ pub struct RuntimeProfileMembershipUpgradeQuote { 纯函数(无 IO,可单测): -- `membership_cycle_window(anchor_micros, index, cycle_kind) -> (start, reset)`: - 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31)。 +- `membership_cycle_window(anchor_micros, index) -> (start, reset)`: + 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31); + 窗口本身与周期类型无关,总期数由 `cycle_kind.cycle_count()` 给出,`expires_at` 取第 `cycle_count` 期的边界。 +- 账期推进基元(复用 `civil_calendar` 的公历换算,不读系统时区): + `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`。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ac2ad0863..966ba291b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -4,6 +4,10 @@ pub mod civil_calendar; pub use civil_calendar::*; mod llm_billing; pub use llm_billing::*; +mod membership_cycle; +pub use membership_cycle::*; +mod membership_limits; +pub use membership_limits::*; mod application; pub use agc_models::*; mod commands; diff --git a/server-rs/crates/module-runtime/src/membership_cycle.rs b/server-rs/crates/module-runtime/src/membership_cycle.rs new file mode 100644 index 000000000..43e76b5e4 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_cycle.rs @@ -0,0 +1,185 @@ +//! 会员账期的自然月推进(纯函数,无 IO)。 +//! +//! 账期锚点 = 北京时间(UTC+8)的「开通日时刻」。第 n 期一律从**原始锚点**重算, +//! 而不是从上一期递推,所以 1/31 开通得到的序列是 1/31 → 2/28 → 3/31,不会漂移成 3/28。 +//! 单期长度固定为一个自然月,日期按目标月实际长度夹取(`min(开通日, 目标月天数)`)。 +//! 中国不使用夏令时,因此这里用固定 UTC+8 偏移而不是时区库。 + +use crate::civil_calendar::{civil_from_days, days_from_civil, days_in_month}; +use crate::{PROFILE_RUNTIME_DAY_MICROS, PROFILE_TASK_BEIJING_OFFSET_MICROS}; + +/// 北京时间(UTC+8)某个时刻的本地「年月日 + 当日已过微秒」。 +/// +/// 与 [`runtime_profile_beijing_day_key`](crate::runtime_profile_beijing_day_key) 使用同一套 +/// 固定偏移与业务日边界,保证「同一个业务日」在所有余额投影里口径一致。 +pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { + let local_micros = micros.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS); + let day_key = local_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS); + let time_of_day_micros = local_micros.rem_euclid(PROFILE_RUNTIME_DAY_MICROS); + let (year, month, day) = civil_from_days(day_key); + (year, month, day, time_of_day_micros) +} + +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期不存在则返回原值。 +pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> i64 { + if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return 0; + } + days_from_civil(year, month, day) + .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) + .saturating_add(time_of_day_micros) + .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS) +} + +/// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 +/// +/// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。 +pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { + let (year, month, day, time_of_day_micros) = beijing_local_date_parts(anchor_micros); + let total_months = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(months); + let target_year = total_months.div_euclid(12) as i32; + let target_month = (total_months.rem_euclid(12) + 1) as u8; + let target_day = day.min(days_in_month(target_year, target_month)); + beijing_local_micros(target_year, target_month, target_day, time_of_day_micros) +} + +/// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 +/// +/// `index = 1` 是开通当期;`cycle_resets_at` 即下一期开始的时刻。窗口长度与周期类型无关, +/// 年付只是把同样的月度窗口重复 12 次;期数总数由 +/// [`RuntimeProfileMembershipCycleKind::cycle_count`](crate::RuntimeProfileMembershipCycleKind::cycle_count) +/// 给出。 +pub fn membership_cycle_window(anchor_micros: i64, index: u32) -> (i64, i64) { + let index = index.max(1); + let start = if index == 1 { + anchor_micros + } else { + advance_beijing_months_clamped(anchor_micros, index - 1) + }; + let resets_at = advance_beijing_months_clamped(anchor_micros, index); + (start, resets_at) +} + +/// 本次购买的到期时刻:从锚点推进「总期数」个自然月(月付 1 期、年付 12 期)。 +pub fn membership_expires_at( + anchor_micros: i64, + cycle_kind: crate::RuntimeProfileMembershipCycleKind, +) -> i64 { + advance_beijing_months_clamped(anchor_micros, cycle_kind.cycle_count()) +} + +/// 从当前时刻到本期刷新时刻的剩余微秒;已过期返回 0。 +pub fn membership_cycle_remaining_micros(cycle_resets_at_micros: i64, now_micros: i64) -> i64 { + cycle_resets_at_micros.saturating_sub(now_micros).max(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RuntimeProfileMembershipCycleKind; + + /// 北京时间某时刻 → UTC 微秒。 + fn beijing(year: i32, month: u8, day: u8, hour: i64, minute: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000 + minute * 60_000_000) + } + + #[test] + fn beijing_local_parts_round_trip() { + let micros = beijing(2026, 10, 2, 9, 20); + assert_eq!( + beijing_local_date_parts(micros), + (2026, 10, 2, 9 * 3_600_000_000 + 20 * 60_000_000) + ); + } + + #[test] + fn advance_months_clamps_to_month_end() { + // 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。 + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!(advance_beijing_months_clamped(anchor, 0), anchor); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2026, 3, 31, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 12), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn advance_months_handles_leap_year() { + let anchor = beijing(2024, 1, 31, 12, 0); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2024, 2, 29, 12, 0) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2024, 3, 31, 12, 0) + ); + } + + #[test] + fn advance_months_crosses_year_boundary() { + let anchor = beijing(2026, 12, 31, 23, 30); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2027, 1, 31, 23, 30) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 3), + beijing(2027, 3, 31, 23, 30) + ); + } + + #[test] + fn cycle_window_is_contiguous_from_the_anchor() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_cycle_window(anchor, 1), + (anchor, beijing(2026, 2, 28, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 2), + (beijing(2026, 2, 28, 9, 20), beijing(2026, 3, 31, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 3), + (beijing(2026, 3, 31, 9, 20), beijing(2026, 4, 30, 9, 20)) + ); + } + + #[test] + fn cycle_window_treats_zero_index_as_first_period() { + let anchor = beijing(2026, 5, 20, 8, 0); + assert_eq!( + membership_cycle_window(anchor, 0), + membership_cycle_window(anchor, 1) + ); + } + + #[test] + fn expires_at_uses_total_period_count() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Monthly), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Yearly), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn remaining_micros_never_goes_negative() { + assert_eq!(membership_cycle_remaining_micros(2_000, 1_000), 1_000); + assert_eq!(membership_cycle_remaining_micros(1_000, 1_000), 0); + assert_eq!(membership_cycle_remaining_micros(1_000, 2_000), 0); + } +} diff --git a/server-rs/crates/module-runtime/src/membership_limits.rs b/server-rs/crates/module-runtime/src/membership_limits.rs new file mode 100644 index 000000000..9f22a6ddd --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_limits.rs @@ -0,0 +1,31 @@ +//! 会员并发上限的判定(纯函数)。 +//! +//! 并发上限用 `u32` 落列与对外暴露,`>= MEMBERSHIP_UNLIMITED_CONCURRENCY`(128)视为「不设上限」。 +//! 刻意不用 `Option`:SpacetimeDB 表列与 DTO 都少一层可空分支,哨兵值只在领域层解释一次。 + +/// 并发上限哨兵值:等于或超过它即视为「不设上限」(`Max` 档)。 +pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128; + +/// 该并发上限是否表示「不设上限」。 +pub fn is_unlimited_concurrency(limit: u32) -> bool { + limit >= MEMBERSHIP_UNLIMITED_CONCURRENCY +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_limits_are_not_unlimited() { + for limit in [1, 3, 5, 127] { + assert!(!is_unlimited_concurrency(limit)); + } + } + + #[test] + fn sentinel_and_above_are_unlimited() { + for limit in [MEMBERSHIP_UNLIMITED_CONCURRENCY, 129, u32::MAX] { + assert!(is_unlimited_concurrency(limit)); + } + } +}