diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 700a6cd95..6af6f93eb 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -20,19 +20,24 @@ pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { (year, month, day, time_of_day_micros) } -/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期或当日时刻越界时返回 0。 -pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> i64 { +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期或当日时刻越界时返回 `None`。 +/// +/// 返回 `Option` 而不是用 `0` 兼作非法哨兵:北京 1970-01-01 08:00 本身就是合法的 `0`, +/// 调用方必须能区分「非法日期」与「合法 epoch」。 +pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> Option { if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) || !(0..PROFILE_RUNTIME_DAY_MICROS).contains(&time_of_day_micros) { - return 0; + return None; } - 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) + Some( + 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` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 @@ -45,6 +50,7 @@ pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { 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) + .expect("夹取后的北京本地年月日与当日时刻必定合法") } /// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 @@ -85,6 +91,7 @@ mod tests { /// 北京时间某时刻 → 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) + .expect("测试用的北京本地时间应当合法") } #[test] @@ -96,6 +103,18 @@ mod tests { ); } + #[test] + fn beijing_local_micros_distinguishes_epoch_from_invalid_input() { + // 中文注释:北京 1970-01-01 08:00 就是合法的 UTC 0,不能再被 0 哨兵吞掉。 + assert_eq!(beijing(1970, 1, 1, 8, 0), 0); + assert_eq!(beijing_local_micros(2026, 13, 1, 0), None); + assert_eq!(beijing_local_micros(2026, 2, 30, 0), None); + assert_eq!( + beijing_local_micros(2026, 1, 1, PROFILE_RUNTIME_DAY_MICROS), + None + ); + } + #[test] fn advance_months_clamps_to_month_end() { // 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。 diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 05f9690e6..051365bc5 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -259,6 +259,7 @@ mod tests { fn beijing(year: i32, month: u8, day: u8, hour: i64) -> i64 { beijing_local_micros(year, month, day, hour * 3_600_000_000) + .expect("测试用的北京本地时间应当合法") } fn snapshot( 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 087f1d7c6..9309abed6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -4142,26 +4142,26 @@ mod tests { #[test] fn membership_plan_window_recomputes_each_period_from_the_original_anchor() { // 中文注释:1/31 开通 → 第 1 期到 2/28,第 2 期到 3/31;每期都从原始锚点重算。 - let anchor = beijing_local_micros(2026, 1, 31, 0); + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); assert_eq!( membership_cycle_window(anchor, 1), ( - beijing_local_micros(2026, 1, 31, 0), - beijing_local_micros(2026, 2, 28, 0) + beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"), + beijing_local_micros(2026, 2, 28, 0).expect("测试用的北京本地时间应当合法") ) ); assert_eq!( membership_cycle_window(anchor, 2), ( - beijing_local_micros(2026, 2, 28, 0), - beijing_local_micros(2026, 3, 31, 0) + beijing_local_micros(2026, 2, 28, 0).expect("测试用的北京本地时间应当合法"), + beijing_local_micros(2026, 3, 31, 0).expect("测试用的北京本地时间应当合法") ) ); } #[test] fn membership_cycle_advance_only_writes_when_a_new_period_starts() { - let anchor = beijing_local_micros(2026, 1, 31, 0); + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); let day = PROFILE_RUNTIME_DAY_MICROS; let row = || ProfileMembership { user_id: "user-1".to_string(),