fix(subscription): align quota windows with subscription term

This commit is contained in:
wucm667
2026-07-30 02:37:14 +08:00
parent 5a6143097d
commit 7b6111f2f7
8 changed files with 401 additions and 28 deletions
@@ -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 {
@@ -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")
@@ -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)
@@ -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)
}
@@ -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{
@@ -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
}
+51 -2
View File
@@ -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 {
@@ -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,
}