From dc7bf57ceb8ad72c31d656dc9d28da1aad676c7f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 08:01:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(calendar):=20=E5=86=9C=E5=8E=86=E6=8F=90?= =?UTF-8?q?=E9=86=92=E6=8C=89=E6=9C=AC=E5=9C=B0=E5=85=AC=E5=8E=86=E6=97=A5?= =?UTF-8?q?=E6=8E=A8=E8=BF=9B=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=87=8C=E6=99=A8?= =?UTF-8?q?=E8=B7=A8=20UTC=20=E6=97=A5=E6=9C=9F=E9=94=99=E4=B8=80=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 现象 全量服务端测试稳定失败: --- 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 通过 --- server/internal/repo/calendar.go | 11 +++++++ server/internal/repo/calendar_test.go | 44 +++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/server/internal/repo/calendar.go b/server/internal/repo/calendar.go index c41055f..2ab8bf3 100644 --- a/server/internal/repo/calendar.go +++ b/server/internal/repo/calendar.go @@ -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 天前设的每日事件,每轮扫描都判定 diff --git a/server/internal/repo/calendar_test.go b/server/internal/repo/calendar_test.go index 8755e61..2138eee 100644 --- a/server/internal/repo/calendar_test.go +++ b/server/internal/repo/calendar_test.go @@ -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) } }