Merge pull request #335 from geminiwen/main

feat(subscription): 支持调整订阅时长(延长/缩短)
This commit is contained in:
Wesley Liddick
2026-01-20 08:52:19 +08:00
committed by GitHub
7 changed files with 90 additions and 42 deletions

View File

@@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct {
Notes string `json:"notes"` Notes string `json:"notes"`
} }
// ExtendSubscriptionRequest represents extend subscription request // AdjustSubscriptionRequest represents adjust subscription request (extend or shorten)
type ExtendSubscriptionRequest struct { type AdjustSubscriptionRequest struct {
Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years 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 // 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)) 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 // POST /api/v1/admin/subscriptions/:id/extend
func (h *SubscriptionHandler) Extend(c *gin.Context) { func (h *SubscriptionHandler) Extend(c *gin.Context) {
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64) subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -189,7 +189,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return return
} }
var req ExtendSubscriptionRequest var req AdjustSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error()) response.BadRequest(c, "Invalid request: "+err.Error())
return return

View File

@@ -27,6 +27,7 @@ var (
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")
ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil") 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 订阅服务 // SubscriptionService 订阅服务
@@ -308,17 +309,20 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti
return nil return nil
} }
// ExtendSubscription 延长订阅 // ExtendSubscription 调整订阅时长(正数延长,负数缩短)
func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) { func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) {
sub, err := s.userSubRepo.GetByID(ctx, subscriptionID) sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
if err != nil { if err != nil {
return nil, ErrSubscriptionNotFound return nil, ErrSubscriptionNotFound
} }
// 限制延长天数 // 限制调整天数范围
if days > MaxValidityDays { if days > MaxValidityDays {
days = MaxValidityDays days = MaxValidityDays
} }
if days < -MaxValidityDays {
days = -MaxValidityDays
}
// 计算新的过期时间 // 计算新的过期时间
newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days) newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days)
@@ -326,6 +330,14 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
newExpiresAt = MaxExpiresAt 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 { if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
return nil, err return nil, err
} }

View File

@@ -4,6 +4,7 @@
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary"> <button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" /> <Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
</button> </button>
<slot name="after"></slot>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button> <button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button> <button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div> </div>

View File

