feat: 为所有账户服务添加订阅过期检查功能

完成账户订阅到期时间功能的核心调度逻辑实现。

## 实现范围

 已添加订阅过期检查的服务(5个):
- Gemini 服务:添加 isSubscriptionExpired() 函数及调度过滤
- OpenAI 服务:添加 isSubscriptionExpired() 函数及调度过滤
- Droid 服务:添加 _isSubscriptionExpired() 方法及调度过滤
- Bedrock 服务:添加 _isSubscriptionExpired() 方法及调度过滤
- Azure OpenAI 服务:添加 isSubscriptionExpired() 函数及调度过滤

## 核心功能

- 账户调度时自动检查 subscriptionExpiresAt 字段
- 过期账户将不再被系统调度使用
- 未设置过期时间的账户视为永不过期(向后兼容)
- 使用 <= 比较判断过期(精确到过期时刻)
- 跳过过期账户时记录 debug 日志便于排查

## 技术实现

- 统一的实现模式:过期检查函数 + 账户选择逻辑集成
- 不影响现有功能,完全向后兼容
- 业务字段 subscriptionExpiresAt 与技术字段 expiresAt(OAuth token过期)独立管理

## 相关文档

参考 account_expire_bugfix.md 了解问题背景和实现细节

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
litongtongxue
2025-10-13 00:04:41 +08:00
committed by mrlitong
parent 268f041588
commit 1e7465e533
11 changed files with 1192 additions and 594 deletions

684
account_expire_bugfix.md Normal file
View File

