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:
2026-09-12 08:01:59 +08:00
parent dacf6c0e1f
commit dc7bf57ceb
2 changed files with 53 additions and 2 deletions

View File

@ -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
// 若不转回本地,农历日会从廿一变成二十。凌晨 08 点创建的农历提醒都会
// 永久偏一天,而且只有等到下次提醒时才暴露。
if recurrence == models.RecurLunarMonthly || recurrence == models.RecurLunarYearly {
eventTime = eventTime.In(time.Local)
}
// **一路推到未来**,不是只推一步。
//
// 只推一步的后果(实测):一条 100 天前设的每日事件,每轮扫描都判定

View File

@ -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)
}
}