Merge PR #541: 添加账户订阅到期时间管理功能 + 修复核心过期检查逻辑

## 原PR功能
-  后端添加subscriptionExpiresAt字段支持
-  前端提供到期时间设置界面(快捷选项 + 自定义日期)
-  账户列表显示到期状态(已过期🔴/即将过期🟠/永不过期)
-  新增AccountExpiryEditModal.vue编辑弹窗组件
-  支持创建和更新账户时设置到期时间
-  完整支持暗黑模式

## 🔧 关键修复(本次提交)
原PR缺少核心过期检查逻辑,过期账户仍会被调度使用。本次合并时添加了:

1. **新增isAccountNotExpired()方法**:
   - 检查账户subscriptionExpiresAt字段
   - 未设置过期时间视为永不过期
   - 添加debug日志记录过期账户

2. **在selectAvailableAccount()中添加过期检查**:
   - 过滤逻辑中集成this.isAccountNotExpired(account)
   - 确保过期账户不被选择

3. **在selectAccountForApiKey()中添加过期检查**:
   - 绑定账户检查中添加过期验证
   - 共享池过滤中添加过期验证

## 🗑️ 清理工作
- 移除了不应提交的account_expire_feature.md评审文档(756行)

## 技术细节
- API层使用expiresAt,存储层使用subscriptionExpiresAt
- 存储格式:ISO 8601 (UTC)
- 空值表示:null表示永不过期
- 时区处理:后端UTC,前端自动转换本地时区

作者: mrlitong (原PR) + Claude Code (修复)
PR: https://github.com/Wei-Shaw/claude-relay-service/pull/541
This commit is contained in:
shaw
2025-10-12 13:42:57 +08:00
5 changed files with 728 additions and 10 deletions

View File

@@ -641,6 +641,49 @@
</p>
</div>
<!-- 到期时间 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>到期时间 (可选)</label
>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateAccountExpireAt"
>
<option value="">永不过期</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateAccountCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1" />
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-infinity mr-1" />
账户永不过期
</p>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -2069,6 +2112,49 @@
</p>
</div>
<!-- 到期时间 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>到期时间 (可选)</label
>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateAccountExpireAt"
>
<option value="">永不过期</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateAccountCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1" />
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-infinity mr-1" />
账户永不过期
</p>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -3352,7 +3438,11 @@ const form = ref({
// Azure OpenAI 特定字段
azureEndpoint: props.account?.azureEndpoint || '',
apiVersion: props.account?.apiVersion || '',
deploymentName: props.account?.deploymentName || ''
deploymentName: props.account?.deploymentName || '',
// 到期时间字段
expireDuration: '',
customExpireDate: '',
expiresAt: props.account?.expiresAt || null
})
// 模型限制配置
@@ -3778,6 +3868,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload
}
@@ -4069,6 +4160,7 @@ const createAccount = async () => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload
}
@@ -4328,6 +4420,7 @@ const updateAccount = async () => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload
}
@@ -5171,6 +5264,61 @@ const handleUnifiedClientIdChange = () => {
}
}
// 到期时间相关方法
// 计算最小日期时间
const minDateTime = computed(() => {
const now = new Date()
now.setMinutes(now.getMinutes() + 1)
return now.toISOString().slice(0, 16)
})
// 更新账户过期时间
const updateAccountExpireAt = () => {
if (!form.value.expireDuration) {
form.value.expiresAt = null
return
}
if (form.value.expireDuration === 'custom') {
return
}
const now = new Date()
const duration = form.value.expireDuration
const match = duration.match(/(\d+)([d])/)
if (match) {
const [, value, unit] = match
const num = parseInt(value)
if (unit === 'd') {
now.setDate(now.getDate() + num)
}
form.value.expiresAt = now.toISOString()
}
}
// 更新自定义过期时间
const updateAccountCustomExpireAt = () => {
if (form.value.customExpireDate) {
form.value.expiresAt = new Date(form.value.customExpireDate).toISOString()
}
}
// 格式化过期日期
const formatExpireDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 组件挂载时获取统一 User-Agent 信息
onMounted(() => {
// 初始化平台分组