Merge pull request #909 from StarryKira/feature/admin-reset-subscription-quota

Feature/管理员可以重置账号额度
This commit is contained in:
Wesley Liddick
2026-03-12 09:26:47 +08:00
committed by GitHub
8 changed files with 303 additions and 0 deletions

View File

@@ -216,6 +216,37 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
}) })
} }
// ResetSubscriptionQuotaRequest represents the reset quota request
type ResetSubscriptionQuotaRequest struct {
Daily bool `json:"daily"`
Weekly bool `json:"weekly"`
}
// ResetQuota resets daily and/or weekly 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)
if err != nil {
response.BadRequest(c, "Invalid subscription ID")
return
}
var req ResetSubscriptionQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
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")
return
}
sub, err := h.subscriptionService.AdminResetQuota(c.Request.Context(), subscriptionID, req.Daily, req.Weekly)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub))
}
// Revoke handles revoking a subscription // Revoke handles revoking a subscription
// DELETE /api/v1/admin/subscriptions/:id // DELETE /api/v1/admin/subscriptions/:id
func (h *SubscriptionHandler) Revoke(c *gin.Context) { func (h *SubscriptionHandler) Revoke(c *gin.Context) {

View File

@@ -456,6 +456,7 @@ func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
subscriptions.POST("/assign", h.Admin.Subscription.Assign) subscriptions.POST("/assign", h.Admin.Subscription.Assign)
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign) subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend) subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
subscriptions.POST("/:id/reset-quota", h.Admin.Subscription.ResetQuota)
subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke) subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke)
} }

View File

@@ -0,0 +1,166 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage
// 其余方法继承 userSubRepoNooppanic
type resetQuotaUserSubRepoStub struct {
userSubRepoNoop
sub *UserSubscription
resetDailyCalled bool
resetWeeklyCalled bool
resetDailyErr error
resetWeeklyErr error
}
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
if r.sub == nil || r.sub.ID != id {
return nil, ErrSubscriptionNotFound
}
cp := *r.sub
return &cp, nil
}
func (r *resetQuotaUserSubRepoStub) ResetDailyUsage(_ context.Context, _ int64, windowStart time.Time) error {
r.resetDailyCalled = true
if r.resetDailyErr == nil && r.sub != nil {
r.sub.DailyUsageUSD = 0
r.sub.DailyWindowStart = &windowStart
}
return r.resetDailyErr
}
func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64, _ time.Time) error {
r.resetWeeklyCalled = true
return r.resetWeeklyErr
}
func newResetQuotaSvc(stub *resetQuotaUserSubRepoStub) *SubscriptionService {
return NewSubscriptionService(groupRepoNoop{}, stub, nil, nil, nil)
}
func TestAdminResetQuota_ResetBoth(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 1, true, true)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
}
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 2, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 2, true, false)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage")
}
func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 3, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 3, false, true)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
}
func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 7, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 7, false, false)
require.ErrorIs(t, err, ErrInvalidInput)
require.False(t, stub.resetDailyCalled)
require.False(t, stub.resetWeeklyCalled)
}
func TestAdminResetQuota_SubscriptionNotFound(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{sub: nil}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 999, true, true)
require.ErrorIs(t, err, ErrSubscriptionNotFound)
require.False(t, stub.resetDailyCalled)
require.False(t, stub.resetWeeklyCalled)
}
func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 4, UserID: 10, GroupID: 20},
resetDailyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 4, true, true)
require.ErrorIs(t, err, dbErr)
require.True(t, stub.resetDailyCalled)
require.False(t, stub.resetWeeklyCalled, "daily 失败后不应继续调用 weekly")
}
func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 5, UserID: 10, GroupID: 20},
resetWeeklyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 5, false, true)
require.ErrorIs(t, err, dbErr)
require.True(t, stub.resetWeeklyCalled)
}
func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{
ID: 6,
UserID: 10,
GroupID: 20,
DailyUsageUSD: 99.9,
},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 6, true, false)
require.NoError(t, err)
// ResetDailyUsage stub 会将 sub.DailyUsageUSD 归零,
// 服务应返回第二次 GetByID 的刷新值而非初始的 99.9
require.Equal(t, float64(0), result.DailyUsageUSD, "返回的订阅应反映已归零的用量")
require.True(t, stub.resetDailyCalled)
}

View File

