diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 3cdc8368..a0d1456f 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct { Notes string `json:"notes"` } -// ExtendSubscriptionRequest represents extend subscription request -type ExtendSubscriptionRequest struct { - Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years +// AdjustSubscriptionRequest represents adjust subscription request (extend or shorten) +type AdjustSubscriptionRequest struct { + Days int `json:"days" binding:"required,min=-36500,max=36500"` // negative to shorten, positive to extend } // List handles listing all subscriptions with pagination and filters @@ -180,7 +180,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) { response.Success(c, dto.BulkAssignResultFromService(result)) } -// Extend handles extending a subscription +// Extend handles adjusting a subscription (extend or shorten) // POST /api/v1/admin/subscriptions/:id/extend func (h *SubscriptionHandler) Extend(c *gin.Context) { subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -189,7 +189,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) { return } - var req ExtendSubscriptionRequest + var req AdjustSubscriptionRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index d960c86f..c25c58a2 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -27,6 +27,7 @@ var ( ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded") ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded") ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil") + ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)") ) // SubscriptionService 订阅服务 @@ -308,17 +309,20 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti return nil } -// ExtendSubscription 延长订阅 +// ExtendSubscription 调整订阅时长(正数延长,负数缩短) func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) { sub, err := s.userSubRepo.GetByID(ctx, subscriptionID) if err != nil { return nil, ErrSubscriptionNotFound } - // 限制延长天数 + // 限制调整天数范围 if days > MaxValidityDays { days = MaxValidityDays } + if days < -MaxValidityDays { + days = -MaxValidityDays + } // 计算新的过期时间 newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days) @@ -326,6 +330,14 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti newExpiresAt = MaxExpiresAt } + // 如果是缩短(负数),检查新的过期时间必须大于当前时间 + if days < 0 { + now := time.Now() + if !newExpiresAt.After(now) { + return nil, ErrAdjustWouldExpire + } + } + if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil { return nil, err } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1ff04ff6..d1eca6a1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -950,7 +950,7 @@ export default { title: 'Subscription Management', description: 'Manage user subscriptions and quota limits', assignSubscription: 'Assign Subscription', - extendSubscription: 'Extend Subscription', + adjustSubscription: 'Adjust Subscription', revokeSubscription: 'Revoke Subscription', allStatus: 'All Status', allGroups: 'All Groups', @@ -965,6 +965,7 @@ export default { resetInHoursMinutes: 'Resets in {hours}h {minutes}m', resetInDaysHours: 'Resets in {days}d {hours}h', daysRemaining: 'days remaining', + remainingDays: 'Remaining days', noExpiration: 'No expiration', status: { active: 'Active', @@ -983,28 +984,32 @@ export default { user: 'User', group: 'Subscription Group', validityDays: 'Validity (Days)', - extendDays: 'Extend by (Days)' + adjustDays: 'Adjust by (Days)' }, selectUser: 'Select a user', selectGroup: 'Select a subscription group', groupHint: 'Only groups with subscription billing type are shown', validityHint: 'Number of days the subscription will be valid', - extendingFor: 'Extending subscription for', + adjustingFor: 'Adjusting subscription for', currentExpiration: 'Current expiration', + adjustDaysPlaceholder: 'Positive to extend, negative to shorten', + adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)', assign: 'Assign', assigning: 'Assigning...', - extend: 'Extend', - extending: 'Extending...', + adjust: 'Adjust', + adjusting: 'Adjusting...', revoke: 'Revoke', noSubscriptionsYet: 'No subscriptions yet', assignFirstSubscription: 'Assign a subscription to get started.', subscriptionAssigned: 'Subscription assigned successfully', - subscriptionExtended: 'Subscription extended successfully', + subscriptionAdjusted: 'Subscription adjusted successfully', subscriptionRevoked: 'Subscription revoked successfully', failedToLoad: 'Failed to load subscriptions', failedToAssign: 'Failed to assign subscription', - failedToExtend: 'Failed to extend subscription', + failedToAdjust: 'Failed to adjust subscription', failedToRevoke: 'Failed to revoke subscription', + adjustWouldExpire: 'Remaining days after adjustment must be greater than 0', + adjustOutOfRange: 'Adjustment days must be between -36500 and 36500', pleaseSelectUser: 'Please select a user', pleaseSelectGroup: 'Please select a group', validityDaysRequired: 'Please enter a valid number of days (at least 1)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 5b1b76f2..86ac7ae5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1025,7 +1025,7 @@ export default { title: '订阅管理', description: '管理用户订阅和配额限制', assignSubscription: '分配订阅', - extendSubscription: '延长订阅', + adjustSubscription: '调整订阅', revokeSubscription: '撤销订阅', allStatus: '全部状态', allGroups: '全部分组', @@ -1040,6 +1040,7 @@ export default { resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', resetInDaysHours: '{days} 天 {hours} 小时后重置', daysRemaining: '天剩余', + remainingDays: '剩余天数', noExpiration: '无过期时间', status: { active: '生效中', @@ -1058,28 +1059,32 @@ export default { user: '用户', group: '订阅分组', validityDays: '有效期(天)', - extendDays: '延长天数' + adjustDays: '调整天数' }, selectUser: '选择用户', selectGroup: '选择订阅分组', groupHint: '仅显示订阅计费类型的分组', validityHint: '订阅的有效天数', - extendingFor: '为以下用户延长订阅', + adjustingFor: '为以下用户调整订阅', currentExpiration: '当前到期时间', + adjustDaysPlaceholder: '正数延长,负数缩短', + adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)', assign: '分配', assigning: '分配中...', - extend: '延长', - extending: '延长中...', + adjust: '调整', + adjusting: '调整中...', revoke: '撤销', noSubscriptionsYet: '暂无订阅', assignFirstSubscription: '分配一个订阅以开始使用。', subscriptionAssigned: '订阅分配成功', - subscriptionExtended: '订阅延长成功', + subscriptionAdjusted: '订阅调整成功', subscriptionRevoked: '订阅撤销成功', failedToLoad: '加载订阅列表失败', failedToAssign: '分配订阅失败', - failedToExtend: '延长订阅失败', + failedToAdjust: '调整订阅失败', failedToRevoke: '撤销订阅失败', + adjustWouldExpire: '调整后剩余天数必须大于0', + adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间', pleaseSelectUser: '请选择用户', pleaseSelectGroup: '请选择分组', validityDaysRequired: '请输入有效的天数(至少1天)', diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index d5a47788..547ea5f4 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -359,10 +359,10 @@