fix(subscription): 周/月窗口展示的重置时间与实际推进锚点对齐

订阅首窗初始化在开通日零点时,automaticWindowStartAt 会把实际推进锚点修正为
StartsAt,但展示层(calculateProgress / WeeklyResetTime / MonthlyResetTime)
仍按窗口起点+周期计算,导致仪表盘显示的重置时间早于实际重置时间(差值 =
StartsAt 的时分秒)。用户在显示的重置时间过后请求仍会收到 429
WEEKLY_LIMIT_EXCEEDED。

抽出 windowResetAnchor 供推进与展示共用,保证两者恒一致。非 legacy 锚点
(手动重置或已推进过的窗口)保持权威,不做修正。

日窗口不在本 PR 范围内:#5380 已把日额度改为按日历日对齐(DailyResetTime =
窗口起点所在日的次日 0 点),与周/月的期限对齐滚动窗口语义不同,不共用本修正。
附一个守护用例钉死这条边界。

Fixes #5348

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Chou
2026-08-28 21:46:44 +08:00
co-authored by Claude Opus 5
parent 7b693ae429
commit 32ad1dcdce
4 changed files with 157 additions and 10 deletions
@@ -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()
@@ -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,
+18 -10
View File
@@ -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
}
@@ -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(那是周/月的语义)")
}