Skip to content

Commit fd693dc

Browse files
authored
Merge pull request Wei-Shaw#967 from StarryKira/fix/admin-reset-quota-monthly
fix: 管理员重置配额补全 monthly 字段并修复 ristretto 缓存异步问题 fix issue Wei-Shaw#964
2 parents ecea137 + e73531c commit fd693dc

7 files changed

Lines changed: 81 additions & 29 deletions

File tree

backend/internal/handler/admin/subscription_handler.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,12 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
218218

219219
// ResetSubscriptionQuotaRequest represents the reset quota request
220220
type ResetSubscriptionQuotaRequest struct {
221-
Daily bool `json:"daily"`
222-
Weekly bool `json:"weekly"`
221+
Daily bool `json:"daily"`
222+
Weekly bool `json:"weekly"`
223+
Monthly bool `json:"monthly"`
223224
}
224225

225-
// ResetQuota resets daily and/or weekly usage for a subscription.
226+
// ResetQuota resets daily, weekly, and/or monthly usage for a subscription.
226227
// POST /api/v1/admin/subscriptions/:id/reset-quota
227228
func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
228229
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -235,11 +236,11 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
235236
response.BadRequest(c, "Invalid request: "+err.Error())
236237
return
237238
}
238-
if !req.Daily && !req.Weekly {
239-
response.BadRequest(c, "At least one of 'daily' or 'weekly' must be true")
239+
if !req.Daily && !req.Weekly && !req.Monthly {
240+
response.BadRequest(c, "At least one of 'daily', 'weekly', or 'monthly' must be true")
240241
return
241242
}
242-
sub, err := h.subscriptionService.AdminResetQuota(c.Request.Context(), subscriptionID, req.Daily, req.Weekly)
243+
sub, err := h.subscriptionService.AdminResetQuota(c.Request.Context(), subscriptionID, req.Daily, req.Weekly, req.Monthly)
243244
if err != nil {
244245
response.ErrorFrom(c, err)
245246
return

backend/internal/service/subscription_reset_quota_test.go

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,19 @@ import (
1111
"github.com/stretchr/testify/require"
1212
)
1313

14-
// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage,
14+
// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage、ResetMonthlyUsage
1515
// 其余方法继承 userSubRepoNoop(panic)。
1616
type resetQuotaUserSubRepoStub struct {
1717
userSubRepoNoop
1818

1919
sub *UserSubscription
2020

21-
resetDailyCalled bool
22-
resetWeeklyCalled bool
23-
resetDailyErr error
24-
resetWeeklyErr error
21+
resetDailyCalled bool
22+
resetWeeklyCalled bool
23+
resetMonthlyCalled bool
24+
resetDailyErr error
25+
resetWeeklyErr error
26+
resetMonthlyErr error
2527
}
2628

2729
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
@@ -46,6 +48,11 @@ func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64,
4648
return r.resetWeeklyErr
4749
}
4850

51+
func (r *resetQuotaUserSubRepoStub) ResetMonthlyUsage(_ context.Context, _ int64, _ time.Time) error {
52+
r.resetMonthlyCalled = true
53+
return r.resetMonthlyErr
54+
}
55+
4956
func newResetQuotaSvc(stub *resetQuotaUserSubRepoStub) *SubscriptionService {
5057
return NewSubscriptionService(groupRepoNoop{}, stub, nil, nil, nil)
5158
}
@@ -56,12 +63,13 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
5663
}
5764
svc := newResetQuotaSvc(stub)
5865

59-
result, err := svc.AdminResetQuota(context.Background(), 1, true, true)
66+
result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false)
6067

6168
require.NoError(t, err)
6269
require.NotNil(t, result)
6370
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
6471
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
72+
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
6573
}
6674

6775
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
@@ -70,12 +78,13 @@ func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
7078
}
7179
svc := newResetQuotaSvc(stub)
7280

73-
result, err := svc.AdminResetQuota(context.Background(), 2, true, false)
81+
result, err := svc.AdminResetQuota(context.Background(), 2, true, false, false)
7482

