fix(会员账期): 非法北京本地时间改用 None 表达

- beijing_local_micros 返回 Option<i64>,不再用 0 兼作非法哨兵,合法 epoch 0 可被区分。
- advance_beijing_months_clamped 对夹取后的合法输入 expect,相关测试助手同步更新。
- 补 beijing_local_micros 区分合法 epoch 与非法输入的用例。
This commit is contained in:
2026-10-03 19:26:36 +08:00
parent 888a94ac86
commit 3db258d927
3 changed files with 33 additions and 13 deletions
@@ -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<i64> {
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,每期都从原始锚点重算。
@@ -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(
@@ -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(),