fix(calendar): 农历提醒按本地公历日推进,修正凌晨跨 UTC 日期错一天
# 现象
全量服务端测试稳定失败:
--- FAIL: TestStaleLunarRecurringDoesNotFlood
calendar_test.go:869: 农历日从 21 变成 20
不是随机失败,也不是测试写错 —— 是产品逻辑的真实缺陷。
# 根因
SQLite 的 DSN 带 `_timezone=UTC`(为了让 `expires_at > NOW()` 这类字符串比较
同一时间轴,见 db.sqliteDSN 的注释)。因此从库里 Scan 出来的 `event_time` 是
UTC 时刻的表示。
对公历重复规则,这无关紧要 —— `AddDate` 操作的是同一时刻的另一种表示。
但**农历换算直接读取 Year/Month/Day**:
本地 2025-09-12 07:00 (+0800) → 存库 → 读出 UTC 2025-09-11 23:00
农历(本地) = 七月廿一 → 农历(UTC 字段) = 七月二十 ← 少一天
后果:在本地时间 0:00–8:00(+0800)创建的农历提醒,之后每次推进都按前一天
计算,日期永久偏一天;而且只有等到下一次该提醒时才暴露,没有任何报错。
# 修法
在 `AdvanceRecurrence` 里,仅对两条农历规则把 event_time 转回 `time.Local`
再交给 `NextOccurrence`。
只转农历规则而不是无条件转:公历规则不需要,且 UTC 与 Local 表示同一时刻,
`AddDate` 在两者上结果相同 —— 无条件转会掩盖「DSN 时间是 UTC」这个事实,
让后来者更难判断该在哪一层做时区处理。
# 测试
新增 `TestAdvanceRecurrenceLunarUsesLocalCalendarDay`,用**固定日期**
(2025-09-12 07:00 本地)而不是 `time.Now()`,因此任何时刻跑都稳定;
并且它先断言测试前提成立:
- 库里读回的时刻确实与输入跨了不同公历日
- 直接按 UTC 字段做农历换算确实会得到不同的农历日
前提不成立就直接 Fatal —— 否则这个用例可能在某个时区/时段下变成永远通过的
空壳(那正是它要防的那类假绿)。
# 验证
- 新用例与原有的两条农历用例 ×10 连跑全绿(`-count=10`)
- `go vet ./...` 干净;`go test ./... -count=1` 全量通过
- 修复前该用例 5/5 失败,修复后 10/10 通过
This commit is contained in:
@ -269,6 +269,17 @@ func AdvanceRecurrence(ctx context.Context, eventID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 农历按用户看到的**本地公历日**换算,不按数据库返回值的 Location 换算。
|
||||
//
|
||||
// SQLite DSN 用 _timezone=UTC 保证时间比较统一,因此从库里 Scan 出来的
|
||||
// eventTime 是 UTC。对普通重复规则这只是同一时刻的另一种表示;但农历换算
|
||||
// 会直接读取 Year/Month/Day:本地 09-12 07:00 入库后是 UTC 09-11 23:00,
|
||||
// 若不转回本地,农历日会从廿一变成二十。凌晨 0–8 点创建的农历提醒都会
|
||||
// 永久偏一天,而且只有等到下次提醒时才暴露。
|
||||
if recurrence == models.RecurLunarMonthly || recurrence == models.RecurLunarYearly {
|
||||
eventTime = eventTime.In(time.Local)
|
||||
}
|
||||
|
||||
// **一路推到未来**,不是只推一步。
|
||||
//
|
||||
// 只推一步的后果(实测):一条 100 天前设的每日事件,每轮扫描都判定
|
||||
|
||||
@ -846,6 +846,46 @@ func TestStaleRecurringEventDoesNotFlood(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite 把时间按 UTC 读回,但农历必须按用户的本地公历日计算。
|
||||
// 这个固定用例钉住凌晨跨 UTC 日期边界:本地 09-12 07:00 入库后会变成
|
||||
// UTC 09-11 23:00;若 AdvanceRecurrence 不先转回 Local,农历日会少一天。
|
||||
func TestAdvanceRecurrenceLunarUsesLocalCalendarDay(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
localTime := time.Date(2025, 9, 12, 7, 0, 0, 0, time.Local)
|
||||
originalLunar := lunar.FromSolar(localTime)
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "凌晨创建的农历提醒",
|
||||
EventTime: localTime,
|
||||
Recurrence: models.RecurLunarMonthly,
|
||||
})
|
||||
|
||||
// 先证明测试真的跨了日期边界;否则它无法捕获这个 bug。
|
||||
var scanned time.Time
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT event_time FROM calendar_events WHERE event_id = ?`, e.EventID).Scan(&scanned); err != nil {
|
||||
t.Fatalf("读数据库时间: %v", err)
|
||||
}
|
||||
if scanned.Day() == localTime.Day() {
|
||||
t.Fatalf("测试前提不成立:数据库时间 %v 与本地时间 %v 没有跨日", scanned, localTime)
|
||||
}
|
||||
if got := lunar.FromSolar(scanned).Day; got == originalLunar.Day {
|
||||
t.Fatalf("测试前提不成立:直接按 UTC 字段换算没有产生日偏移(仍为 %d)", got)
|
||||
}
|
||||
|
||||
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
after, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if got := lunar.FromSolar(after.EventTime.In(time.Local)).Day; got != originalLunar.Day {
|
||||
t.Errorf("农历日从 %d 变成 %d(推进后 %v)", originalLunar.Day, got, after.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 农历规则的过期事件同样不能刷屏。
|
||||
func TestStaleLunarRecurringDoesNotFlood(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
@ -864,8 +904,8 @@ func TestStaleLunarRecurringDoesNotFlood(t *testing.T) {
|
||||
if !after.EventTime.After(time.Now()) {
|
||||
t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime)
|
||||
}
|
||||
// 农历日必须保持
|
||||
if d := lunar.FromSolar(after.EventTime); d.Day != lunar.FromSolar(e.EventTime).Day {
|
||||
// EventTime 从数据库读回是 UTC;农历必须按用户看到的本地公历日比较。
|
||||
if d := lunar.FromSolar(after.EventTime.In(time.Local)); d.Day != lunar.FromSolar(e.EventTime).Day {
|
||||
t.Errorf("农历日从 %d 变成 %d", lunar.FromSolar(e.EventTime).Day, d.Day)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user