7583
require.NoError(t, err)
7684
require.NotNil(t, result)
7785
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
7886
require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage")
87+
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
7988
}
8089

8190
func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
@@ -84,12 +93,13 @@ func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
8493
}
8594
svc := newResetQuotaSvc(stub)
8695

87-
result, err := svc.AdminResetQuota(context.Background(), 3, false, true)
96+
result, err := svc.AdminResetQuota(context.Background(), 3, false, true, false)
8897

8998
require.NoError(t, err)
9099
require.NotNil(t, result)
91100
require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage")
92101
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
102+
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
93103
}
94104

95105
func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) {
@@ -98,22 +108,24 @@ func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) {
98108
}
99109
svc := newResetQuotaSvc(stub)
100110

101-
_, err := svc.AdminResetQuota(context.Background(), 7, false, false)
111+
_, err := svc.AdminResetQuota(context.Background(), 7, false, false, false)
102112

103113
require.ErrorIs(t, err, ErrInvalidInput)
104114
require.False(t, stub.resetDailyCalled)
105115
require.False(t, stub.resetWeeklyCalled)
116+
require.False(t, stub.resetMonthlyCalled)
106117
}
107118

108119
func TestAdminResetQuota_SubscriptionNotFound(t *testing.T) {
109120
stub := &resetQuotaUserSubRepoStub{sub: nil}
110121
svc := newResetQuotaSvc(stub)
111122

112-
_, err := svc.AdminResetQuota(context.Background(), 999, true, true)
123+
_, err := svc.AdminResetQuota(context.Background(), 999, true, true, true)
113124

114125
require.ErrorIs(t, err, ErrSubscriptionNotFound)
115126
require.False(t, stub.resetDailyCalled)
116127
require.False(t, stub.resetWeeklyCalled)
128+
require.False(t, stub.resetMonthlyCalled)
117129
}
118130

119131
func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
@@ -124,7 +136,7 @@ func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
124136
}
125137
svc := newResetQuotaSvc(stub)
126138

127-
_, err := svc.AdminResetQuota(context.Background(), 4, true, true)
139+
_, err := svc.AdminResetQuota(context.Background(), 4, true, true, false)
128140

129141
require.ErrorIs(t, err, dbErr)
130142
require.True(t, stub.resetDailyCalled)
@@ -139,12 +151,41 @@ func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) {
139151
}
140152
svc := newResetQuotaSvc(stub)
141153

142-
_, err := svc.AdminResetQuota(context.Background(), 5, false, true)
154+
_, err := svc.AdminResetQuota(context.Background(), 5, false, true, false)
143155

144156
require.ErrorIs(t, err, dbErr)
145157
require.True(t, stub.resetWeeklyCalled)
146158
}
147159

160+
func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
161+
stub := &resetQuotaUserSubRepoStub{
162+
sub: &UserSubscription{ID: 8, UserID: 10, GroupID: 20},
163+
}
164+
svc := newResetQuotaSvc(stub)
165+
166+
result, err := svc.AdminResetQuota(context.Background(), 8, false, false, true)
167+
168+
require.NoError(t, err)
169+
require.NotNil(t, result)
170+
require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage")
171+
require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage")
172+
require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage")
173+
}
174+
175+
func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) {
176+
dbErr := errors.New("db error")
177+
stub := &resetQuotaUserSubRepoStub{
178+
sub: &UserSubscription{ID: 9, UserID: 10, GroupID: 20},
179+
resetMonthlyErr: dbErr,
180+
}
181+
svc := newResetQuotaSvc(stub)
182+
183+
_, err := svc.AdminResetQuota(context.Background(), 9, false, false, true)
184+
185+
require.ErrorIs(t, err, dbErr)
186+
require.True(t, stub.resetMonthlyCalled)
187+
}
188+
148189
func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
149190
stub := &resetQuotaUserSubRepoStub{
150191
sub: &UserSubscription{
@@ -156,7 +197,7 @@ func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
156197
}
157198

158199
svc := newResetQuotaSvc(stub)
159-
result, err := svc.AdminResetQuota(context.Background(), 6, true, false)
200+
result, err := svc.AdminResetQuota(context.Background(), 6, true, false, false)
160201

161202
require.NoError(t, err)
162203
// ResetDailyUsage stub 会将 sub.DailyUsageUSD 归零,

backend/internal/service/subscription_service.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var (
3131
ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group")
3232
ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics")
3333
ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type")
34-
ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily or resetWeekly must be true")
34+
ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true")
3535
ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded")
3636
ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded")
3737
ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded")
@@ -696,10 +696,10 @@ func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *U
696696
return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart)
697697
}
698698

