Files
claude-relay-service/account_expire_bugfix.md
litongtongxue 1e7465e533 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>
2025-10-14 02:42:03 +00:00

685 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 账号过期功能代码评审报告
**评审日期**: 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. **错误处理简单**: 部分服务错误处理不完善
**建议**: 后续迭代逐步改进
---
**报告结束**