From 4421524b3a0019e0528d8cec8567a3f6b70282ab Mon Sep 17 00:00:00 2001 From: ShawnKung Date: Tue, 8 Sep 2026 11:47:10 +0800 Subject: [PATCH] fix(align): normalize preview times to local timezone --- internal/align/align_test.go | 24 ++++++++++++++++++++++++ internal/align/scheduler.go | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/align/align_test.go b/internal/align/align_test.go index 2599c0e..40b99de 100644 --- a/internal/align/align_test.go +++ b/internal/align/align_test.go @@ -143,6 +143,30 @@ func TestUpcomingUsesRunningWindow(t *testing.T) { } } +func TestUpcomingNormalizesPersistedUTCLastPing(t *testing.T) { + originalLocal := time.Local + time.Local = time.FixedZone("UTC+8", 8*60*60) + t.Cleanup(func() { time.Local = originalLocal }) + + planner := mustPlanner(t, []string{"0 0 * * *"}, 300) + now := time.Date(2026, 9, 8, 11, 40, 0, 0, time.Local) + lastPingLocal := time.Date(2026, 9, 8, 10, 41, 0, 0, time.Local) + lastPingUTC := lastPingLocal.UTC() + + plans := planner.Upcoming(now, &lastPingUTC, 3) + if len(plans) != 3 { + t.Fatalf("want 3 plans, got %d", len(plans)) + } + wantPing := time.Date(2026, 9, 8, 19, 0, 0, 0, time.Local) + wantRefresh := time.Date(2026, 9, 9, 0, 0, 0, 0, time.Local) + if !plans[0].PingAt.Equal(wantPing) || !plans[0].RefreshAt.Equal(wantRefresh) { + t.Fatalf("plan[0] = %+v, want ping %s refresh %s", plans[0], wantPing, wantRefresh) + } + if plans[0].PingAt.Location() != time.Local || plans[0].RefreshAt.Location() != time.Local { + t.Fatalf("plan should use local timezone, got ping=%s refresh=%s", plans[0].PingAt.Location(), plans[0].RefreshAt.Location()) + } +} + func TestParseCronRejectsInvalid(t *testing.T) { if _, err := ParseCron("not a cron"); err == nil { t.Fatal("expected error for invalid cron") diff --git a/internal/align/scheduler.go b/internal/align/scheduler.go index 6107e12..e7290f5 100644 --- a/internal/align/scheduler.go +++ b/internal/align/scheduler.go @@ -82,6 +82,7 @@ func (p *Planner) HasTargets() bool { // 仅依赖当前时间与 cron 目标:延后中的每一分钟都会重新评估,随着时间逼近锚点, // 待等时长单调减小,绝不会超过预算。每个目标使用它自己的延时预算。 func (p *Planner) Decide(now time.Time) Decision { + now = now.In(time.Local) if len(p.targets) == 0 { return Decision{Action: ActionPing, Now: now, Reason: "未配置对齐目标,满额即 ping"} } @@ -128,7 +129,7 @@ func (p *Planner) Upcoming(now time.Time, lastPing *time.Time, n int) []PingPlan // 模拟假设每次 ping 后窗口在 window 后再次满额(工具自身链式语义),不建模真实使用。 func (p *Planner) NextPingTimes(start time.Time, n int) []PingPlan { plans := make([]PingPlan, 0, n) - full := start + full := start.In(time.Local) for i := 0; i < n; i++ { decision := p.Decide(full) pingAt := full