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

16 KiB
Raw Blame History

账号过期功能代码评审报告

评审日期: 2025-10-12 评审范围: 账号订阅过期时间管理功能 代码状态: 部分完成,存在严重缺陷 评审结论: 不建议合并,需要修复核心缺陷


📋 执行摘要

本次功能开发实现了为所有 9 个账户平台添加订阅过期时间管理功能。在数据存储和前端展示层面,实现完整且准确。但在核心调度逻辑层存在严重缺陷

已完成:

  • 前端路由层:所有平台支持过期时间编辑
  • 后端数据层:所有服务完整存储 subscriptionExpiresAt 字段
  • 字段映射层:路由层正确处理 expiresAtsubscriptionExpiresAt 映射

严重缺陷:

  • 调度逻辑缺失:除 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 - 必须修复(阻塞发布)

  1. Gemini - geminiAccountService.js:285
  2. OpenAI - openaiAccountService.js:917
  3. Droid - droidAccountService.js:914

P1 - 高优先级(建议修复)

  1. ⚠️ 确认其他 5 平台 - CCR, Claude Console, Bedrock, Azure OpenAI, OpenAI-Responses
  2. 📝 统一日志格式

P2 - 中优先级(可选)

  1. 🔔 WebHook 通知 - 账号即将过期提醒
  2. 🎨 前端视觉提示 - 高亮即将过期账号

📝 修复检查清单

完成修复后,请逐项确认:

代码修改

  • 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. 错误处理简单: 部分服务错误处理不完善

建议: 后续迭代逐步改进


报告结束