diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 8047d7d78b..0af3eda985 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -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) diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 1d1964a20f..28f4c1b7d3 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -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) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9a017f5620..cb7e41007f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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 { diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 25adbb3121..6b5f10d48e 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -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 }, diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 1bfb3519b1..964fe8041b 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -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") } diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index d6dde5e944..ed6c4e0aae 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -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) diff --git a/backend/internal/service/subscription_daily_midnight_reset_test.go b/backend/internal/service/subscription_daily_midnight_reset_test.go new file mode 100644 index 0000000000..5f16847511 --- /dev/null +++ b/backend/internal/service/subscription_daily_midnight_reset_test.go @@ -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) +} diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index bb4372fa46..74b82e56c3 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -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 } diff --git a/backend/internal/service/subscription_monthly_window_test.go b/backend/internal/service/subscription_monthly_window_test.go index 6cd4410c7b..3e71ca17ef 100644 --- a/backend/internal/service/subscription_monthly_window_test.go +++ b/backend/internal/service/subscription_monthly_window_test.go @@ -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) diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go index df16db1d56..43ea5b0d10 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -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) } diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 47b18c582e..7d716b7a8e 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -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 diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 3a73554a4b..fb4d77f0f1 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -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 } diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index 6e62eb3539..43e9799333 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -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) diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index 7ce79f4b27..a20e4a4f26 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -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