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