@@ -950,7 +950,7 @@ export default {
title: 'Subscription Management', title: 'Subscription Management',
description: 'Manage user subscriptions and quota limits', description: 'Manage user subscriptions and quota limits',
assignSubscription: 'Assign Subscription', assignSubscription: 'Assign Subscription',
extendSubscription: 'Extend Subscription', adjustSubscription: 'Adjust Subscription',
revokeSubscription: 'Revoke Subscription', revokeSubscription: 'Revoke Subscription',
allStatus: 'All Status', allStatus: 'All Status',
allGroups: 'All Groups', allGroups: 'All Groups',
@@ -965,6 +965,7 @@ export default {
resetInHoursMinutes: 'Resets in {hours}h {minutes}m', resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
resetInDaysHours: 'Resets in {days}d {hours}h', resetInDaysHours: 'Resets in {days}d {hours}h',
daysRemaining: 'days remaining', daysRemaining: 'days remaining',
remainingDays: 'Remaining days',
noExpiration: 'No expiration', noExpiration: 'No expiration',
status: { status: {
active: 'Active', active: 'Active',
@@ -983,28 +984,32 @@ export default {
user: 'User', user: 'User',
group: 'Subscription Group', group: 'Subscription Group',
validityDays: 'Validity (Days)', validityDays: 'Validity (Days)',
extendDays: 'Extend by (Days)' adjustDays: 'Adjust by (Days)'
}, },
selectUser: 'Select a user', selectUser: 'Select a user',
selectGroup: 'Select a subscription group', selectGroup: 'Select a subscription group',
groupHint: 'Only groups with subscription billing type are shown', groupHint: 'Only groups with subscription billing type are shown',
validityHint: 'Number of days the subscription will be valid', validityHint: 'Number of days the subscription will be valid',
extendingFor: 'Extending subscription for', adjustingFor: 'Adjusting subscription for',
currentExpiration: 'Current expiration', 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', assign: 'Assign',
assigning: 'Assigning...', assigning: 'Assigning...',
extend: 'Extend', adjust: 'Adjust',
extending: 'Extending...', adjusting: 'Adjusting...',
revoke: 'Revoke', revoke: 'Revoke',
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',
subscriptionExtended: 'Subscription extended successfully', subscriptionAdjusted: 'Subscription adjusted successfully',
subscriptionRevoked: 'Subscription revoked successfully', subscriptionRevoked: 'Subscription revoked successfully',
failedToLoad: 'Failed to load subscriptions', failedToLoad: 'Failed to load subscriptions',
failedToAssign: 'Failed to assign subscription', failedToAssign: 'Failed to assign subscription',
failedToExtend: 'Failed to extend subscription', failedToAdjust: 'Failed to adjust subscription',
failedToRevoke: 'Failed to revoke 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', pleaseSelectUser: 'Please select a user',
pleaseSelectGroup: 'Please select a group', pleaseSelectGroup: 'Please select a group',
validityDaysRequired: 'Please enter a valid number of days (at least 1)', validityDaysRequired: 'Please enter a valid number of days (at least 1)',

View File

@@ -1025,7 +1025,7 @@ export default {
title: '订阅管理', title: '订阅管理',
description: '管理用户订阅和配额限制', description: '管理用户订阅和配额限制',
assignSubscription: '分配订阅', assignSubscription: '分配订阅',
extendSubscription: '延长订阅', adjustSubscription: '调整订阅',
revokeSubscription: '撤销订阅', revokeSubscription: '撤销订阅',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
@@ -1040,6 +1040,7 @@ export default {
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
resetInDaysHours: '{days} 天 {hours} 小时后重置', resetInDaysHours: '{days} 天 {hours} 小时后重置',
daysRemaining: '天剩余', daysRemaining: '天剩余',
remainingDays: '剩余天数',
noExpiration: '无过期时间', noExpiration: '无过期时间',
status: { status: {
active: '生效中', active: '生效中',
@@ -1058,28 +1059,32 @@ export default {
user: '用户', user: '用户',
group: '订阅分组', group: '订阅分组',
validityDays: '有效期(天)', validityDays: '有效期(天)',
extendDays: '延长天数' adjustDays: '调整天数'
}, },
selectUser: '选择用户', selectUser: '选择用户',
selectGroup: '选择订阅分组', selectGroup: '选择订阅分组',
groupHint: '仅显示订阅计费类型的分组', groupHint: '仅显示订阅计费类型的分组',
validityHint: '订阅的有效天数', validityHint: '订阅的有效天数',
extendingFor: '为以下用户延长订阅', adjustingFor: '为以下用户调整订阅',
currentExpiration: '当前到期时间', currentExpiration: '当前到期时间',
adjustDaysPlaceholder: '正数延长,负数缩短',
adjustHint: '输入正数延长订阅负数缩短订阅缩短后剩余天数需大于0',
assign: '分配', assign: '分配',
assigning: '分配中...', assigning: '分配中...',
extend: '延长', adjust: '调整',
extending: '延长中...', adjusting: '调整中...',
revoke: '撤销', revoke: '撤销',
noSubscriptionsYet: '暂无订阅', noSubscriptionsYet: '暂无订阅',
assignFirstSubscription: '分配一个订阅以开始使用。', assignFirstSubscription: '分配一个订阅以开始使用。',
subscriptionAssigned: '订阅分配成功', subscriptionAssigned: '订阅分配成功',
subscriptionExtended: '订阅延长成功', subscriptionAdjusted: '订阅调整成功',
subscriptionRevoked: '订阅撤销成功', subscriptionRevoked: '订阅撤销成功',
failedToLoad: '加载订阅列表失败', failedToLoad: '加载订阅列表失败',
failedToAssign: '分配订阅失败', failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败', failedToAdjust: '调整订阅失败',
failedToRevoke: '撤销订阅失败', failedToRevoke: '撤销订阅失败',
adjustWouldExpire: '调整后剩余天数必须大于0',
adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间',
pleaseSelectUser: '请选择用户', pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组', pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数至少1天', validityDaysRequired: '请输入有效的天数至少1天',

View File

@@ -16,7 +16,7 @@
@sync="showSync = true" @sync="showSync = true"
@create="showCreate = true" @create="showCreate = true"
> >
<template #before> <template #after>
<!-- Column Settings Dropdown --> <!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef"> <div class="relative" ref="columnDropdownRef">
<button <button

View File

@@ -85,6 +85,14 @@
<!-- Right: Actions --> <!-- Right: Actions -->
<div class="ml-auto flex flex-wrap items-center justify-end gap-3"> <div class="ml-auto flex flex-wrap items-center justify-end gap-3">
<button
@click="loadSubscriptions"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<!-- Column Settings Dropdown --> <!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef"> <div class="relative" ref="columnDropdownRef">
<button <button
@@ -136,14 +144,6 @@
</div> </div>
</div> </div>
</div> </div>
<button
@click="loadSubscriptions"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="showAssignModal = true" class="btn btn-primary"> <button @click="showAssignModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.subscriptions.assignSubscription') }} {{ t('admin.subscriptions.assignSubscription') }}
@@ -359,10 +359,10 @@
<button <button
v-if="row.status === 'active'" v-if="row.status === 'active'"
@click="handleExtend(row)" @click="handleExtend(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
> >
<Icon name="clock" size="sm" /> <Icon name="calendar" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span> <span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span>
</button> </button>
<button <button
v-if="row.status === 'active'" v-if="row.status === 'active'"
@@ -512,10 +512,10 @@
</template> </template>
</BaseDialog> </BaseDialog>
<!-- Extend Subscription Modal --> <!-- Adjust Subscription Modal -->
<BaseDialog <BaseDialog
:show="showExtendModal" :show="showExtendModal"
:title="t('admin.subscriptions.extendSubscription')" :title="t('admin.subscriptions.adjustSubscription')"
width="narrow" width="narrow"
@close="closeExtendModal" @close="closeExtendModal"
> >
@@ -527,7 +527,7 @@
> >
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700"> <div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.subscriptions.extendingFor') }} {{ t('admin.subscriptions.adjustingFor') }}
<span class="font-medium text-gray-900 dark:text-white">{{ <span class="font-medium text-gray-900 dark:text-white">{{
extendingSubscription.user?.email extendingSubscription.user?.email
}}</span> }}</span>
@@ -542,10 +542,25 @@
}} }}
</span> </span>
</p> </p>
<p v-if="extendingSubscription.expires_at" class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.subscriptions.remainingDays') }}:
<span class="font-medium text-gray-900 dark:text-white">
{{ getDaysRemaining(extendingSubscription.expires_at) ?? 0 }}
</span>
</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label> <label class="input-label">{{ t('admin.subscriptions.form.adjustDays') }}</label>
<input v-model.number="extendForm.days" type="number" min="1" required class="input" /> <div class="flex items-center gap-2">
<input
v-model.number="extendForm.days"
type="number"
required
class="input text-center"
:placeholder="t('admin.subscriptions.adjustDaysPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.subscriptions.adjustHint') }}</p>
</div> </div>
</form> </form>
<template #footer> <template #footer>
@@ -559,7 +574,7 @@
:disabled="submitting" :disabled="submitting"
class="btn btn-primary" class="btn btn-primary"
> >
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }} {{ submitting ? t('admin.subscriptions.adjusting') : t('admin.subscriptions.adjust') }}
</button> </button>
</div> </div>
</template> </template>
@@ -1000,17 +1015,27 @@ const closeExtendModal = () => {
const handleExtendSubscription = async () => { const handleExtendSubscription = async () => {
if (!extendingSubscription.value) return if (!extendingSubscription.value) return
// 前端验证:调整后剩余天数必须 > 0
if (extendingSubscription.value.expires_at) {
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
const newDaysRemaining = currentDaysRemaining + extendForm.days
if (newDaysRemaining <= 0) {
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
return
}
}
submitting.value = true submitting.value = true
try { try {
await adminAPI.subscriptions.extend(extendingSubscription.value.id, { await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
days: extendForm.days days: extendForm.days
}) })
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended')) appStore.showSuccess(t('admin.subscriptions.subscriptionAdjusted'))
closeExtendModal() closeExtendModal()
loadSubscriptions() loadSubscriptions()
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend')) appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAdjust'))
console.error('Error extending subscription:', error) console.error('Error adjusting subscription:', error)
} finally { } finally {
submitting.value = false submitting.value = false
} }