fix(会员账期): 极端锚点推进月份饱和而非 panic

- advance_beijing_months_clamped 去掉 beijing_local_micros 后的 expect,改为安全年份区间 + unwrap_or 按方向饱和(评审第 16 条)
- 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时返回 i64::MAX / i64::MIN,避免历法乘法与 checked_* 溢出 panic
- 新增 advance_saturates_instead_of_panicking_on_extreme_anchors 覆盖 i64::MAX / i64::MIN / u32::MAX
This commit is contained in:
2026-10-04 19:47:38 +08:00
parent cf2a8820d9
commit b97d559782
@@ -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);