@@ -0,0 +1,684 @@
# 账号过期功能代码评审报告
**评审日期**: 2025-10-12
**评审范围**: 账号订阅过期时间管理功能
**代码状态**: 部分完成,存在严重缺陷
**评审结论**: ❌ 不建议合并,需要修复核心缺陷
---
## 📋 执行摘要
本次功能开发实现了为所有 9 个账户平台添加订阅过期时间管理功能。在数据存储和前端展示层面,实现**完整且准确**。但在**核心调度逻辑层存在严重缺陷**
**✅ 已完成**:
- 前端路由层:所有平台支持过期时间编辑
- 后端数据层:所有服务完整存储 `subscriptionExpiresAt` 字段
- 字段映射层:路由层正确处理 `expiresAt``subscriptionExpiresAt` 映射
**❌ 严重缺陷**:
- **调度逻辑缺失**:除 Claude 外的所有平台Gemini、OpenAI、Droid 等)未在账号选择时检查订阅过期时间,导致过期账号仍会被正常调度使用
**影响评估**: 该缺陷导致功能对大部分平台实际无效,用户设置的过期时间不会生效。
---
## ✅ 已完成部分(质量优秀)
### 1. 前端路由修复 ⭐⭐⭐⭐⭐
**文件**: `web/admin-spa/src/views/AccountsView.vue`
**位置**: 第 3730-3790 行
**实现内容**:
```javascript
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
const account = accounts.value.find((acc) => acc.id === accountId)
// 根据平台类型动态选择正确的 API 端点
let endpoint = ''
switch (account.platform) {
case 'claude':
case 'claude-oauth':
endpoint = `/admin/claude-accounts/${accountId}`
break
case 'gemini':
endpoint = `/admin/gemini-accounts/${accountId}`
break
// ... 其他 7 个平台
}
await apiClient.put(endpoint, { expiresAt: expiresAt || null })
}
```
**覆盖平台**: 所有 9 个平台claude, gemini, claude-console, bedrock, ccr, openai, droid, azure_openai, openai-responses
---
### 2. 后端路由层字段映射 ⭐⭐⭐⭐⭐
**文件**: `src/routes/admin.js`
**覆盖路由**: 8 个 PUT 端点
**统一实现**:
```javascript
// 所有路由统一添加字段映射逻辑
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
logger.info(`Mapping expiresAt to subscriptionExpiresAt...`)
}
```
**覆盖位置**:
- ✅ Claude Console: admin.js:2748
- ✅ CCR: admin.js:3174
- ✅ Bedrock: admin.js:3577
- ✅ Gemini: admin.js:4047
- ✅ OpenAI: admin.js:7429
- ✅ Azure OpenAI: admin.js:7987
- ✅ OpenAI-Responses: admin.js:8357
- ✅ Droid: admin.js:8837
---
### 3. 后端数据层字段存储 ⭐⭐⭐⭐⭐
**涉及服务**: 全部 9 个 AccountService
**三层完整实现**:
#### 存储层 (createAccount)
```javascript
subscriptionExpiresAt: accountData.subscriptionExpiresAt || '',
```
#### 查询层 (getAllAccounts)
```javascript
// 映射给前端
expiresAt: accountData.subscriptionExpiresAt || null,
```
#### 更新层 (updateAccount)
```javascript
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做调整
}
```
**字段独立性**:
- `expiresAt` - OAuth Token 过期时间(技术字段,自动刷新)
- `subscriptionExpiresAt` - 账户订阅到期时间(业务字段,手动管理)
- 两字段完全独立Token 刷新不会覆盖订阅过期时间 ✅
---
## ❌ 严重缺陷(阻塞发布)
### 核心问题:调度逻辑缺失订阅过期时间检查
**严重性**: 🔴 **P0 - 阻塞发布**
**影响范围**: Gemini、OpenAI、Droid、及其他 6 个平台(除 Claude 外所有平台)
**影响**: 过期账号仍会被正常调度,导致功能实际无效
---
### 缺陷 1: Gemini 账号 ❌
**文件**: `src/services/geminiAccountService.js`
**位置**: 第 285-290 行
**方法**: `selectAvailableAccount()`
**问题代码**:
```javascript
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account) // ❌ 未检查 subscriptionExpiresAt
}
}
```
**修复方案**:
```javascript
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) return false
return new Date(account.subscriptionExpiresAt) <= new Date()
}
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account) // ✅ 添加过期检查
) {
availableAccounts.push(account)
}
}
```
---
### 缺陷 2: OpenAI 账号 ❌
**文件**: `src/services/openaiAccountService.js`
**位置**: 第 917-922 行
**方法**: `selectAvailableAccount()`
**问题代码**:
```javascript
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account) // ❌ 未检查 subscriptionExpiresAt
}
}
```
**修复方案**: 与 Gemini 相同,添加过期检查
---
### 缺陷 3: Droid 账号 ❌
**文件**: `src/services/droidAccountService.js`
**位置**: 第 914-939 行
**方法**: `getSchedulableAccounts()`
**问题代码**:
```javascript
return allAccounts.filter((account) => {
const isActive = this._isTruthy(account.isActive)
const isSchedulable = this._isTruthy(account.schedulable)
const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
if (!isActive || !isSchedulable || status !== 'active') {
return false // ❌ 只检查了这些条件,未检查 subscriptionExpiresAt
}
// ...
})
```
**修复方案**:
```javascript
_isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) return false
return new Date(account.subscriptionExpiresAt) <= new Date()
}
return allAccounts.filter((account) => {
const isActive = this._isTruthy(account.isActive)
const isSchedulable = this._isTruthy(account.schedulable)
const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
const expired = this._isSubscriptionExpired(account) // ✅ 添加过期检查
if (!isActive || !isSchedulable || status !== 'active' || expired) {
return false
}
// ...
})
```
---
### 缺陷 4-9: 其他平台 ⚠️
**平台**: CCR、Claude Console、Bedrock、Azure OpenAI、OpenAI-Responses
**状态**: 未发现独立的账号选择方法
**分析**:
- 这些平台可能通过 Claude 的统一调度逻辑
- 或采用简单的轮询/随机选择
- **需要全面测试确认**
**建议**: 修复前 3 个平台后,进行全量测试
---
### 参考实现: Claude 账号 ✅
**文件**: `src/services/claudeAccountService.js`
**位置**: 第 786-814 行
**正确实现**:
```javascript
isAccountNotExpired(account) {
if (!account.subscriptionExpiresAt) {
return true // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
const now = new Date()
if (expiryDate <= now) {
logger.debug(
`⏰ Account ${account.name} expired at ${account.subscriptionExpiresAt}`
)
return false
}
return true
}
// 在账号筛选时使用
activeAccounts = activeAccounts.filter(account =>
account.isActive === 'true' &&
account.status !== 'error' &&
account.schedulable !== 'false' &&
this.isAccountNotExpired(account) // ✅ 正确检查
)
```
**质量**: ⭐⭐⭐⭐⭐ 可作为其他平台的参考
---
## 🔧 修复方案
### 推荐方案:各服务添加独立过期检查函数
**优点**:
- 保持服务独立性
- 可定制日志格式
- 不引入额外依赖
### 修复步骤
#### 步骤 1: 添加辅助函数
在各服务中添加:
```javascript
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt || account.subscriptionExpiresAt === '') {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
```
#### 步骤 2: 在筛选逻辑中调用
**Gemini**:
```javascript
if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account) // ✅
) {
availableAccounts.push(account)
} else if (account && isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
}
```
**OpenAI**: 同上
**Droid**:
```javascript
const expired = this._isSubscriptionExpired(account)
if (!isActive || !isSchedulable || status !== 'active' || expired) {
if (expired) {
logger.debug(`⏰ Skipping expired Droid account: ${account.name}`)
}
return false
}
```
---
## ✅ 测试方案
### 1. 功能测试
#### TC-1.1: Gemini 账号过期检查
```yaml
前提条件:
- 2 个 Gemini 共享账号
- Account A: subscriptionExpiresAt = "2025-01-01" (未过期)
- Account B: subscriptionExpiresAt = "2024-01-01" (已过期)
步骤:
1. 调用 /api/v1/messages 发送 Gemini 请求
2. 检查日志查看选中的账号
预期:
- ✅ 只有 Account A 被选中
- ✅ 日志显示 Account B 因过期被跳过
- ✅ 请求正常完成
```
#### TC-1.2: OpenAI 账号过期检查
```yaml
前提条件:
- 2 个 OpenAI 共享账号
- Account C: subscriptionExpiresAt = "2025-01-01" (未过期)
- Account D: subscriptionExpiresAt = "2024-01-01" (已过期)
步骤:
1. 调用 OpenAI API
2. 检查日志
预期:
- ✅ 只有 Account C 被选中
- ✅ 请求正常完成
```
#### TC-1.3: Droid 账号过期检查
```yaml
前提条件:
- 2 个 Droid 共享账号
- Account E: subscriptionExpiresAt = "2025-01-01" (未过期)
- Account F: subscriptionExpiresAt = "2024-01-01" (已过期)
步骤:
1. 调用 Droid API
2. 检查日志
预期:
- ✅ 只有 Account E 被选中
```
#### TC-1.4: 全部过期无可用账号
```yaml
前提条件:
- 某平台所有账号都已过期
步骤:
1. 调用该平台 API
预期:
- ✅ 返回错误: "No available {platform} accounts"
- ✅ 日志显示所有账号因过期被跳过
```
---
### 2. 边界测试
#### TC-2.1: 未设置过期时间
```yaml
前提条件:
- subscriptionExpiresAt = null 或 ""
步骤:
1. 调用 API
预期:
- ✅ 账号正常被选中(永不过期)
```
#### TC-2.2: 过期时间边界
```yaml
前提条件:
- 当前时间: 2025-10-12 10:00:00
- subscriptionExpiresAt = "2025-10-12T10:00:00Z"
步骤:
1. 在 10:00:00 时调用 API
2. 在 10:00:01 时调用 API
预期:
- ✅ 两次调用账号都因过期被跳过(<= 判断)
```
#### TC-2.3: 无效日期格式
```yaml
前提条件:
- subscriptionExpiresAt = "invalid-date"
步骤:
1. 调用 API
预期:
- ✅ new Date() 返回 Invalid Date
- ✅ 比较结果为 false账号视为未过期容错
```
---
### 3. 回归测试
#### TC-3.1: 现有条件不受影响
```yaml
验证点:
- isActive = 'false' 仍被跳过
- schedulable = 'false' 仍被跳过
- status = 'error' 仍被跳过
- 限流账号仍被跳过
预期:
- ✅ 所有原有过滤条件正常工作
```
#### TC-3.2: Token 刷新不影响订阅时间
```yaml
前提条件:
- subscriptionExpiresAt = "2026-01-01"
- OAuth token 即将过期
步骤:
1. 触发 token 自动刷新
2. 检查 subscriptionExpiresAt
预期:
- ✅ expiresAt (OAuth) 被更新
- ✅ subscriptionExpiresAt 保持不变
```
#### TC-3.3: 字段独立性
```yaml
步骤:
1. 通过 Web 界面更新订阅过期时间
预期:
- ✅ subscriptionExpiresAt 更新
- ✅ expiresAt (OAuth) 不变
```
---
### 4. 集成测试
#### TC-4.1: 多平台混合场景
```yaml
场景:
- 3 个 Claude: 1 过期, 2 正常
- 2 个 Gemini: 1 过期, 1 正常
- 2 个 OpenAI: 全部过期
步骤:
1. 调用 10 次 Claude API
2. 调用 10 次 Gemini API
3. 调用 1 次 OpenAI API
预期:
- ✅ Claude 分配到 2 个正常账号
- ✅ Gemini 分配到 1 个正常账号
- ✅ OpenAI 返回错误
```
#### TC-4.2: Web 界面端到端
```yaml
步骤:
1. 登录 Web 管理界面
2. 编辑账号过期时间为昨天
3. 保存并刷新
4. 调用 API 验证
预期:
- ✅ 过期时间成功保存
- ✅ 界面正确显示
- ✅ API 调用时账号被跳过
```
---
### 5. 性能测试
#### TC-5.1: 大量过期账号性能
```yaml
场景:
- 100 个账号95 个过期5 个正常
步骤:
1. 并发 100 次 API 调用
2. 测量响应时间
预期:
- ✅ 过期检查耗时 <5ms
- ✅ 无性能告警
```
---
## 📊 测试覆盖率目标
| 测试类型 | 目标覆盖率 | 优先级 |
|---------|-----------|--------|
| 功能测试 | 100% | P0 |
| 边界测试 | 100% | P0 |
| 回归测试 | 100% | P0 |
| 集成测试 | 80% | P1 |
| 性能测试 | - | P1 |
---
## 🎯 修复优先级
### P0 - 必须修复(阻塞发布)
1.**Gemini** - geminiAccountService.js:285
2.**OpenAI** - openaiAccountService.js:917
3.**Droid** - droidAccountService.js:914
### P1 - 高优先级(建议修复)
4. ⚠️ **确认其他 5 平台** - CCR, Claude Console, Bedrock, Azure OpenAI, OpenAI-Responses
5. 📝 **统一日志格式**
### P2 - 中优先级(可选)
6. 🔔 **WebHook 通知** - 账号即将过期提醒
7. 🎨 **前端视觉提示** - 高亮即将过期账号
---
## 📝 修复检查清单
完成修复后,请逐项确认:
### 代码修改
- [ ] Gemini 添加 `isSubscriptionExpired()` 函数
- [ ] Gemini 在 `selectAvailableAccount()` 中调用检查
- [ ] OpenAI 添加 `isSubscriptionExpired()` 函数
- [ ] OpenAI 在 `selectAvailableAccount()` 中调用检查
- [ ] Droid 添加 `_isSubscriptionExpired()` 函数
- [ ] Droid 在 `getSchedulableAccounts()` 中调用检查
- [ ] 所有检查都记录调试日志
### 代码质量
- [ ] ESLint 检查通过
- [ ] Prettier 格式化完成
- [ ] 添加必要注释
- [ ] 函数命名符合规范
### 测试验证
- [ ] 通过所有功能测试TC-1.1 ~ 1.4
- [ ] 通过所有边界测试TC-2.1 ~ 2.3
- [ ] 通过所有回归测试TC-3.1 ~ 3.3
- [ ] 通过集成测试TC-4.1 ~ 4.2
- [ ] 性能测试无退化TC-5.1
### 文档更新
- [ ] 更新 CLAUDE.md
- [ ] 记录 commit 信息
- [ ] 更新本报告状态
---
## 🚀 发布建议
### ⛔ 不建议当前版本发布
**原因**:
1. **功能不完整**: 核心调度逻辑缺失,大部分平台功能无效
2. **用户体验差**: 用户设置过期时间但实际不生效
3. **潜在风险**: 过期账号继续使用可能导致 API 失败
### ✅ 发布前必须完成
1. 修复 P0 级别的 3 个缺陷
2. 通过所有 P0 测试用例
3. 进行充分回归测试
### 📅 推荐发布流程
| 阶段 | 任务 | 工期 |
|-----|------|------|
| 阶段 1 | 修复代码 + 单元测试 | 1-2 天 |
| 阶段 2 | 集成测试 + 回归测试 | 1 天 |
| 阶段 3 | 测试环境验证 | 1-2 天 |
| 阶段 4 | 生产环境部署 | 0.5 天 |
**预计完成**: 3-5 个工作日
---
## 📞 评审信息
- **评审人员**: Claude Code
- **评审日期**: 2025-10-12
- **项目名称**: Claude Relay Service
- **功能名称**: 账号订阅过期时间管理
---
## 附录 A: 相关文件清单
### 前端
- `web/admin-spa/src/views/AccountsView.vue`
### 后端路由
- `src/routes/admin.js`
### 后端服务
- `src/services/claudeAccountService.js` ✅ (参考实现)
- `src/services/geminiAccountService.js` ❌ 需修复
- `src/services/openaiAccountService.js` ❌ 需修复
- `src/services/droidAccountService.js` ❌ 需修复
- `src/services/claudeConsoleAccountService.js`
- `src/services/bedrockAccountService.js`
- `src/services/ccrAccountService.js`
- `src/services/azureOpenaiAccountService.js`
- `src/services/openaiResponsesAccountService.js`
---
## 附录 B: 技术债务
发现以下技术债务(不影响本次功能):
1. **缺少单元测试**: 所有 AccountService 缺少测试
2. **代码重复**: 9 个服务逻辑高度相似
3. **日志不统一**: 不同服务日志格式差异大
4. **错误处理简单**: 部分服务错误处理不完善
**建议**: 后续迭代逐步改进
---
**报告结束**