领域层:新增会员自然月账期与并发上限纯函数
- 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 <junie@jetbrains.com>
This commit is contained in:
@@ -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 += 补点`、
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 会员并发上限的判定(纯函数)。
|
||||
//!
|
||||
//! 并发上限用 `u32` 落列与对外暴露,`>= MEMBERSHIP_UNLIMITED_CONCURRENCY`(128)视为「不设上限」。
|
||||
//! 刻意不用 `Option<u32>`: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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user