diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 37f06a038b..671f847e86 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -358,12 +358,18 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error { client := clientFromContext(ctx, r.client) - _, err := client.UserSubscription.UpdateOneID(id). + n, err := client.UserSubscription.Update(). + Where( + usersubscription.IDEQ(id), + usersubscription.DailyWindowStartIsNil(), + usersubscription.WeeklyWindowStartIsNil(), + usersubscription.MonthlyWindowStartIsNil(), + ). SetDailyWindowStart(start). SetWeeklyWindowStart(start). SetMonthlyWindowStart(start). Save(ctx) - return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) + 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 { diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 96eead494e..1d1964a20f 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -463,6 +463,25 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond) } +func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() { + user := s.mustCreateUser("activate-cas@test.com", service.RoleUser) + group := s.mustCreateGroup("g-activate-cas") + 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) + + 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)) + // Simulate a concurrent request carrying the original unactivated snapshot. + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, 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(manualResetAt, *got.WeeklyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond) +} + func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() { user := s.mustCreateUser("resetd@test.com", service.RoleUser) group := s.mustCreateGroup("g-resetd") diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 151b5c7928..5819182109 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -417,9 +417,9 @@ 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, startOfDay(sub.StartsAt), *sub.DailyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart) + require.Equal(t, sub.StartsAt, *sub.DailyWindowStart) + require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart) + require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart) require.Zero(t, sub.DailyUsageUSD) require.Zero(t, sub.WeeklyUsageUSD) require.Zero(t, sub.MonthlyUsageUSD) diff --git a/backend/internal/service/subscription_monthly_window_test.go b/backend/internal/service/subscription_monthly_window_test.go new file mode 100644 index 0000000000..6cd4410c7b --- /dev/null +++ b/backend/internal/service/subscription_monthly_window_test.go @@ -0,0 +1,260 @@ +//go:build unit + +package service + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type activateWindowUserSubRepo struct { + userSubRepoNoop + windowStart time.Time +} + +type monthlyResetUserSubRepo struct { + userSubRepoNoop + resetCalled bool + resetAt time.Time +} + +func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, resetAt time.Time) error { + r.resetCalled = true + r.resetAt = resetAt + return nil +} + +func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error { + r.windowStart = start + return nil +} + +func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) { + repo := &activateWindowUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + startsAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + activatedAt := time.Date(2026, 7, 10, 23, 30, 0, 0, time.UTC) + svc.now = func() time.Time { return activatedAt } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + } + + require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub)) + + require.Equal(t, activatedAt, repo.windowStart) + monthlyWindowStart := repo.windowStart + 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) + require.NotEqual(t, startsAt.Add(30*24*time.Hour), resetAt) +} + +func TestThirtyDaySubscriptionDoesNotResetMonthlyQuotaBeforeExpiry(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + expiresAt := startsAt.Add(30 * 24 * time.Hour) + renewed := renewedSubscriptionTerm(&UserSubscription{}, "", startsAt, expiresAt) + + require.Equal(t, startsAt, *renewed.MonthlyWindowStart) + require.False(t, renewed.NeedsMonthlyResetAt(expiresAt.Add(-time.Second))) + require.True(t, renewed.NeedsMonthlyResetAt(expiresAt)) + require.False(t, renewed.canAutomaticallyResetMonthlyAt(expiresAt)) + require.Equal(t, expiresAt, *renewed.MonthlyResetTime()) +} + +func TestCheckAndResetWindowsDoesNotResetExactThirtyDayLegacyMonthlyWindow(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.False(t, repo.resetCalled) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) + require.Equal(t, windowStart, *sub.MonthlyWindowStart) +} + +func TestCheckAndResetWindowsResetsPartialFinalMonthlySubscriptions(t *testing.T) { + for _, durationDays := range []int{31, 45} { + t.Run(strconv.Itoa(durationDays)+"_days", func(t *testing.T) { + legacyWindowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 2, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(time.Duration(durationDays) * 24 * time.Hour), + MonthlyWindowStart: &legacyWindowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.True(t, repo.resetCalled) + require.Equal(t, startsAt.Add(30*24*time.Hour), repo.resetAt) + require.Zero(t, sub.MonthlyUsageUSD) + }) + } +} + +func TestNormalizeExpiredWindowsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Equal(t, 12.0, subs[0].MonthlyUsageUSD) + require.Equal(t, windowStart, *subs[0].MonthlyWindowStart) +} + +func TestNormalizeExpiredWindowsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(31 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Zero(t, subs[0].MonthlyUsageUSD) + require.Nil(t, subs[0].MonthlyWindowStart) +} + +func TestValidateAndCheckLimitsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.ErrorIs(t, err, ErrMonthlyLimitExceeded) + require.False(t, needsMaintenance) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.NoError(t, err) + require.True(t, needsMaintenance) + require.Zero(t, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsRejectsExactExpiry(t *testing.T) { + now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + sub := &UserSubscription{Status: SubscriptionStatusActive, ExpiresAt: now} + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{}) + + require.ErrorIs(t, err, ErrSubscriptionExpired) + require.False(t, needsMaintenance) +} + +func TestAutomaticWindowsAllowPartialFinalDailyAndWeeklyPeriods(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 45, 0, 0, time.UTC) + legacyWindowStart := startOfDay(startsAt) + tests := []struct { + name string + period time.Duration + expiresAt time.Time + }{ + {name: "daily", period: 24 * time.Hour, expiresAt: startsAt.Add(36 * time.Hour)}, + {name: "weekly", period: 7 * 24 * time.Hour, expiresAt: startsAt.Add(10 * 24 * time.Hour)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: tt.expiresAt} + resetAt, ok := sub.automaticWindowStartAt(&legacyWindowStart, tt.period, startsAt.Add(tt.period)) + + require.True(t, ok) + require.Equal(t, startsAt.Add(tt.period), resetAt) + }) + } +} + +func TestAutomaticWindowPreservesPersistedManualAnchorAndAdvancesWholePeriods(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startsAt.Add(5 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(65*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(60*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesPeriodAlignedLaterMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startOfDay(startsAt).Add(30 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, startsAt.Add(60*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesExactMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(30*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.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 e4ed45ec45..df16db1d56 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -24,6 +24,7 @@ type resetQuotaUserSubRepoStub struct { resetDailyErr error resetWeeklyErr error resetMonthlyErr error + windowStart time.Time } func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) { @@ -38,6 +39,7 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64 r.resetDailyCalled = resetDaily r.resetWeeklyCalled = resetWeekly r.resetMonthlyCalled = resetMonthly + r.windowStart = windowStart if resetDaily && r.resetDailyErr != nil { return r.resetDailyErr } @@ -93,6 +95,8 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20}, } svc := newResetQuotaSvc(stub) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + svc.now = func() time.Time { return resetAt } result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false) @@ -101,6 +105,9 @@ 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) + require.Equal(t, resetAt, *result.WeeklyWindowStart) } func TestAdminResetQuota_ResetDailyOnly(t *testing.T) { @@ -203,6 +210,30 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) { require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage") } +func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + stub := &resetQuotaUserSubRepoStub{ + sub: &UserSubscription{ + ID: 10, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + }, + } + svc := newResetQuotaSvc(stub) + svc.now = func() time.Time { return resetAt } + + result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true) + + require.NoError(t, err) + require.Equal(t, resetAt, *result.MonthlyWindowStart) + boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour)) + require.True(t, ok) + require.Equal(t, resetAt.Add(30*24*time.Hour), boundary) +} + func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) { dbErr := errors.New("db error") stub := &resetQuotaUserSubRepoStub{ diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index fbe99bf906..a55e898259 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -55,6 +55,7 @@ type SubscriptionService struct { subCacheJitter int // 抖动百分比 maintenanceQueue *SubscriptionMaintenanceQueue + now func() time.Time } // NewSubscriptionService 创建订阅服务 @@ -64,6 +65,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript userSubRepo: userSubRepo, billingCacheService: billingCacheService, entClient: entClient, + now: time.Now, } svc.initSubCache(cfg) svc.initMaintenanceQueue(cfg) @@ -368,7 +370,7 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription { renewed := *existingSub - windowStart := startOfDay(startsAt) + windowStart := startsAt renewed.StartsAt = startsAt renewed.ExpiresAt = expiresAt renewed.Status = SubscriptionStatusActive @@ -803,20 +805,24 @@ func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, user // normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库) // 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据 func normalizeExpiredWindows(subs []UserSubscription) { + normalizeExpiredWindowsAt(subs, time.Now()) +} + +func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) { for i := range subs { sub := &subs[i] // 日窗口过期:清零展示数据 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyWindowStart = nil sub.DailyUsageUSD = 0 } // 周窗口过期:清零展示数据 - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyWindowStart = nil sub.WeeklyUsageUSD = 0 } // 月窗口过期:清零展示数据 - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyWindowStart = nil sub.MonthlyUsageUSD = 0 } @@ -842,17 +848,18 @@ func startOfDay(t time.Time) time.Time { // CheckAndActivateWindow 检查并激活窗口(首次使用时) func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error { + return s.checkAndActivateWindowAt(ctx, sub, s.now()) +} + +func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error { if sub.IsWindowActivated() { return nil } - // 使用当天零点作为窗口起始时间 - windowStart := startOfDay(time.Now()) - return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart) + return s.userSubRepo.ActivateWindows(ctx, sub.ID, now) } // AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows. -// Uses startOfDay(now) as the new window start, matching automatic resets. func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) { if !resetDaily && !resetWeekly && !resetMonthly { return nil, ErrInvalidInput @@ -861,7 +868,7 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI if err != nil { return nil, err } - windowStart := startOfDay(time.Now()) + windowStart := s.now() if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil { return nil, err } @@ -878,12 +885,11 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI // CheckAndResetWindows 检查并重置过期的窗口 func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error { - // 使用当天零点作为新窗口起始时间 - windowStart := startOfDay(time.Now()) + now := s.now() needsInvalidateCache := false // 日窗口重置(24小时) - if sub.NeedsDailyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok { expectedWindowStart := sub.DailyWindowStart if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -894,7 +900,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 周窗口重置(7天) - if sub.NeedsWeeklyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok { expectedWindowStart := sub.WeeklyWindowStart if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -905,7 +911,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 月窗口重置(30天) - if sub.NeedsMonthlyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok { expectedWindowStart := sub.MonthlyWindowStart if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -971,6 +977,7 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub // 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。 // 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。 func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) { + now := s.now() // 1. 验证订阅状态 if sub.Status == SubscriptionStatusExpired { return false, ErrSubscriptionExpired @@ -978,21 +985,21 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou if sub.Status == SubscriptionStatusSuspended { return false, ErrSubscriptionSuspended } - if sub.IsExpired() { + if !sub.ExpiresAt.After(now) { return false, ErrSubscriptionExpired } // 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。 // 调用方随后同步推进 DB 窗口,并用回读快照重新校验。 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyUsageUSD = 0 needsMaintenance = true } diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 12e3dd428a..3a73554a4b 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -85,17 +85,66 @@ func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool { } func (s *UserSubscription) NeedsWeeklyReset() bool { + return s.NeedsWeeklyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool { if s.WeeklyWindowStart == nil { return false } - return time.Since(*s.WeeklyWindowStart) >= 7*24*time.Hour + return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour)) } func (s *UserSubscription) NeedsMonthlyReset() bool { + return s.NeedsMonthlyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool { if s.MonthlyWindowStart == nil { return false } - return time.Since(*s.MonthlyWindowStart) >= 30*24*time.Hour + return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour)) +} + +func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now) + return !s.HasOneTimeDailyQuota() && ok +} + +func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now) + return ok +} + +func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now) + return ok +} + +func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) { + if previous == nil { + 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 + } + next := anchor.Add(period) + if now.Before(next) || !next.Before(s.ExpiresAt) { + return time.Time{}, false + } + + periods := now.Sub(anchor) / period + lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period + if periods > lastPeriodBeforeExpiry { + periods = lastPeriodBeforeExpiry + } + return anchor.Add(periods * period), true } func (s *UserSubscription) DailyResetTime() *time.Time { diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index bc04812166..6e62eb3539 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -58,7 +58,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, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart) + require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart) require.Equal(t, 0.0, renewed.DailyUsageUSD) require.Equal(t, 0.0, renewed.WeeklyUsageUSD) require.Equal(t, 0.0, renewed.MonthlyUsageUSD) @@ -160,17 +160,18 @@ func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) { } func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) { - now := time.Now() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) startsAt := now.Add(-48 * time.Hour) dailyWindowStart := now.Add(-25 * time.Hour) repo := &dailyResetTrackingUserSubRepo{} 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, 2), + ExpiresAt: startsAt.AddDate(0, 0, 4), DailyUsageUSD: 10, DailyWindowStart: &dailyWindowStart, }