diff --git a/backend/internal/service/subscription_calculate_progress_test.go b/backend/internal/service/subscription_calculate_progress_test.go index 8a3f8bee8e..9fc4b3040c 100644 --- a/backend/internal/service/subscription_calculate_progress_test.go +++ b/backend/internal/service/subscription_calculate_progress_test.go @@ -115,6 +115,32 @@ func TestCalculateProgress_WeeklyUsage(t *testing.T) { assert.Equal(t, 50.0, progress.Weekly.Percentage) } +// 周窗口初始化在开通日零点(legacy anchor)时,展示的 ResetsAt 应与 +// automaticWindowStartAt 的实际推进时间一致(StartsAt+7d),而非窗口起点+7d。 +func TestCalculateProgress_WeeklyResetsAt_LegacyMidnightAnchor(t *testing.T) { + svc := newTestSubscriptionService() + startsAt := time.Date(2026, 7, 31, 13, 37, 6, 0, time.FixedZone("UTC+8", 8*3600)) + weeklyStart := time.Date(startsAt.Year(), startsAt.Month(), startsAt.Day(), 0, 0, 0, 0, startsAt.Location()) + + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 30), + WeeklyUsageUSD: 2000.18, + WeeklyWindowStart: ptrTime(weeklyStart), + } + group := &Group{ + Name: "Pro", + WeeklyLimitUSD: ptrFloat64(2000.0), + } + + progress := svc.calculateProgress(sub, group) + + require.NotNil(t, progress.Weekly) + assert.True(t, startsAt.Add(7*24*time.Hour).Equal(progress.Weekly.ResetsAt), + "legacy 午夜锚点的周窗口 ResetsAt 应为 StartsAt+7d,与实际推进时间一致") +} + func TestCalculateProgress_MonthlyUsage(t *testing.T) { svc := newTestSubscriptionService() now := time.Now() diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 7d716b7a8e..51956dfd1a 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -1157,6 +1157,9 @@ func (s *SubscriptionService) calculateProgress(sub *UserSubscription, group *Gr if group.HasWeeklyLimit() && sub.WeeklyWindowStart != nil { limit := *group.WeeklyLimitUSD resetsAt := sub.WeeklyWindowStart.Add(7 * 24 * time.Hour) + if weeklyResetTime := sub.WeeklyResetTime(); weeklyResetTime != nil { + resetsAt = *weeklyResetTime + } progress.Weekly = &UsageWindowProgress{ LimitUSD: limit, UsedUSD: sub.WeeklyUsageUSD, @@ -1181,6 +1184,9 @@ func (s *SubscriptionService) calculateProgress(sub *UserSubscription, group *Gr if group.HasMonthlyLimit() && sub.MonthlyWindowStart != nil { limit := *group.MonthlyLimitUSD resetsAt := sub.MonthlyWindowStart.Add(30 * 24 * time.Hour) + if monthlyResetTime := sub.MonthlyResetTime(); monthlyResetTime != nil { + resetsAt = *monthlyResetTime + } progress.Monthly = &UsageWindowProgress{ LimitUSD: limit, UsedUSD: sub.MonthlyUsageUSD, diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index fb4d77f0f1..a7c45bd4e9 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -138,6 +138,21 @@ func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool { return ok } +// windowResetAnchor 返回周/月窗口实际推进所依据的锚点。 +// 早期订阅把首个窗口初始化在开通日零点;只有这个初始值是无歧义的,之后出现的 +// 零点锚点可能来自手动重置,必须保持权威。 +// 自动推进(automaticWindowStartAt)与对外展示的重置时间(WeeklyResetTime/ +// MonthlyResetTime)必须共用这一修正,否则仪表盘显示的重置时间会早于窗口实际 +// 滚动的时间。 +// 日窗口按日历日对齐(automaticDailyWindowStartAt),不走这里。 +func (s *UserSubscription) windowResetAnchor(previous time.Time) time.Time { + legacyAnchor := startOfDay(s.StartsAt) + if legacyAnchor.Before(s.StartsAt) && previous.Equal(legacyAnchor) { + return s.StartsAt + } + return previous +} + // automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。 // 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整 // 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。 @@ -146,14 +161,7 @@ func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period ti return time.Time{}, false } - anchor := *previous - // Older subscriptions initialized their first windows at midnight on their - // start date. Only that initial value is unambiguous; later midnight anchors - // may be manual resets and must remain authoritative. - legacyAnchor := startOfDay(s.StartsAt) - if legacyAnchor.Before(s.StartsAt) && anchor.Equal(legacyAnchor) { - anchor = s.StartsAt - } + anchor := s.windowResetAnchor(*previous) next := anchor.Add(period) if now.Before(next) || !next.Before(s.ExpiresAt) { return time.Time{}, false @@ -184,7 +192,7 @@ func (s *UserSubscription) WeeklyResetTime() *time.Time { if s.WeeklyWindowStart == nil { return nil } - t := s.WeeklyWindowStart.Add(7 * 24 * time.Hour) + t := s.windowResetAnchor(*s.WeeklyWindowStart).Add(7 * 24 * time.Hour) return &t } @@ -192,7 +200,7 @@ func (s *UserSubscription) MonthlyResetTime() *time.Time { if s.MonthlyWindowStart == nil { return nil } - t := s.MonthlyWindowStart.Add(30 * 24 * time.Hour) + t := s.windowResetAnchor(*s.MonthlyWindowStart).Add(30 * 24 * time.Hour) return &t } diff --git a/backend/internal/service/user_subscription_reset_time_test.go b/backend/internal/service/user_subscription_reset_time_test.go new file mode 100644 index 0000000000..59457d0158 --- /dev/null +++ b/backend/internal/service/user_subscription_reset_time_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 周窗口初始化在开通日零点(legacy anchor)时,automaticWindowStartAt 会把实际 +// 推进锚点修正为 StartsAt。展示层的 WeeklyResetTime 必须应用同一修正, +// 否则用户看到的重置时间会比实际重置时间早(差值 = StartsAt 的时分秒)。 +func TestWeeklyResetTime_LegacyMidnightAnchor_UsesStartsAt(t *testing.T) { + startsAt := time.Date(2026, 7, 31, 13, 37, 6, 0, time.FixedZone("UTC+8", 8*3600)) + windowStart := startOfDay(startsAt) + + sub := &UserSubscription{ + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 30), + WeeklyWindowStart: ptrTime(windowStart), + } + + got := sub.WeeklyResetTime() + require.NotNil(t, got) + assert.True(t, startsAt.Add(7*24*time.Hour).Equal(*got), + "legacy 午夜锚点应按 StartsAt+7d 计算重置时间,而不是窗口起点+7d") +} + +// 非 legacy 锚点(手动重置或已推进过的窗口)保持权威,不做修正。 +func TestWeeklyResetTime_RegularAnchor_UsesWindowStart(t *testing.T) { + startsAt := time.Date(2026, 7, 31, 13, 37, 6, 0, time.FixedZone("UTC+8", 8*3600)) + windowStart := startsAt.Add(7 * 24 * time.Hour) // 已推进过一轮,非开通日零点 + + sub := &UserSubscription{ + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 30), + WeeklyWindowStart: ptrTime(windowStart), + } + + got := sub.WeeklyResetTime() + require.NotNil(t, got) + assert.True(t, windowStart.Add(7*24*time.Hour).Equal(*got), + "非 legacy 锚点应按窗口起点+7d 计算重置时间") +} + +// 展示与执行一致性:WeeklyResetTime 前一秒窗口不应推进,到点后应推进。 +func TestWeeklyResetTime_MatchesAutomaticWindowStart(t *testing.T) { + startsAt := time.Date(2026, 7, 31, 13, 37, 6, 0, time.FixedZone("UTC+8", 8*3600)) + windowStart := startOfDay(startsAt) + + sub := &UserSubscription{ + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 30), + WeeklyWindowStart: ptrTime(windowStart), + } + + resetAt := *sub.WeeklyResetTime() + + _, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, resetAt.Add(-time.Second)) + assert.False(t, ok, "展示的重置时间之前窗口不应可推进") + + newStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, resetAt) + assert.True(t, ok, "到达展示的重置时间后窗口应可推进") + assert.True(t, resetAt.Equal(newStart), "推进后的新窗口起点应等于展示的重置时间") +} + +// 月窗口与周窗口同属期限对齐滚动窗口,应用同一 legacy 锚点修正。 +// 日窗口按日历日对齐(见 automaticDailyWindowStartAt),不走此修正,故不在此覆盖。 +func TestMonthlyResetTime_LegacyMidnightAnchor_UsesStartsAt(t *testing.T) { + startsAt := time.Date(2026, 7, 31, 13, 37, 6, 0, time.FixedZone("UTC+8", 8*3600)) + windowStart := startOfDay(startsAt) + + sub := &UserSubscription{ + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 60), + MonthlyWindowStart: ptrTime(windowStart), + } + + monthly := sub.MonthlyResetTime() + require.NotNil(t, monthly) + assert.True(t, startsAt.Add(30*24*time.Hour).Equal(*monthly), + "legacy 午夜锚点应按 StartsAt+30d 计算重置时间,而不是窗口起点+30d") +} + +// 日窗口不受本修正影响:DailyResetTime 保持 #5380 的日历日对齐语义。 +// 基准取配置时区的 0 点(与 subscription_daily_midnight_reset_test.go 同构), +// 保证断言在任意本地时区下都成立。 +func TestDailyResetTime_UnaffectedByWindowResetAnchor(t *testing.T) { + base := timezone.StartOfDay(time.Date(2026, 7, 31, 12, 0, 0, 0, timezone.Location())) + startsAt := base.Add(13*time.Hour + 37*time.Minute + 6*time.Second) + windowStart := base // legacy 锚点:开通日 0 点 + + sub := &UserSubscription{ + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 30), + DailyWindowStart: ptrTime(windowStart), + } + + got := sub.DailyResetTime() + require.NotNil(t, got) + assert.True(t, got.Equal(base.AddDate(0, 0, 1)), + "日窗口应保持日历日对齐(窗口起点所在日的次日 0 点)") + assert.False(t, got.Equal(startsAt.Add(24*time.Hour)), + "日窗口不应被 windowResetAnchor 修正成 StartsAt+24h(那是周/月的语义)") +}