mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
fix(subscription): align quota windows with subscription term
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user