fix(subscription): restore midnight daily quota reset

日额度窗口恢复日历日语义(配置时区每天 0 点刷新),修复 v0.1.170 引入的回归:
- 手动重置配额后日窗口锚点漂移到重置时刻,此后 0 点不再刷新
- 新建/续费订阅日窗口按购买或首用时刻滚动,而非 0 点刷新

日窗口三处锚点(激活/手动重置/续期)统一写入当天 0 点,自动重置改为跨日历日
边界触发;存量非 0 点锚点在下一个 0 点自愈,无需数据库迁移。周/月窗口保持
期限对齐滚动语义(含到期约束)不变,不回归 issue #5051 的月额度翻倍问题。
日卡一次性额度豁免逻辑保留。
This commit is contained in:
shaw
2026-08-07 17:42:51 +08:00
parent 32e4de7942
commit 99b357083e
14 changed files with 280 additions and 70 deletions
@@ -368,7 +368,7 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
}
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
client := clientFromContext(ctx, r.client)
n, err := client.UserSubscription.Update().
Where(
@@ -377,24 +377,24 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int
usersubscription.WeeklyWindowStartIsNil(),
usersubscription.MonthlyWindowStartIsNil(),
).
SetDailyWindowStart(start).
SetWeeklyWindowStart(start).
SetMonthlyWindowStart(start).
SetDailyWindowStart(dailyStart).
SetWeeklyWindowStart(periodicStart).
SetMonthlyWindowStart(periodicStart).
Save(ctx)
return r.translateConditionalWindowReset(ctx, client, id, n, err)
}
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
client := clientFromContext(ctx, r.client)
update := client.UserSubscription.UpdateOneID(id)
if resetDaily {
update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart)
update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart)
}
if resetWeekly {
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart)
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart)
}
if resetMonthly {
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart)
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart)
}
_, err := update.Save(ctx)
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
@@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
group := s.mustCreateGroup("g-activate")
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt)
err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt)
s.Require().NoError(err, "ActivateWindows")
got, err := s.repo.GetByID(s.ctx, sub.ID)
@@ -460,7 +461,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
s.Require().NotNil(got.DailyWindowStart)
s.Require().NotNil(got.WeeklyWindowStart)
s.Require().NotNil(got.MonthlyWindowStart)
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond)
s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond)
}
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
@@ -469,15 +472,16 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreserves
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
manualResetAt := activatedAt.Add(2 * time.Hour)
manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt))
// Simulate a concurrent request carrying the original unactivated snapshot.
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour)))
got, err := s.repo.GetByID(s.ctx, sub.ID)
s.Require().NoError(err)
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
}
@@ -535,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma
newWindowStart := oldWindowStart.Add(24 * time.Hour)
s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart))
s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart))
got, err := s.repo.GetByID(s.ctx, sub.ID)
s.Require().NoError(err)
@@ -770,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba
s.Require().Equal(active.ID, got.ID, "expected active subscription")
activateAt := time.Now().Add(-25 * time.Hour)
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows")
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows")
s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage")
after, err := s.repo.GetByID(s.ctx, active.ID)
+2 -2
View File
@@ -2247,10 +2247,10 @@ func (stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID
func (stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error {
@@ -82,7 +82,7 @@ type fakeGoogleSubscriptionRepo struct {
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
activateWindow func(ctx context.Context, id int64, start time.Time) error
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
resetDaily func(ctx context.Context, id int64, start time.Time) error
resetWeekly func(ctx context.Context, id int64, start time.Time) error
resetMonthly func(ctx context.Context, id int64, start time.Time) error
@@ -231,13 +231,13 @@ func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscripti
func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
if f.activateWindow != nil {
return f.activateWindow(ctx, id, start)
return f.activateWindow(ctx, id, dailyStart, periodicStart)
}
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
@@ -886,7 +886,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
@@ -119,7 +119,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error {
sub.DailyWindowStart = &start
sub.DailyUsageUSD = 0
@@ -252,7 +252,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
@@ -1631,7 +1631,7 @@ type stubUserSubscriptionRepo struct {
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
activateWindow func(ctx context.Context, id int64, start time.Time) error
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
resetDaily func(ctx context.Context, id int64, start time.Time) error
resetWeekly func(ctx context.Context, id int64, start time.Time) error
resetMonthly func(ctx context.Context, id int64, start time.Time) error
@@ -1753,14 +1753,14 @@ func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscription
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
if r.activateWindow != nil {
return r.activateWindow(ctx, id, start)
return r.activateWindow(ctx, id, dailyStart, periodicStart)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return errors.New("not implemented")
}
@@ -9,6 +9,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/dgraph-io/ristretto"
"github.com/stretchr/testify/require"
)
@@ -157,10 +158,10 @@ func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error {
func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error {
panic("unexpected UpdateNotes call")
}
func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time) error {
func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
panic("unexpected ActivateWindows call")
}
func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
panic("unexpected ResetUsageWindows call")
}
func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error {
@@ -424,7 +425,7 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
require.False(t, sub.StartsAt.Before(before))
require.False(t, sub.StartsAt.After(after))
require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
require.Equal(t, sub.StartsAt, *sub.DailyWindowStart)
require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
require.Zero(t, sub.DailyUsageUSD)
@@ -0,0 +1,162 @@
package service
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
// dailyMidnightResetRepo 记录 ResetDailyUsage 收到的新窗口起点。
type dailyMidnightResetRepo struct {
userSubRepoNoop
resetCalled bool
newWindowStart time.Time
}
func (r *dailyMidnightResetRepo) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, newWindowStart time.Time) error {
r.resetCalled = true
r.newWindowStart = newWindowStart
return nil
}
// midnightTestBase 返回一个固定日期在配置时区的 0 点,测试用时刻均从它推导,
// 保证断言在任意本地时区下都与生产逻辑同构。
func midnightTestBase() time.Time {
return timezone.StartOfDay(time.Date(2026, 8, 6, 12, 0, 0, 0, timezone.Location()))
}
func newMidnightTestSub(dailyWindowStart time.Time, base time.Time) *UserSubscription {
start := dailyWindowStart
return &UserSubscription{
ID: 1,
UserID: 10,
GroupID: 20,
StartsAt: base.AddDate(0, 0, -3),
ExpiresAt: base.AddDate(0, 0, 30),
DailyUsageUSD: 43.34,
DailyWindowStart: &start,
}
}
// 手动重置(或任何原因)留下的非 0 点锚点,跨 0 点后必须重置,
// 且新窗口起点是当天 0 点,而不是锚点+24h 的滚动时刻。
func TestCheckAndResetWindows_DailyResetsAtMidnightNotRollingAnchor(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute) // 昨日 16:49 手动重置
now := base.AddDate(0, 0, 1).Add(5 * time.Minute) // 次日 00:05
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(manualResetAt, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.True(t, repo.resetCalled, "跨 0 点后日窗口必须重置")
require.Equal(t, base.AddDate(0, 0, 1), repo.newWindowStart, "新窗口起点应为当天 0 点")
require.Zero(t, sub.DailyUsageUSD)
require.Equal(t, base.AddDate(0, 0, 1), *sub.DailyWindowStart)
}
// 同一日历日内(即使已过锚点+若干小时)不得重置:手动重置当天剩余时间继续累计用量。
func TestCheckAndResetWindows_DailyNoResetWithinSameCalendarDay(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
now := base.Add(23*time.Hour + 59*time.Minute)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(manualResetAt, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.False(t, repo.resetCalled, "同一日历日内不应重置日窗口")
require.Equal(t, 43.34, sub.DailyUsageUSD)
}
// 0.1.170/171 期间产生的滚动锚点(如多日前的 17:18)自愈:下一次维护即拉回今天 0 点。
func TestCheckAndResetWindows_LegacyRollingAnchorHealsToMidnight(t *testing.T) {
base := midnightTestBase()
staleAnchor := base.AddDate(0, 0, -3).Add(17*time.Hour + 18*time.Minute)
now := base.Add(10 * time.Hour)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(staleAnchor, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.True(t, repo.resetCalled)
require.Equal(t, base, repo.newWindowStart, "滚动锚点应被拉回今天 0 点,而非按 17:18 步进")
}
// 手动重置写入的 0 点锚点不会改变刷新节奏:次日 0 点照常需要重置。
func TestNeedsDailyReset_MidnightScheduleSurvivesManualReset(t *testing.T) {
base := midnightTestBase()
sub := newMidnightTestSub(base, base) // 手动重置后锚点=当天 0 点
require.False(t, sub.NeedsDailyResetAt(base.Add(23*time.Hour+54*time.Minute)), "当天 23:54 不应重置")
require.True(t, sub.NeedsDailyResetAt(base.AddDate(0, 0, 1).Add(time.Minute)), "次日 00:01 应重置")
}
// 多日订阅的日窗口展示的下次刷新时间固定为次日 0 点(截图中「X 小时后重置」的数据源)。
func TestDailyResetTime_NextMidnightForMultiDaySubscription(t *testing.T) {
base := midnightTestBase()
// 0 点锚点 → 次日 0 点
sub := newMidnightTestSub(base, base)
resetAt := sub.DailyResetTime()
require.NotNil(t, resetAt)
require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
// 非 0 点滚动锚点 → 仍是其所在日的次日 0 点,而非锚点+24h
rolling := newMidnightTestSub(base.Add(16*time.Hour+49*time.Minute), base)
resetAt = rolling.DailyResetTime()
require.NotNil(t, resetAt)
require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
}
// 截图场景:跨 0 点后(后台尚未收到请求推进窗口)列表展示即应清零日用量。
func TestNormalizeExpiredWindows_DailyUsageClearsAfterMidnight(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
now := base.AddDate(0, 0, 1).Add(time.Minute) // 次日 00:01
subs := []UserSubscription{*newMidnightTestSub(manualResetAt, base)}
normalizeExpiredWindowsAt(subs, now)
require.Zero(t, subs[0].DailyUsageUSD, "跨 0 点后展示的日用量应清零")
require.Nil(t, subs[0].DailyWindowStart)
}
// 日卡(一次性日额度)不受 0 点语义影响:跨 0 点不重置。
func TestCheckAndResetWindows_OneTimeDailyCardStillExemptFromMidnightReset(t *testing.T) {
base := midnightTestBase()
startsAt := base.Add(17 * time.Hour)
anchor := base
now := base.AddDate(0, 0, 1).Add(2 * time.Hour)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := &UserSubscription{
ID: 1,
UserID: 10,
GroupID: 20,
StartsAt: startsAt,
ExpiresAt: startsAt.AddDate(0, 0, 1),
DailyUsageUSD: 10,
DailyWindowStart: &anchor,
}
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.False(t, repo.resetCalled, "日卡为一次性配额,跨 0 点不应重置")
require.Equal(t, 10.0, sub.DailyUsageUSD)
}
@@ -87,11 +87,11 @@ func (r *subscriptionExpiryRepoStub) UpdateNotes(context.Context, int64, string)
return nil
}
func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time) error {
func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
return nil
}
func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return nil
}
@@ -8,12 +8,14 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
type activateWindowUserSubRepo struct {
userSubRepoNoop
windowStart time.Time
dailyStart time.Time
periodicStart time.Time
}
type monthlyResetUserSubRepo struct {
@@ -28,8 +30,9 @@ func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64,
return nil
}
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error {
r.windowStart = start
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, dailyStart, periodicStart time.Time) error {
r.dailyStart = dailyStart
r.periodicStart = periodicStart
return nil
}
@@ -47,8 +50,9 @@ func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) {
require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub))
require.Equal(t, activatedAt, repo.windowStart)
monthlyWindowStart := repo.windowStart
require.Equal(t, activatedAt, repo.periodicStart)
require.Equal(t, timezone.StartOfDay(activatedAt), repo.dailyStart)
monthlyWindowStart := repo.periodicStart
resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour))
require.True(t, ok)
require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt)
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
@@ -24,7 +25,8 @@ type resetQuotaUserSubRepoStub struct {
resetDailyErr error
resetWeeklyErr error
resetMonthlyErr error
windowStart time.Time
dailyStart time.Time
periodicStart time.Time
}
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
@@ -35,11 +37,12 @@ func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserS
return &cp, nil
}
func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, windowStart time.Time) error {
func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
r.resetDailyCalled = resetDaily
r.resetWeeklyCalled = resetWeekly
r.resetMonthlyCalled = resetMonthly
r.windowStart = windowStart
r.dailyStart = dailyStart
r.periodicStart = periodicStart
if resetDaily && r.resetDailyErr != nil {
return r.resetDailyErr
}
@@ -54,15 +57,15 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64
}
if resetDaily {
r.sub.DailyUsageUSD = 0
r.sub.DailyWindowStart = &windowStart
r.sub.DailyWindowStart = &dailyStart
}
if resetWeekly {
r.sub.WeeklyUsageUSD = 0
r.sub.WeeklyWindowStart = &windowStart
r.sub.WeeklyWindowStart = &periodicStart
}
if resetMonthly {
r.sub.MonthlyUsageUSD = 0
r.sub.MonthlyWindowStart = &windowStart
r.sub.MonthlyWindowStart = &periodicStart
}
return nil
}
@@ -105,8 +108,10 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
require.Equal(t, resetAt, stub.windowStart)
require.Equal(t, resetAt, *result.DailyWindowStart)
// 手动重置后日窗口锚定当天 0 点(保持 0 点刷新节奏),周窗口锚定重置时刻。
require.Equal(t, timezone.StartOfDay(resetAt), stub.dailyStart)
require.Equal(t, resetAt, stub.periodicStart)
require.Equal(t, timezone.StartOfDay(resetAt), *result.DailyWindowStart)
require.Equal(t, resetAt, *result.WeeklyWindowStart)
}
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/dgraph-io/ristretto"
"golang.org/x/sync/singleflight"
)
@@ -380,13 +381,15 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f
func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
renewed := *existingSub
windowStart := startsAt
// 日窗口按日历日对齐(0 点刷新);周/月窗口按订阅期限对齐(锚点为新周期起点)。
dailyWindowStart := timezone.StartOfDay(startsAt)
periodicWindowStart := startsAt
renewed.StartsAt = startsAt
renewed.ExpiresAt = expiresAt
renewed.Status = SubscriptionStatusActive
renewed.DailyWindowStart = &windowStart
renewed.WeeklyWindowStart = &windowStart
renewed.MonthlyWindowStart = &windowStart
renewed.DailyWindowStart = &dailyWindowStart
renewed.WeeklyWindowStart = &periodicWindowStart
renewed.MonthlyWindowStart = &periodicWindowStart
renewed.DailyUsageUSD = 0
renewed.WeeklyUsageUSD = 0
renewed.MonthlyUsageUSD = 0
@@ -858,7 +861,9 @@ func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub
return nil
}
return s.userSubRepo.ActivateWindows(ctx, sub.ID, now)
// 日窗口锚定当天 0 点(日历日语义);周/月窗口锚定首次使用时刻(期限对齐语义,
// 锚点不得早于 StartsAt,否则最后一个不完整周期会重复发放额度,见 issue #5051)。
return s.userSubRepo.ActivateWindows(ctx, sub.ID, timezone.StartOfDay(now), now)
}
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
@@ -870,8 +875,10 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
if err != nil {
return nil, err
}
windowStart := s.now()
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil {
now := s.now()
// 日窗口锚点取当天 0 点:手动重置只清空用量,不改变“每天 0 点刷新”的节奏。
// 周/月窗口保持锚定重置时刻(期限对齐滚动窗口语义)。
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, timezone.StartOfDay(now), now); err != nil {
return nil, err
}
// Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
@@ -890,8 +897,8 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
now := s.now()
needsInvalidateCache := false
// 日窗口重置(24小时
if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok {
// 日窗口重置(每天 0 点刷新,按日历日对齐
if windowStart, ok := sub.automaticDailyWindowStartAt(now); ok {
expectedWindowStart := sub.DailyWindowStart
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
return err
+32 -11
View File
@@ -1,6 +1,10 @@
package service
import "time"
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
)
const subscriptionDayDuration = 24 * time.Hour
@@ -75,13 +79,8 @@ func (s *UserSubscription) NeedsDailyReset() bool {
}
func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
if s.DailyWindowStart == nil {
return false
}
if s.HasOneTimeDailyQuota() {
return false
}
return !now.Before(s.DailyWindowStart.Add(24 * time.Hour))
_, ok := s.automaticDailyWindowStartAt(now)
return ok
}
func (s *UserSubscription) NeedsWeeklyReset() bool {
@@ -107,8 +106,26 @@ func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool {
}
func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool {
_, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now)
return !s.HasOneTimeDailyQuota() && ok
_, ok := s.automaticDailyWindowStartAt(now)
return ok
}
// automaticDailyWindowStartAt 计算日窗口按“配置时区日历日”对齐后的当前窗口起点。
// 日额度固定在每天 0 点刷新(与周/月的期限对齐滚动窗口语义不同),因此只要持久化
// 的窗口起点落在更早的日历日,就允许推进到今天 0 点。手动重置、激活等写入的任何
// 非 0 点锚点都会在下一个 0 点被拉回日历日边界,不会永久漂移刷新时刻。
func (s *UserSubscription) automaticDailyWindowStartAt(now time.Time) (time.Time, bool) {
if s.DailyWindowStart == nil {
return time.Time{}, false
}
if s.HasOneTimeDailyQuota() {
return time.Time{}, false
}
today := timezone.StartOfDay(now)
if !today.After(timezone.StartOfDay(*s.DailyWindowStart)) {
return time.Time{}, false
}
return today, true
}
func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool {
@@ -121,6 +138,9 @@ func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool {
return ok
}
// automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。
// 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整
// 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。
func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) {
if previous == nil {
return time.Time{}, false
@@ -155,7 +175,8 @@ func (s *UserSubscription) DailyResetTime() *time.Time {
t := s.ExpiresAt
return &t
}
t := s.DailyWindowStart.Add(24 * time.Hour)
// 日窗口按日历日对齐:下次刷新固定在窗口起点所在日的次日 0 点。
t := timezone.StartOfDay(*s.DailyWindowStart).AddDate(0, 0, 1)
return &t
}
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
@@ -58,7 +59,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes
require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
require.NotNil(t, renewed.DailyWindowStart)
require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart)
require.Equal(t, timezone.StartOfDay(renewed.StartsAt), *renewed.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
require.Equal(t, 0.0, renewed.DailyUsageUSD)
require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
@@ -29,8 +29,13 @@ type UserSubscriptionRepository interface {
UpdateStatus(ctx context.Context, subscriptionID int64, status string) error
UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error
ActivateWindows(ctx context.Context, id int64, start time.Time) error
ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error
// ActivateWindows 首次使用时激活用量窗口。日窗口按日历日对齐,锚点为当天 0 点
// dailyStart);周/月窗口为期限对齐滚动窗口,锚点为激活时刻(periodicStart)。
// 仅当三个窗口均未激活时生效。
ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
// ResetUsageWindows 手动重置所选窗口的用量。日窗口锚点写入 dailyStart(当天 0 点,
// 保持 0 点刷新节奏不漂移);周/月窗口锚点写入 periodicStart(重置时刻)。
ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error
ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error
ResetWeeklyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error
ResetMonthlyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error