diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 2b29f37e6..c0ba36070 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -43,14 +43,37 @@ pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i /// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 /// /// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。 +/// +/// 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`: +/// 该输入在物理上不可表示,饱和既保留「更晚 / 更早」的方向,又避免历法乘法与 +/// [`beijing_local_micros`] 的 `checked_*` 溢出让这个纯函数 panic。 pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { + // 中文注释:`beijing_local_date_parts` 对极端 micros 会饱和后再反解,年份可能已被 i32 截断; + // 这里先按 i64 算目标年份,只在安全区间内才落回 i32 历法换算。 + const MIN_SAFE_YEAR: i64 = -300_000; + const MAX_SAFE_YEAR: i64 = 300_000; + 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_year = total_months.div_euclid(12); let target_month = (total_months.rem_euclid(12) + 1) as u8; + if target_year < MIN_SAFE_YEAR { + return i64::MIN; + } + if target_year > MAX_SAFE_YEAR { + return i64::MAX; + } + let target_year = target_year as i32; 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("夹取后的北京本地年月日与当日时刻必定合法") + beijing_local_micros(target_year, target_month, target_day, time_of_day_micros).unwrap_or_else( + || { + if anchor_micros < 0 { + i64::MIN + } else { + i64::MAX + } + }, + ) } /// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 @@ -98,6 +121,14 @@ mod tests { .expect("测试用的北京本地时间应当合法") } + #[test] + fn advance_saturates_instead_of_panicking_on_extreme_anchors() { + // 中文注释:极端锚点已无法精确表示目标月份,本用例只要求「不 panic 且方向不反转」。 + assert_eq!(advance_beijing_months_clamped(i64::MAX, 1), i64::MAX); + assert!(advance_beijing_months_clamped(i64::MIN, 1) < 0); + assert_eq!(advance_beijing_months_clamped(i64::MAX, u32::MAX), i64::MAX); + } + #[test] fn beijing_local_parts_round_trip() { let micros = beijing(2026, 10, 2, 9, 20);