699-
// AdminResetQuota manually resets the daily and/or weekly usage windows.
699+
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
700700
// Uses startOfDay(now) as the new window start, matching automatic resets.
701-
func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly bool) (*UserSubscription, error) {
702-
if !resetDaily && !resetWeekly {
701+
func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) {
702+
if !resetDaily && !resetWeekly && !resetMonthly {
703703
return nil, ErrInvalidInput
704704
}
705705
sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
@@ -717,8 +717,18 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
717717
return nil, err
718718
}
719719
}
720-
// Invalidate caches, same as CheckAndResetWindows
720+
if resetMonthly {
721+
if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, windowStart); err != nil {
722+
return nil, err
723+
}
724+
}
725+
// Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
726+
// so call Wait() immediately after to flush pending operations and guarantee
727+
// the deleted key is not returned on the very next Get() call.
721728
s.InvalidateSubCache(sub.UserID, sub.GroupID)
729+
if s.subCacheL1 != nil {
730+
s.subCacheL1.Wait()
731+
}
722732
if s.billingCacheService != nil {
723733
_ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
724734
}

frontend/src/api/admin/subscriptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@ export async function revoke(id: number): Promise<{ message: string }> {
121121
}
122122

123123
/**
124-
* Reset daily and/or weekly usage quota for a subscription
124+
* Reset daily, weekly, and/or monthly usage quota for a subscription
125125
* @param id - Subscription ID
126126
* @param options - Which windows to reset
127127
* @returns Updated subscription
128128
*/
129129
export async function resetQuota(
130130
id: number,
131-
options: { daily: boolean; weekly: boolean }
131+
options: { daily: boolean; weekly: boolean; monthly: boolean }
132132
): Promise<UserSubscription> {
133133
const { data } = await apiClient.post<UserSubscription>(
134134
`/admin/subscriptions/${id}/reset-quota`,

frontend/src/i18n/locales/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1574,7 +1574,7 @@ export default {
15741574
revoke: 'Revoke',
15751575
resetQuota: 'Reset Quota',
15761576
resetQuotaTitle: 'Reset Usage Quota',
1577-
resetQuotaConfirm: "Reset the daily and weekly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.",
1577+
resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.",
15781578
quotaResetSuccess: 'Quota reset successfully',
15791579
failedToResetQuota: 'Failed to reset quota',
15801580
noSubscriptionsYet: 'No subscriptions yet',

frontend/src/i18n/locales/zh.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1662,7 +1662,7 @@ export default {
16621662
revoke: '撤销',
16631663
resetQuota: '重置配额',
16641664
resetQuotaTitle: '重置用量配额',
1665-
resetQuotaConfirm: "确定要重置 '{user}' 的每日和每周用量配额吗?用量将归零并从今天开始重新计算。",
1665+
resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。",
16661666
quotaResetSuccess: '配额重置成功',
16671667
failedToResetQuota: '重置配额失败',
16681668
noSubscriptionsYet: '暂无订阅',

frontend/src/views/admin/SubscriptionsView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1154,7 +1154,7 @@ const confirmResetQuota = async () => {
11541154
if (resettingQuota.value) return
11551155
resettingQuota.value = true
11561156
try {
1157-
await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true })
1157+
await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true, monthly: true })
11581158
appStore.showSuccess(t('admin.subscriptions.quotaResetSuccess'))
11591159
showResetQuotaConfirm.value = false
11601160
resettingSubscription.value = null

0 commit comments

Comments
 (0)