@@ -31,6 +31,7 @@ var (
ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group") 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") 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") 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")
ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded") ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded")
ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded") ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded")
ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded") ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded")
@@ -695,6 +696,36 @@ func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *U
return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart) return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart)
} }
// AdminResetQuota manually resets the daily and/or weekly 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 {
return nil, ErrInvalidInput
}
sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
windowStart := startOfDay(time.Now())
if resetDaily {
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, windowStart); err != nil {
return nil, err
}
}
if resetWeekly {
if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, windowStart); err != nil {
return nil, err
}
}
// Invalidate caches, same as CheckAndResetWindows
s.InvalidateSubCache(sub.UserID, sub.GroupID)
if s.billingCacheService != nil {
_ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
}
// Return the refreshed subscription from DB
return s.userSubRepo.GetByID(ctx, subscriptionID)
}
// CheckAndResetWindows 检查并重置过期的窗口 // CheckAndResetWindows 检查并重置过期的窗口
func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error { func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error {
// 使用当天零点作为新窗口起始时间 // 使用当天零点作为新窗口起始时间

View File

@@ -120,6 +120,23 @@ export async function revoke(id: number): Promise<{ message: string }> {
return data return data
} }
/**
* Reset daily and/or weekly 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 }
): Promise<UserSubscription> {
const { data } = await apiClient.post<UserSubscription>(
`/admin/subscriptions/${id}/reset-quota`,
options
)
return data
}
/** /**
* List subscriptions by group * List subscriptions by group
* @param groupId - Group ID * @param groupId - Group ID
@@ -170,6 +187,7 @@ export const subscriptionsAPI = {
bulkAssign, bulkAssign,
extend, extend,
revoke, revoke,
resetQuota,
listByGroup, listByGroup,
listByUser listByUser
} }

View File

@@ -1570,6 +1570,11 @@ export default {
adjust: 'Adjust', adjust: 'Adjust',
adjusting: 'Adjusting...', adjusting: 'Adjusting...',
revoke: 'Revoke', 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.",
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
noSubscriptionsYet: 'No subscriptions yet', noSubscriptionsYet: 'No subscriptions yet',
assignFirstSubscription: 'Assign a subscription to get started.', assignFirstSubscription: 'Assign a subscription to get started.',
subscriptionAssigned: 'Subscription assigned successfully', subscriptionAssigned: 'Subscription assigned successfully',

View File

@@ -1658,6 +1658,11 @@ export default {
adjust: '调整', adjust: '调整',
adjusting: '调整中...', adjusting: '调整中...',
revoke: '撤销', revoke: '撤销',
resetQuota: '重置配额',
resetQuotaTitle: '重置用量配额',
resetQuotaConfirm: "确定要重置 '{user}' 的每日和每周用量配额吗?用量将归零并从今天开始重新计算。",
quotaResetSuccess: '配额重置成功',
failedToResetQuota: '重置配额失败',
noSubscriptionsYet: '暂无订阅', noSubscriptionsYet: '暂无订阅',
assignFirstSubscription: '分配一个订阅以开始使用。', assignFirstSubscription: '分配一个订阅以开始使用。',
subscriptionAssigned: '订阅分配成功', subscriptionAssigned: '订阅分配成功',

View File

@@ -370,6 +370,15 @@
<Icon name="calendar" size="sm" /> <Icon name="calendar" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span> <span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span>
</button> </button>
<button
v-if="row.status === 'active'"
@click="handleResetQuota(row)"
:disabled="resettingQuota && resettingSubscription?.id === row.id"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon name="refresh" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.resetQuota') }}</span>
</button>
<button <button
v-if="row.status === 'active'" v-if="row.status === 'active'"
@click="handleRevoke(row)" @click="handleRevoke(row)"
@@ -618,6 +627,17 @@
@confirm="confirmRevoke" @confirm="confirmRevoke"
@cancel="showRevokeDialog = false" @cancel="showRevokeDialog = false"
/> />
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show="showResetQuotaConfirm"
:title="t('admin.subscriptions.resetQuotaTitle')"
:message="t('admin.subscriptions.resetQuotaConfirm', { user: resettingSubscription?.user?.email })"
:confirm-text="t('admin.subscriptions.resetQuota')"
:cancel-text="t('common.cancel')"
@confirm="confirmResetQuota"
@cancel="showResetQuotaConfirm = false"
/>
</AppLayout> </AppLayout>
</template> </template>
@@ -812,7 +832,10 @@ const pagination = reactive({
const showAssignModal = ref(false) const showAssignModal = ref(false)
const showExtendModal = ref(false) const showExtendModal = ref(false)
const showRevokeDialog = ref(false) const showRevokeDialog = ref(false)
const showResetQuotaConfirm = ref(false)
const submitting = ref(false) const submitting = ref(false)
const resettingSubscription = ref<UserSubscription | null>(null)
const resettingQuota = ref(false)
const extendingSubscription = ref<UserSubscription | null>(null) const extendingSubscription = ref<UserSubscription | null>(null)
const revokingSubscription = ref<UserSubscription | null>(null) const revokingSubscription = ref<UserSubscription | null>(null)
@@ -1121,6 +1144,29 @@ const confirmRevoke = async () => {
} }
} }
const handleResetQuota = (subscription: UserSubscription) => {
resettingSubscription.value = subscription
showResetQuotaConfirm.value = true
}
const confirmResetQuota = async () => {
if (!resettingSubscription.value) return
if (resettingQuota.value) return
resettingQuota.value = true
try {
await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true })
appStore.showSuccess(t('admin.subscriptions.quotaResetSuccess'))
showResetQuotaConfirm.value = false
resettingSubscription.value = null
await loadSubscriptions()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToResetQuota'))
console.error('Error resetting quota:', error)
} finally {
resettingQuota.value = false
}
}
// Helper functions // Helper functions
const getDaysRemaining = (expiresAt: string): number | null => { const getDaysRemaining = (expiresAt: string): number | null => {
const now = new Date() const now = new Date()