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