完成账户订阅到期时间功能的核心调度逻辑实现。 ## 实现范围 ✅ 已添加订阅过期检查的服务(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>
16 KiB
账号过期功能代码评审报告
评审日期: 2025-10-12 评审范围: 账号订阅过期时间管理功能 代码状态: 部分完成,存在严重缺陷 评审结论: ❌ 不建议合并,需要修复核心缺陷
📋 执行摘要
本次功能开发实现了为所有 9 个账户平台添加订阅过期时间管理功能。在数据存储和前端展示层面,实现完整且准确。但在核心调度逻辑层存在严重缺陷:
✅ 已完成:
- 前端路由层:所有平台支持过期时间编辑
- 后端数据层:所有服务完整存储
subscriptionExpiresAt字段 - 字段映射层:路由层正确处理
expiresAt→subscriptionExpiresAt映射
❌ 严重缺陷:
- 调度逻辑缺失:除 Claude 外的所有平台(Gemini、OpenAI、Droid 等)未在账号选择时检查订阅过期时间,导致过期账号仍会被正常调度使用
影响评估: 该缺陷导致功能对大部分平台实际无效,用户设置的过期时间不会生效。
✅ 已完成部分(质量优秀)
1. 前端路由修复 ⭐⭐⭐⭐⭐
文件: web/admin-spa/src/views/AccountsView.vue
位置: 第 3730-3790 行
实现内容:
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 端点
统一实现:
// 所有路由统一添加字段映射逻辑
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)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || '',
查询层 (getAllAccounts)
// 映射给前端
expiresAt: accountData.subscriptionExpiresAt || null,
更新层 (updateAccount)
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做调整
}
字段独立性:
expiresAt- OAuth Token 过期时间(技术字段,自动刷新)subscriptionExpiresAt- 账户订阅到期时间(业务字段,手动管理)- 两字段完全独立,Token 刷新不会覆盖订阅过期时间 ✅
❌ 严重缺陷(阻塞发布)
核心问题:调度逻辑缺失订阅过期时间检查
严重性: 🔴 P0 - 阻塞发布 影响范围: Gemini、OpenAI、Droid、及其他 6 个平台(除 Claude 外所有平台) 影响: 过期账号仍会被正常调度,导致功能实际无效
缺陷 1: Gemini 账号 ❌
文件: src/services/geminiAccountService.js
位置: 第 285-290 行
方法: selectAvailableAccount()
问题代码:
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account) // ❌ 未检查 subscriptionExpiresAt
}
}
修复方案:
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()
问题代码:
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()
问题代码:
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
}
// ...
})
修复方案:
_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 行
正确实现:
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: 添加辅助函数
在各服务中添加:
/**
* 检查账户订阅是否过期
* @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:
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:
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 账号过期检查
前提条件:
- 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 账号过期检查
前提条件:
- 2 个 OpenAI 共享账号
- Account C: subscriptionExpiresAt = "2025-01-01" (未过期)
- Account D: subscriptionExpiresAt = "2024-01-01" (已过期)
步骤:
1. 调用 OpenAI API
2. 检查日志
预期:
- ✅ 只有 Account C 被选中
- ✅ 请求正常完成
TC-1.3: Droid 账号过期检查
前提条件:
- 2 个 Droid 共享账号
- Account E: subscriptionExpiresAt = "2025-01-01" (未过期)
- Account F: subscriptionExpiresAt = "2024-01-01" (已过期)
步骤:
1. 调用 Droid API
2. 检查日志
预期:
- ✅ 只有 Account E 被选中
TC-1.4: 全部过期无可用账号
前提条件:
- 某平台所有账号都已过期
步骤:
1. 调用该平台 API
预期:
- ✅ 返回错误: "No available {platform} accounts"
- ✅ 日志显示所有账号因过期被跳过
2. 边界测试
TC-2.1: 未设置过期时间
前提条件:
- subscriptionExpiresAt = null 或 ""
步骤:
1. 调用 API
预期:
- ✅ 账号正常被选中(永不过期)
TC-2.2: 过期时间边界
前提条件:
- 当前时间: 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: 无效日期格式
前提条件:
- subscriptionExpiresAt = "invalid-date"
步骤:
1. 调用 API
预期:
- ✅ new Date() 返回 Invalid Date
- ✅ 比较结果为 false,账号视为未过期(容错)
3. 回归测试
TC-3.1: 现有条件不受影响
验证点:
- isActive = 'false' 仍被跳过
- schedulable = 'false' 仍被跳过
- status = 'error' 仍被跳过
- 限流账号仍被跳过
预期:
- ✅ 所有原有过滤条件正常工作
TC-3.2: Token 刷新不影响订阅时间
前提条件:
- subscriptionExpiresAt = "2026-01-01"
- OAuth token 即将过期
步骤:
1. 触发 token 自动刷新
2. 检查 subscriptionExpiresAt
预期:
- ✅ expiresAt (OAuth) 被更新
- ✅ subscriptionExpiresAt 保持不变
TC-3.3: 字段独立性
步骤:
1. 通过 Web 界面更新订阅过期时间
预期:
- ✅ subscriptionExpiresAt 更新
- ✅ expiresAt (OAuth) 不变
4. 集成测试
TC-4.1: 多平台混合场景
场景:
- 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 界面端到端
步骤:
1. 登录 Web 管理界面
2. 编辑账号过期时间为昨天
3. 保存并刷新
4. 调用 API 验证
预期:
- ✅ 过期时间成功保存
- ✅ 界面正确显示
- ✅ API 调用时账号被跳过
5. 性能测试
TC-5.1: 大量过期账号性能
场景:
- 100 个账号,95 个过期,5 个正常
步骤:
1. 并发 100 次 API 调用
2. 测量响应时间
预期:
- ✅ 过期检查耗时 <5ms
- ✅ 无性能告警
📊 测试覆盖率目标
| 测试类型 | 目标覆盖率 | 优先级 |
|---|---|---|
| 功能测试 | 100% | P0 |
| 边界测试 | 100% | P0 |
| 回归测试 | 100% | P0 |
| 集成测试 | 80% | P1 |
| 性能测试 | - | P1 |
🎯 修复优先级
P0 - 必须修复(阻塞发布)
- ❌ Gemini - geminiAccountService.js:285
- ❌ OpenAI - openaiAccountService.js:917
- ❌ Droid - droidAccountService.js:914
P1 - 高优先级(建议修复)
- ⚠️ 确认其他 5 平台 - CCR, Claude Console, Bedrock, Azure OpenAI, OpenAI-Responses
- 📝 统一日志格式
P2 - 中优先级(可选)
- 🔔 WebHook 通知 - 账号即将过期提醒
- 🎨 前端视觉提示 - 高亮即将过期账号
📝 修复检查清单
完成修复后,请逐项确认:
代码修改
- 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 信息
- 更新本报告状态
🚀 发布建议
⛔ 不建议当前版本发布
原因:
- 功能不完整: 核心调度逻辑缺失,大部分平台功能无效
- 用户体验差: 用户设置过期时间但实际不生效
- 潜在风险: 过期账号继续使用可能导致 API 失败
✅ 发布前必须完成
- 修复 P0 级别的 3 个缺陷
- 通过所有 P0 测试用例
- 进行充分回归测试
📅 推荐发布流程
| 阶段 | 任务 | 工期 |
|---|---|---|
| 阶段 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: 技术债务
发现以下技术债务(不影响本次功能):
- 缺少单元测试: 所有 AccountService 缺少测试
- 代码重复: 9 个服务逻辑高度相似
- 日志不统一: 不同服务日志格式差异大
- 错误处理简单: 部分服务错误处理不完善
建议: 后续迭代逐步改进
报告结束