diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index d6073551..342964b6 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -218,11 +218,12 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) { // ResetSubscriptionQuotaRequest represents the reset quota request type ResetSubscriptionQuotaRequest struct { - Daily bool `json:"daily"` - Weekly bool `json:"weekly"` + Daily bool `json:"daily"` + Weekly bool `json:"weekly"` + Monthly bool `json:"monthly"` } -// ResetQuota resets daily and/or weekly usage for a subscription. +// ResetQuota resets daily, weekly, and/or monthly usage for a subscription. // POST /api/v1/admin/subscriptions/:id/reset-quota func (h *SubscriptionHandler) ResetQuota(c *gin.Context) { subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -235,11 +236,11 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if !req.Daily && !req.Weekly { - response.BadRequest(c, "At least one of 'daily' or 'weekly' must be true") + if !req.Daily && !req.Weekly && !req.Monthly { + response.BadRequest(c, "At least one of 'daily', 'weekly', or 'monthly' must be true") return } - sub, err := h.subscriptionService.AdminResetQuota(c.Request.Context(), subscriptionID, req.Daily, req.Weekly) + sub, err := h.subscriptionService.AdminResetQuota(c.Request.Context(), subscriptionID, req.Daily, req.Weekly, req.Monthly) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go index 36aa177f..3bbc2170 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -11,17 +11,19 @@ import ( "github.com/stretchr/testify/require" ) -// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage, +// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage、ResetMonthlyUsage, // 其余方法继承 userSubRepoNoop(panic)。 type resetQuotaUserSubRepoStub struct { userSubRepoNoop sub *UserSubscription - resetDailyCalled bool - resetWeeklyCalled bool - resetDailyErr error - resetWeeklyErr error + resetDailyCalled bool + resetWeeklyCalled bool + resetMonthlyCalled bool + resetDailyErr error + resetWeeklyErr error + resetMonthlyErr error } func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) { @@ -46,6 +48,11 @@ func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64, return r.resetWeeklyErr } +func (r *resetQuotaUserSubRepoStub) ResetMonthlyUsage(_ context.Context, _ int64, _ time.Time) error { + r.resetMonthlyCalled = true + return r.resetMonthlyErr +} + func newResetQuotaSvc(stub *resetQuotaUserSubRepoStub) *SubscriptionService { return NewSubscriptionService(groupRepoNoop{}, stub, nil, nil, nil) } @@ -56,12 +63,13 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { } svc := newResetQuotaSvc(stub) - result, err := svc.AdminResetQuota(context.Background(), 1, true, true) + result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false) require.NoError(t, err) require.NotNil(t, result) require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage") require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage") + require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") } func TestAdminResetQuota_ResetDailyOnly(t *testing.T) { @@ -70,12 +78,13 @@ func TestAdminResetQuota_ResetDailyOnly(t *testing.T) { } svc := newResetQuotaSvc(stub) - result, err := svc.AdminResetQuota(context.Background(), 2, true, false) + result, err := svc.AdminResetQuota(context.Background(), 2, true, false, false) require.NoError(t, err) require.NotNil(t, result) require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage") require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage") + require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") } func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) { @@ -84,12 +93,13 @@ func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) { } svc := newResetQuotaSvc(stub) - result, err := svc.AdminResetQuota(context.Background(), 3, false, true) + result, err := svc.AdminResetQuota(context.Background(), 3, false, true, false) require.NoError(t, err) require.NotNil(t, result) require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage") require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage") + require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") } func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) { @@ -98,22 +108,24 @@ func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) { } svc := newResetQuotaSvc(stub) - _, err := svc.AdminResetQuota(context.Background(), 7, false, false) + _, err := svc.AdminResetQuota(context.Background(), 7, false, false, false) require.ErrorIs(t, err, ErrInvalidInput) require.False(t, stub.resetDailyCalled) require.False(t, stub.resetWeeklyCalled) + require.False(t, stub.resetMonthlyCalled) } func TestAdminResetQuota_SubscriptionNotFound(t *testing.T) { stub := &resetQuotaUserSubRepoStub{sub: nil} svc := newResetQuotaSvc(stub) - _, err := svc.AdminResetQuota(context.Background(), 999, true, true) + _, err := svc.AdminResetQuota(context.Background(), 999, true, true, true) require.ErrorIs(t, err, ErrSubscriptionNotFound) require.False(t, stub.resetDailyCalled) require.False(t, stub.resetWeeklyCalled) + require.False(t, stub.resetMonthlyCalled) } func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) { @@ -124,7 +136,7 @@ func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) { } svc := newResetQuotaSvc(stub) - _, err := svc.AdminResetQuota(context.Background(), 4, true, true) + _, err := svc.AdminResetQuota(context.Background(), 4, true, true, false) require.ErrorIs(t, err, dbErr) require.True(t, stub.resetDailyCalled) @@ -139,12 +151,41 @@ func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) { } svc := newResetQuotaSvc(stub) - _, err := svc.AdminResetQuota(context.Background(), 5, false, true) + _, err := svc.AdminResetQuota(context.Background(), 5, false, true, false) require.ErrorIs(t, err, dbErr) require.True(t, stub.resetWeeklyCalled) } +func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) { + stub := &resetQuotaUserSubRepoStub{ + sub: &UserSubscription{ID: 8, UserID: 10, GroupID: 20}, + } + svc := newResetQuotaSvc(stub) + + result, err := svc.AdminResetQuota(context.Background(), 8, false, false, true) + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage") + require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage") + require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage") +} + +func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) { + dbErr := errors.New("db error") + stub := &resetQuotaUserSubRepoStub{ + sub: &UserSubscription{ID: 9, UserID: 10, GroupID: 20}, + resetMonthlyErr: dbErr, + } + svc := newResetQuotaSvc(stub) + + _, err := svc.AdminResetQuota(context.Background(), 9, false, false, true) + + require.ErrorIs(t, err, dbErr) + require.True(t, stub.resetMonthlyCalled) +} + func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) { stub := &resetQuotaUserSubRepoStub{ sub: &UserSubscription{ @@ -156,7 +197,7 @@ func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) { } svc := newResetQuotaSvc(stub) - result, err := svc.AdminResetQuota(context.Background(), 6, true, false) + result, err := svc.AdminResetQuota(context.Background(), 6, true, false, false) require.NoError(t, err) // ResetDailyUsage stub 会将 sub.DailyUsageUSD 归零, diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 55f029fa..af548509 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -31,7 +31,7 @@ var ( ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group") ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics") ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type") - ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily or resetWeekly must be true") + ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true") ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded") ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded") ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded") @@ -696,10 +696,10 @@ func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *U return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart) } -// AdminResetQuota manually resets the daily and/or weekly usage windows. +// 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 bool) (*UserSubscription, error) { - if !resetDaily && !resetWeekly { +func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) { + if !resetDaily && !resetWeekly && !resetMonthly { return nil, ErrInvalidInput } sub, err := s.userSubRepo.GetByID(ctx, subscriptionID) @@ -717,8 +717,18 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI return nil, err } } - // Invalidate caches, same as CheckAndResetWindows + if resetMonthly { + if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, windowStart); err != nil { + return nil, err + } + } + // Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design, + // so call Wait() immediately after to flush pending operations and guarantee + // the deleted key is not returned on the very next Get() call. s.InvalidateSubCache(sub.UserID, sub.GroupID) + if s.subCacheL1 != nil { + s.subCacheL1.Wait() + } if s.billingCacheService != nil { _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID) } diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index d06e0774..7557e3ad 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -121,14 +121,14 @@ export async function revoke(id: number): Promise<{ message: string }> { } /** - * Reset daily and/or weekly usage quota for a subscription + * Reset daily, weekly, and/or monthly usage quota for a subscription * @param id - Subscription ID * @param options - Which windows to reset * @returns Updated subscription */ export async function resetQuota( id: number, - options: { daily: boolean; weekly: boolean } + options: { daily: boolean; weekly: boolean; monthly: boolean } ): Promise { const { data } = await apiClient.post( `/admin/subscriptions/${id}/reset-quota`, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9f847eb6..045964bf 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1574,7 +1574,7 @@ export default { revoke: 'Revoke', resetQuota: 'Reset Quota', resetQuotaTitle: 'Reset Usage Quota', - resetQuotaConfirm: "Reset the daily and weekly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.", + resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.", quotaResetSuccess: 'Quota reset successfully', failedToResetQuota: 'Failed to reset quota', noSubscriptionsYet: 'No subscriptions yet', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ddaced42..4307c314 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1662,7 +1662,7 @@ export default { revoke: '撤销', resetQuota: '重置配额', resetQuotaTitle: '重置用量配额', - resetQuotaConfirm: "确定要重置 '{user}' 的每日和每周用量配额吗?用量将归零并从今天开始重新计算。", + resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。", quotaResetSuccess: '配额重置成功', failedToResetQuota: '重置配额失败', noSubscriptionsYet: '暂无订阅', diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index bb711b01..97282594 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -1154,7 +1154,7 @@ const confirmResetQuota = async () => { if (resettingQuota.value) return resettingQuota.value = true try { - await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true }) + await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true, monthly: true }) appStore.showSuccess(t('admin.subscriptions.quotaResetSuccess')) showResetQuotaConfirm.value = false resettingSubscription.value = null