Compare commits

...

176 Commits

Author SHA1 Message Date
github-actions[bot]
2180c42b84 chore: sync VERSION file with release v1.1.263 [skip ci] 2026-01-22 13:57:22 +00:00
Wesley Liddick
0883bb6b39 Merge pull request #859 from SunSeekerX/feat/optimize
feat: 大规模性能优化 - Redis Pipeline 批量操作、索引系统、连接池优化
2026-01-22 21:57:07 +08:00
SunSeekerX
ea6d1f1b36 1 2026-01-22 17:07:52 +08:00
SunSeekerX
4367fa47da 1 2026-01-22 17:02:00 +08:00
root
55c876fad5 fix: unify weekly cost key to usage:opus:*
- redis.getWeeklyOpusCost: read only usage:opus:weekly:* (remove claude fallback)
- weeklyClaudeCostInitService: write to usage:opus:weekly:* instead of claude

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:09:01 +08:00
root
f9df276d0c merge: resolve conflicts from main branch
- auth.js: keep 402 status code with Opus message
- redis.js: keep dual-cost tracking (rated/real) with opus key prefix, add setWeeklyOpusCost method
- apiKeyService.js: keep both imports, serviceRates handling, and 5-param recordOpusCost

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:27:52 +08:00
github-actions[bot]
9ebef1b116 chore: sync VERSION file with release v1.1.262 [skip ci] 2026-01-22 07:18:31 +00:00
Wesley Liddick
35f755246e Merge pull request #914 from sczheng189/main
mod: 修改opus周限额为Claude模型的周限额
2026-01-22 15:18:16 +08:00
root
83cbaf7c3e fix: resolve all ESLint errors
- droidRelayService: add missing keyId variable declaration
- quotaCardService: use object destructuring for actualDeducted
- apiKeyService: remove unused variables and duplicate requires
- redis: remove shadowed logger/config requires
- unifiedGeminiScheduler: rename isActive param to avoid shadow
- commonHelper: add comments to empty catch blocks
- testPayloadHelper: prefix unused model param with underscore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:14:22 +08:00
github-actions[bot]
338d44faee chore: sync VERSION file with release v1.1.261 [skip ci] 2026-01-22 07:08:02 +00:00
shaw
968398ffa5 fix: API Key permissions multi-select save and display issue
- Fix updateApiKey to use JSON.stringify for permissions field
- Add comma-separated string handling in normalizePermissions
- Add frontend parsing for comma-separated permissions format

Fixes issue where selecting multiple permissions (e.g. Claude + OpenAI)
would be saved as "claude,openai" instead of '["claude","openai"]'
2026-01-22 15:07:19 +08:00
shaw
645ab43675 chore: sync latest Claude Code system prompt definitions
Add claudeOtherSystemPrompt5 for CLI billing header detection
2026-01-22 15:07:10 +08:00
root
24f825f60d style: format all files with prettier
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:05:58 +08:00
root
ac7d28f9ce style: format quotaCardService.js with prettier
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:04:55 +08:00
sczheng
1027a2e3e2 mod: 修改opus周限额为Claude模型的周限额 2026-01-22 15:04:34 +08:00
SunSeekerX
cb935ea0f0 1 2026-01-22 14:56:09 +08:00
SunSeekerX
73a241df1a Merge branch 'main' into feat/optimize 2026-01-21 11:56:02 +08:00
SunSeekerX
029bdf3719 1 2026-01-21 11:55:28 +08:00
github-actions[bot]
0f5321b0ef chore: sync VERSION file with release v1.1.260 [skip ci] 2026-01-21 02:19:34 +00:00
shaw
c7d7bf47d6 fix: 更新claude账号oauth链接生成规则 2026-01-21 10:06:24 +08:00
Wesley Liddick
ebc30b6026 Merge pull request #906 from 0xRichardH/fix-bedrock-sse-stream-event [skip ci]
Fix bedrock sse stream event
2026-01-21 09:38:19 +08:00
Wesley Liddick
d5a7af2d7d Merge pull request #903 from RedwindA/main [skip ci]
feat(droid): add prompt_cache_retention and safety_identifier to fiel…
2026-01-21 09:37:19 +08:00
SunSeekerX
76ecbe18a5 1 2026-01-19 20:24:47 +08:00
Richard Hao
81a3e26e27 fix: correct Bedrock SSE stream event format to match Claude API spec
- message_start: nest fields inside 'message' object with type: 'message'
- content_block_delta: add type field to data
- message_delta: add type field to data
- message_stop: remove usage field, just return type
- Extract usage from message_delta instead of message_stop
2026-01-18 11:38:38 +08:00
Richard Hao
64db4a270d fix: handle bedrock content block start/stop events 2026-01-18 10:58:11 +08:00
RedwindA
ca027ecb90 feat(droid): add prompt_cache_retention and safety_identifier to fieldsToRemove 2026-01-16 04:22:05 +08:00
github-actions[bot]
21e6944abb chore: sync VERSION file with release v1.1.259 [skip ci] 2026-01-15 03:07:53 +00:00
Wesley Liddick
4ea3d4830f Merge pull request #858 from zengqinglei/feature/gemini-retrieve-user-quota
feat: 添加 Gemini retrieveUserQuota 接口支持
2026-01-15 11:07:41 +08:00
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +08:00
github-actions[bot]
35ab34d687 chore: sync VERSION file with release v1.1.257 [skip ci] 2026-01-14 07:41:16 +00:00
Wesley Liddick
bc4b050c69 Merge pull request #895 from wayfind/fix/memory-simple
fix(memory): reduce memory retention in request handling
2026-01-14 15:40:59 +08:00
root
189d53d793 style: fix ESLint prefer-const and formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:08 +00:00
root
b148537428 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:42:39 +00:00
root
9d1a451027 fix(memory): comprehensive req closure capture fixes
Additional fixes for memory leaks:
- Bedrock stream: extract _apiKeyIdBedrock, _rateLimitInfoBedrock, _requestBodyBedrock
- Non-stream requests: extract variables at block start
- Non-stream service calls: use extracted variables
- Non-stream usage recording: use extracted variables

All async callbacks now use local variables instead of req.* references,
preventing the entire request object (including large req.body with images)
from being retained by closures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:29:29 +00:00
root
ba815de08f fix(memory): extract req properties to avoid closure capturing entire request object
Problem:
- usageCallback closures referenced req.apiKey.id and req.rateLimitInfo
- This caused entire req object (including req.body with images) to be retained
- Base64 images in messages accumulated in memory (290 images = 26MB)

Solution:
- Extract needed properties before callback: _apiKeyId, _rateLimitInfo, etc.
- Closures now capture small local variables instead of entire req object
- Enables proper GC of request bodies after stream completion

Results verified via heapdump analysis:
- String memory: 144MB -> 24MB (-83%)
- Base64 images: 290 -> 0 (-100%)
- Heapdump size: 57MB -> 28MB (-51%)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:20 +00:00
root
b26027731e fix(memory): clear bodyString after req.write() to prevent closure capture
Additional memory optimizations:
- Set bodyString = null after req.write() in both stream and non-stream requests
- Use let instead of const for bodyString to allow nullifying
- Store non-stream originalBodyString in bodyStore to avoid closure capture
- Clean up bodyStore in finally block for non-stream requests

This prevents V8 closures (res.on handlers) from retaining large request
body strings until stream completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:57:54 +00:00
root
f535b35a1c fix(memory): use bodyStore to avoid closure capturing request body
Problem:
- Stream response handlers (res.on) captured requestOptions in closures
- requestOptions contained originalBodyString (~800KB per request)
- These strings couldn't be GC'd until stream completed
- With concurrent requests, memory accumulated rapidly

Solution:
- Store request body strings in this.bodyStore Map with unique ID
- Pass only bodyStoreId in requestOptions (not the 800KB string)
- Closures capture small ID, not large string
- Clean up bodyStore on request completion (success/error/timeout)
- Extract needed values before closures to avoid capturing body object
2026-01-12 08:31:47 +00:00
github-actions[bot]
962e01b080 chore: sync VERSION file with release v1.1.256 [skip ci] 2026-01-10 08:56:29 +00:00
Wesley Liddick
fcc6ac4e22 Merge pull request #885 from junejuneli/feat/add_claude_aws_type
为 AWS Bedrock 账户添加 Bearer Token 认证支持
2026-01-10 16:56:13 +08:00
Wesley Liddick
3a03147ac9 Merge pull request #884 from moonsphere/fix_claude_auth [skip ci]
fix: claude subscription detection
2026-01-10 16:55:59 +08:00
juenjunli
94f239b56a feat: 添加 Claude 4.5 系列模型支持到 AWS Bedrock 映射表
为 AWS Bedrock 服务添加 Claude 4.5 系列模型的映射支持,包括:
- Claude 4.5 Opus (claude-opus-4-5)
- Claude 4.5 Sonnet (claude-sonnet-4-5)
- Claude 4.5 Haiku (claude-haiku-4-5)

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

Co-Authored-By: Claude Haiku 3.5 <noreply@anthropic.com>
2026-01-10 16:04:58 +08:00
juenjunli
b07873772c fix 2026-01-10 14:19:59 +08:00
juenjunli
549c95eb80 feat: 为 AWS Bedrock 账户添加 Bearer Token 认证支持
- 新增 credentialType 字段支持 access_key 和 bearer_token 两种认证方式
- 实现 Bedrock 账户的 testAccountConnection 方法,支持 SSE 流式测试
- 前端账户表单增加认证类型选择器,自动切换输入字段
- 前端测试模态框根据账户类型自动选择测试模型(Bearer Token 使用 Sonnet 4.5,Access Key 使用 Haiku)
- 改进测试接口错误处理,避免响应流重复关闭

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 14:13:36 +08:00
jett
b397954ea4 fix lint 2026-01-10 11:41:35 +08:00
jett
ed835d0c28 using TTL-based random suffix 2026-01-10 00:56:21 +08:00
jett
28b27e6a7b fix: claude subscription detection 2026-01-10 00:31:17 +08:00
github-actions[bot]
810fe9fe90 chore: sync VERSION file with release v1.1.255 [skip ci] 2026-01-09 07:55:15 +00:00
Wesley Liddick
141b07db78 Merge pull request #880 from DaydreamCoding/feature/fix-claude-openai-endpoint
fix: 修正Claude通过openaiClaudeRoutes访问失败问题
2026-01-09 15:55:01 +08:00
QTom
1dad810d15 refactor: 统一权限检查逻辑,使用 apiKeyService.hasPermission
将散布在各处的权限检查逻辑(permissions || 'all')统一为
apiKeyService.hasPermission() 方法调用,确保:

- 权限检查的唯一真实来源
- 避免默认值不一致导致的安全问题
- 便于后续权限模型的扩展和维护

影响文件:
- geminiHandlers.js: key-info 端点
- apiStats.js: user-stats 统计端点
- openaiClaudeRoutes.js: 权限校验辅助函数
- openaiRoutes.js: key-info 端点
2026-01-09 14:36:31 +08:00
QTom
4723328be4 fix: 添加 OpenAI 兼容路由对 Claude Console 账户的支持 2026-01-09 14:31:06 +08:00
曾庆雷
944ef096b3 fix: eslint 代码风格优化 2026-01-08 18:26:45 +08:00
github-actions[bot]
114e9facee chore: sync VERSION file with release v1.1.254 [skip ci] 2026-01-08 04:08:28 +00:00
shaw
e20ce86ad4 feat: Antigravity 账号注入 systemInstruction 和 requestType header
- 在 antigravityClient.js 的请求 header 中添加 requestType: agent
- 在 anthropicGeminiBridgeService.js 中为 antigravity 账号前置注入系统提示词
2026-01-08 12:07:50 +08:00
shaw
6caabb5444 update readme 2026-01-08 08:58:16 +08:00
shaw
b924c3c559 update readme 2026-01-08 08:33:09 +08:00
QTom
6682e0a982 fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
防止非等待等待重置的账号刷新,导致大量错误消息通知问题
2026-01-08 00:05:47 +08:00
github-actions[bot]
b9c088ce58 chore: sync VERSION file with release v1.1.253 [skip ci] 2026-01-07 14:12:03 +00:00
shaw
2ff74c21d2 Merge branch 'antigravity' 2026-01-07 21:55:15 +08:00
shaw
8a4dadbbc0 fix(security): 修复余额脚本功能的RCE和SSRF漏洞
- 将 BALANCE_SCRIPT_ENABLED 默认值改为 false,需显式启用
- 添加 isUrlSafe() SSRF防护,禁止访问:
  - localhost/127.x
  - 私有IP (10.x, 172.16-31.x, 192.168.x)
  - AWS metadata (169.254.x)
  - 非HTTP(S)协议
2026-01-07 21:55:08 +08:00
shaw
adf2890f65 fix: 去除context_management会导致压缩失败还原逻辑 [skip ci] 2026-01-07 21:24:39 +08:00
Wesley Liddick
7d892a69f1 Merge pull request #873 from DaydreamCoding/patch-5 [skip ci]
fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 08:09:15 -05:00
QTom
a749ddfede fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 20:57:49 +08:00
Wesley Liddick
dbd4fb19cf Merge branch 'main' into antigravity 2026-01-07 03:49:14 -05:00
github-actions[bot]
39ba345a43 chore: sync VERSION file with release v1.1.252 [skip ci] 2026-01-07 08:22:01 +00:00
shaw
2693fd77b7 fix: 移除context_management字段,避免报错 2026-01-07 16:21:41 +08:00
52227
3cc3219a90 docs: 更新中英文 README,完善模型配额查询与 Claude Code 适配说明
- 新增 Antigravity 账户额度与模型列表查询指南
- 完善 Claude Code 兼容性特性说明 (Thinking Signature, Zombie Stream Watchdog)
- 移除无关广告信息,优化文档结构
- 明确二开维护者信息
2026-01-05 23:07:04 +08:00
52227
1b834ffcdb feat: 增强稳定性与Antigravity适配 (僵尸流看门狗/自动重试/签名缓存)
主要变更:
1. **僵尸流看门狗 (Zombie Stream Watchdog)**:
   - 新增 resetActivityTimeout 机制,45秒无数据强制断开连接,防止服务假死。

2. **智能重试机制**:
   - 针对 Antigravity 429 (Resource Exhausted) 错误,自动清理会话并切换账号重试。
   - 涵盖流式 (Stream) 和非流式 (Non-stream) 请求。

3. **Thought Signature 增强**:
   - 新增签名缓存与恢复机制 (signatureCache)。
   - 增加 skip_thought_signature_validator 兜底签名策略。
   - 强制补充 thought: true 标记以满足上游校验。

4. **系统稳定性与调试**:
   - 使用 util.inspect 替代 JSON.stringify 打印错误日志,彻底修复循环引用导致的服务崩溃。
   - 新增针对 Antigravity 参数错误 (400) 的详细请求结构分析日志。
   - 优化日志写入为轮转模式 (safeRotatingAppend)。

5. **其他优化**:
   - antigravityClient 数据处理安全增强 (safeDataToString)。
2026-01-05 09:37:39 +08:00
SunSeekerX
12fd5e1cb4 Merge branch 'main' into feat/optimize 2026-01-04 12:06:29 +08:00
SunSeekerX
f5e982632d chore 2026-01-04 12:05:53 +08:00
SunSeekerX
90023d1551 Revert: 撤销 584fa8c 之后的所有提交 2026-01-03 23:24:59 +08:00
SunSeekerX
74e71d0afc chore 2026-01-03 23:20:05 +08:00
52227
41999f56b4 feat: 适配 Antigravity 账户余额查询与流式响应优化
1. Antigravity 账户适配:
   - 新增 GeminiBalanceProvider,支持 Antigravity 账户的额度查询(API 模式)
   - AccountBalanceService 增加 queryMode 逻辑与安全限制
   - 前端 BalanceDisplay 适配 Antigravity 配额显示

2. 流式响应增强:
   - 优化 thoughtSignature 捕获与回填,支持思维链透传
   - 修复工具调用签名校验

3. 其他:
   - 请求体大小限制提升至 100MB
   - .gitignore 更新
2026-01-03 10:15:13 +08:00
52227
b81c2b946f feat: 增强 Gemini 桥接处理并添加 Antigravity 响应转储工具 2026-01-01 15:24:12 +08:00
github-actions[bot]
0a59a0f9d4 chore: sync VERSION file with release v1.1.251 [skip ci] 2026-01-01 05:57:53 +00:00
SunSeekerX
d8a33f9aa7 chore 2026-01-01 12:42:47 +08:00
SunSeekerX
666b0120b7 chore 2025-12-31 02:28:51 +08:00
SunSeekerX
fba18000e5 chore 2025-12-31 02:24:27 +08:00
SunSeekerX
b4233033a6 chore 2025-12-31 02:17:10 +08:00
SunSeekerX
584fa8c9c1 feat: 大规模性能优化 - Redis Pipeline 批量操作、索引系统、连接池优化 2025-12-31 02:08:47 +08:00
Chapoly1305
c4448db6ab fix: 防止客户端断开连接时服务崩溃
当客户端在流式响应过程中断开连接时,catch 块尝试发送 JSON 错误响应
会触发 ERR_HTTP_HEADERS_SENT 错误,导致 unhandledRejection 使服务崩溃。

修复文件:
- src/routes/openaiClaudeRoutes.js
- src/routes/openaiGeminiRoutes.js

修复内容:
- 添加 res.headersSent 检查,避免在响应已发送后再次尝试发送
- 客户端断开连接使用 INFO 级别日志(不是 ERROR)
- 客户端断开使用 499 状态码 (Client Closed Request)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 01:18:06 -05:00
52227
c67d2bce9d feat: 完善 Antigravity OAuth 功能与权限校验
新增功能:
- 实现 Antigravity OAuth 账户支持与路径分流
- 支持 /antigravity/api 路径自动分流到 Antigravity OAuth 账户
- 支持 gemini-antigravity 平台类型的账户创建和管理

修复问题:
- 修复 OAuthFlow 组件中 gemini-antigravity 平台授权页面空白的问题
- 修复 EditApiKeyModal 中 Redis 返回字符串格式 permissions 导致的 400 错误
- 统一使用 hasPermission 函数进行权限校验,支持数组格式

优化改进:
- 添加 Antigravity 调试环境变量说明
2025-12-29 14:23:43 +08:00
github-actions[bot]
a345812cd7 chore: sync VERSION file with release v1.1.250 [skip ci] 2025-12-29 05:46:39 +00:00
shaw
a0cbafd759 Merge branch 'fix-authenticateUserOrAdmin-bypass' 2025-12-29 13:45:44 +08:00
Wesley Liddick
3c64038fa7 Create SECURITY.md for security policy [skip ci]
Add a security policy document outlining supported versions and vulnerability reporting.
2025-12-29 13:37:15 +08:00
Junming Chen
45b81bd478 fix: 修复 authenticateUserOrAdmin 认证绕过漏洞
- 添加 username 和 loginTime 字段验证(与 authenticateAdmin 保持一致)
- 无效/伪造会话自动删除并记录安全日志
- 删除未使用的 id 字段(死代码清理)

漏洞详情:
- 位置:src/middleware/auth.js:1569-1581
- 原因:只检查 Object.keys(session).length > 0,未验证必须字段
- 影响:攻击者可通过注入最小会话 {foo:'bar'} 绕过认证

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 23:56:05 -05:00
github-actions[bot]
fc57133230 chore: sync VERSION file with release v1.1.249 [skip ci] 2025-12-26 11:26:14 +00:00
shaw
1f06af4a56 chore: trigger release [force release] 2025-12-26 19:25:53 +08:00
shaw
6165fad090 docs: 添加安全漏洞警告 2025-12-26 19:22:08 +08:00
shaw
d53a399d41 revert: 回退到安全漏洞修复版本 2025-12-26 19:15:50 +08:00
Wesley Liddick
3f98267738 Merge branch 'main' into antigravity 2025-12-26 00:56:27 -05:00
Wesley Liddick
e187b8946a Merge pull request #825 from atoz03/feat/account-quota [skip ci]
Feat:account quota
2025-12-26 00:53:33 -05:00
Wesley Liddick
8917019a78 Merge pull request #814 from Guccbai/feature/multi-select-permissions [skip ci]
feat(permissions): 服务权限从单选改为多选
2025-12-26 00:52:42 -05:00
52227
9960f237b8 feat: 实现 Antigravity OAuth 账户支持与路径分流 2025-12-25 14:33:24 +08:00
shaw
b6da77cabe docs: update readme 2025-12-25 14:27:23 +08:00
github-actions[bot]
e561387e81 chore: sync VERSION file with release v1.1.241 [skip ci] 2025-12-25 06:23:55 +00:00
shaw
982cca1020 fix: 修复鉴权检测的重大安全漏洞 2025-12-25 14:23:35 +08:00
github-actions[bot]
792ba51290 chore: sync VERSION file with release v1.1.240 [skip ci] 2025-12-25 02:46:09 +00:00
Wesley Liddick
74d138a2fb Merge pull request #842 from IanShaw027/feat/account-export-api
feat(admin): 添加账户导出同步 API
2025-12-24 21:45:55 -05:00
IanShaw027
b88698191e style(admin): fix ESLint curly rule violations in sync.js
为单行 if 语句添加花括号以符合 ESLint curly 规则要求
2025-12-24 17:57:30 -08:00
IanShaw027
11c38b23d1 style(admin): format sync.js with prettier
修复 CI 格式化检查失败问题
2025-12-24 17:52:51 -08:00
IanShaw027
b2dfc2eb25 feat(admin): 添加账户导出同步 API
- 新增 /api/accounts 端点,支持导出所有账户数据
- 新增 /api/proxies 端点,支持导出所有代理配置
- 支持 Sub2API 从 CRS 批量同步账户
- 包含完整的 credentials 和 extra 字段
- 提供账户类型标识 (oauth/setup_token/api_key)

相关 PR: Sub2API 端实现账户同步功能
2025-12-24 17:35:11 -08:00
曾庆雷
18a493e805 feat: 添加 Gemini retrieveUserQuota 接口支持
支持 Gemini CLI 0.22.2+ 的配额查询功能
实现与现有 v1internal 接口一致的 projectId 处理逻辑
2025-12-24 22:48:27 +08:00
github-actions[bot]
59ce0f091c chore: sync VERSION file with release v1.1.239 [skip ci] 2025-12-24 11:56:05 +00:00
shaw
67c20fa30e feat: 为 claude-official 账户添加 403 错误重试机制
针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时:
- 休息 2 秒后进行重试
- 最多重试 2 次(总共最多 3 次请求)
- 重试后仍是 403 才标记账户为 blocked

同时支持流式和非流式请求,并修复了流式请求中的竞态条件问题。
2025-12-24 19:54:25 +08:00
shaw
671451253f fix: 修复并发清理任务 WRONGTYPE 错误
问题:
- 并发清理定时任务在遇到非 zset 类型的遗留键时报 WRONGTYPE 错误
- 错误键如 concurrency:wait:*, concurrency:user:*, concurrency:account:* 等

修复:
- app.js: 使用原子 Lua 脚本先检查键类型再执行清理,消除竞态条件
- redis.js: 为 6 个并发管理函数添加类型检查
  - getAllConcurrencyStatus(): 跳过 queue 键 + 类型检查
  - getConcurrencyStatus(): 类型检查,非 zset 返回 invalidType
  - forceClearConcurrency(): 类型检查,任意类型都删除
  - forceClearAllConcurrency(): 跳过 queue 键 + 类型检查
  - cleanupExpiredConcurrency(): 跳过 queue 键 + 类型检查

- 遗留键会被自动识别并删除,同时记录日志
2025-12-24 17:51:19 +08:00
Guccbai
534fbf6ac2 fix(eslint): 修复 ESLint 检查错误
- 修复 apiKeyService.js 中 if 语句缺少大括号的 curly 错误
- 移除 openaiGeminiRoutes.js 中重复声明 apiKeyService 导致的 no-shadow 错误

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:26:18 +08:00
github-actions[bot]
0173ab224b chore: sync VERSION file with release v1.1.238 [skip ci] 2025-12-21 14:41:29 +00:00
shaw
11fb77c8bd chore: trigger release [force release] 2025-12-21 22:41:03 +08:00
shaw
3d67f0b124 chore: update readme 2025-12-21 22:37:13 +08:00
shaw
84f19b348b fix: 适配cc遥测端点 2025-12-21 22:29:36 +08:00
shaw
8ec8a59b07 feat: claude账号新增支持拦截预热请求 2025-12-21 22:28:22 +08:00
shaw
00d8ac4bec Merge branch 'main' into dev 2025-12-21 21:35:16 +08:00
atoz03
b6f3459522 修复 eslint 2025-12-20 01:40:41 +08:00
atoz03
e56d797d87 修复 tests/accountBalanceService.test.js 的 Prettier 格式问题 2025-12-20 01:35:30 +08:00
atoz03
4c6879a9c2 Prettier 格式化 2025-12-20 01:24:08 +08:00
atoz03
1c8084a3b1 fix(admin): 打开余额脚本弹窗时重置表单,避免跨账户残留配置
- 打开弹窗先重置表单字段(baseUrl/apiKey/extra 等),仅保留示例脚本\n- 若后端存在已保存配置,则加载后覆盖\n- 同步清理测试结果与 loading 状态,避免残留误导
2025-12-20 01:18:49 +08:00
atoz03
f6f4b5cfec feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复
- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
2025-12-20 01:18:49 +08:00
atoz03
26ca696b91 fix:修复了重复声明 redis 导致的启动报错,并保留余额脚本功能接入账户 2025-12-20 01:18:49 +08:00
atoz03
ce496ed9e6 feat:单账户配置余额脚本 + 刷新按钮即用脚本”,并去掉独立页面/标签。
具体改动

  - 后端
      - src/models/redis.js:新增脚本配置存取 account_balance_script:{platform}:{accountId}。
      - src/services/accountBalanceService.js:支持脚本查询。若账户有脚本配置且 queryApi=true,调用 balanceScriptService.execute 获取余额/配额,缓存后返回。
      - src/routes/admin/accountBalance.js:新增接口
          - GET /admin/accounts/:id/balance/script?platform=...
          - PUT /admin/accounts/:id/balance/script?platform=...
          - POST /admin/accounts/:id/balance/script/test?platform=...
  - 前端
      - 新增弹窗 AccountBalanceScriptModal,在账户管理页每个账户“余额/配额”下方有“配置余额脚本”按钮,支持填写 baseUrl/apiKey/token/extra/超时/自动间隔、编写脚本、测试、保存。
      - 将余额脚本独立路由/标签移除。
  - 格式/ lint 已通过(新组件及 AccountsView)。
2025-12-20 01:18:49 +08:00
atoz03
f6ed420401 feat(admin): 新增账户余额/配额查询与展示
- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用)
  - Redis 增加余额查询结果与本地统计缓存读写
  - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider
  - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示
  - 补充 accountBalanceService 单元测试
2025-12-20 01:15:33 +08:00
github-actions[bot]
5863816882 chore: sync VERSION file with release v1.1.237 [skip ci] 2025-12-19 14:30:21 +00:00
shaw
638d2ff189 feat: 支持claude单账户开启串行队列 2025-12-19 22:29:57 +08:00
github-actions[bot]
fa2fc2fb16 chore: sync VERSION file with release v1.1.236 [skip ci] 2025-12-19 07:50:25 +00:00
Wesley Liddick
6d56601550 Merge pull request #821 from guoyongchang/feat/cron-test-support
feat: Claude账户定时测试功能
2025-12-19 02:50:08 -05:00
guoyongchang
dd8a0c95c3 fix: use template literals instead of string concatenation
- Convert string concatenation to template literals per ESLint prefer-template rule
- Fixes ESLint errors in sessionKeyPrefix logging (lines 281, 330)

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-19 15:46:38 +08:00
guoyongchang
126eee3712 feat/cron-test-support format fix. 2025-12-19 14:59:47 +08:00
guoyongchang
26bfdd6892 [feat/cron-test-support]optimize. 2025-12-19 14:03:31 +08:00
guoyongchang
cd3f51e9e2 refactor: optimize cron test support feature
**优化内容:**

1. **验证和安全性加强**
   - 移除cron验证重复,统一使用accountTestSchedulerService.validateCronExpression()方法
   - 添加model参数类型和长度验证(max 256 chars)
   - 限制cronExpression长度至100字符防止DoS攻击
   - 双层验证:service层和route层都进行长度检查

2. **性能优化**
   - 优化_refreshAllTasks()使用Promise.all()并行加载所有平台配置(之前是顺序加载)
   - 改进错误处理,平台加载失败时继续处理其他平台

3. **数据管理改进**
   - 为test config添加1年TTL过期机制(之前没有过期设置)
   - 保证test history已有30天TTL和5条记录限制

4. **错误响应标准化**
   - 统一所有API响应格式,确保error状态都包含message字段
   - 改进错误消息的可读性和上下文信息

5. **用户体验改进**
   - Vue组件使用showToast()替代原生alert()
   - 移除console.error()改用toast通知用户
   - 成功保存时显示成功提示

6. **代码整理**
   - 移除未使用的maxConcurrentTests变量及其getStatus()中的引用
   - 保持代码整洁性

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-19 13:39:39 +08:00
guoyongchang
9977245d59 feat/cron-test-support package lock fix. 2025-12-19 13:32:16 +08:00
guoyongchang
09cf951cdc [feat/cron-test-support]done. 2025-12-19 10:25:43 +08:00
Guccbai
33ea26f2ac feat(permissions): 服务权限从单选改为多选
- 将 API Key 的服务权限从单选改为多选,支持同时选择多个服务
- 移除"全部服务"选项,空数组表示允许访问全部服务
- 后端自动兼容旧格式('all' -> [], 'claude' -> ['claude'])
- 前端 radio 改为 checkbox,更新账户选择器联动逻辑

修改文件:
- apiKeyService.js: 添加 normalizePermissions/hasPermission 函数
- api.js, droidRoutes.js, openaiRoutes.js, unified.js, openaiGeminiRoutes.js, geminiHandlers.js: 使用新权限验证函数
- admin/apiKeys.js: 支持数组格式权限验证
- CreateApiKeyModal.vue, EditApiKeyModal.vue: UI 改为 checkbox 多选

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:35:11 +08:00
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -05:00
Wesley Liddick
53cda0fd18 Merge pull request #806 from XiaoXice/main [skip ci]
fix: 全时间api-token统计因为日token记录过期导致不准的问题
2025-12-16 19:34:35 -05:00
Wesley Liddick
151cb7536c Merge pull request #808 from SilentFlower/fix/openai-scheduler-priority [skip ci]
fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt
2025-12-16 19:33:18 -05:00
sususu
0994eb346f format 2025-12-16 18:32:11 +08:00
sususu
4863a37328 feat: 添加 Claude Code 遥测端点并优化日志级别
- 添加 /api/event_logging/batch 端点处理客户端遥测请求
- 将遥测相关请求日志改为 debug 级别,减少日志噪音
2025-12-16 18:31:07 +08:00
huajiwuyan
052e236a93 fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt 2025-12-15 23:17:44 +08:00
XiaoXice
c79ea19aa1 fix: 全时间api-token统计因为日token记录过期导致不准的问题 2025-12-15 15:14:09 +08:00
github-actions[bot]
79f2cebdb8 chore: sync VERSION file with release v1.1.235 [skip ci] 2025-12-15 01:48:14 +00:00
Wesley Liddick
bd7b8884ab Merge pull request #801 from miraserver/fix/cost-calculation-and-ui-display
fix: correct API key cost calculation and UI display issues
2025-12-14 20:48:00 -05:00
github-actions[bot]
38e0adb499 chore: sync VERSION file with release v1.1.234 [skip ci] 2025-12-15 01:44:56 +00:00
shaw
7698f5ce11 chore: 增加opus4.5快捷映射按钮 2025-12-15 09:44:36 +08:00
shaw
ce13e5ddb1 fix: console账号转发使用白名单透传header 2025-12-15 09:38:51 +08:00
John Doe
baafebbf7b fix: correct API key cost calculation and UI display issues
- Fix admin panel cost display for "all time" period using permanent Redis key
- Fix user statistics total cost limit to show complete history
- Fix restricted models list overflow with scrollable container

Backend changes:
- src/routes/admin/apiKeys.js: Use allTimeCost for timeRange='all' instead of scanning TTL keys
- src/routes/apiStats.js: Prioritize permanent usage:cost:total key over monthly keys

Frontend changes:
- web/admin-spa/src/components/apistats/LimitConfig.vue: Add overflow-visible and scrolling to model list

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:02 +03:00
github-actions[bot]
87426133a2 chore: sync VERSION file with release v1.1.233 [skip ci] 2025-12-12 06:58:37 +00:00
Wesley Liddick
60f5cbe780 Merge pull request #800 from DaydreamCoding/feature/concurrency-queue
feat: enhance concurrency queue with health check and admin endpoints
2025-12-12 01:58:24 -05:00
Wesley Liddick
86d8ed52d7 Merge pull request #799 from kikii16/main [skip ci]
尝试自定义请求体maxSize大小
2025-12-12 01:58:12 -05:00
DaydreamCoding
07633ddbf8 feat: enhance concurrency queue with health check and admin endpoints
- Add queue health check for fast-fail when overloaded (P90 > threshold)
  - Implement socket identity verification with UUID token
  - Add wait time statistics (P50/P90/P99) and queue stats tracking
  - Add admin endpoints for queue stats and cleanup
  - Add CLEAR_CONCURRENCY_QUEUES_ON_STARTUP config option
  - Update documentation with troubleshooting and proxy config guide
2025-12-12 14:32:09 +08:00
kikii16
dd90c426e4 Update docker-compose.yml 2025-12-12 11:38:31 +08:00
kikii16
059357f834 Update .env.example 2025-12-12 11:36:01 +08:00
kikii16
ceee3a9295 Update auth.js 2025-12-12 11:34:46 +08:00
Wesley Liddick
403f609f69 Merge pull request #797 from thejoven/patch-1 [skip ci]
修改提醒内容:新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-11 21:10:07 -05:00
thejoven
304c8dda4e Update AccountForm.vue
新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-12 00:43:11 +08:00
github-actions[bot]
c4d923c46f chore: sync VERSION file with release v1.1.232 [skip ci] 2025-12-11 02:46:31 +00:00
Wesley Liddick
fa9f9146a2 Merge pull request #793 from qq790716890/main
fix:修复codex统计token问题
2025-12-10 21:46:15 -05:00
Wesley Liddick
dc9409a5a6 Merge pull request #788 from atoz03/main [skip ci]
fix: 账户列表默认显示限额/限流账号并加固加载健壮性
2025-12-10 21:44:55 -05:00
LZY
51aa8dc381 fix:修复codex统计token问题 2025-12-10 22:56:25 +08:00
github-actions[bot]
5061f4d9fd chore: sync VERSION file with release v1.1.231 [skip ci] 2025-12-10 12:11:39 +00:00
Wesley Liddick
4337af06d4 Merge pull request #791 from DaydreamCoding/feature/log-opt
fix: improve logging for client disconnections in relay services
2025-12-10 07:11:24 -05:00
Wesley Liddick
d226d57325 Merge pull request #790 from DaydreamCoding/patch-4 [skip ci]
fix(security): add authenticateAdmin middleware to concurrency routes
2025-12-10 07:11:07 -05:00
Wesley Liddick
9f92c58640 Merge pull request #789 from DaydreamCoding/feature/user-message-queue-optimize [skip ci]
feat(queue): 优化用户消息队列锁释放时机
2025-12-10 07:10:38 -05:00
QTom
8901994644 fix: improve logging for client disconnections in relay services
当客户端主动断开连接时,改为使用 INFO 级别记录而不是 ERROR 级别,
因为这是正常情况而非错误。

- ccrRelayService: 区分客户端断开与实际错误
- claudeConsoleRelayService: 区分客户端断开与实际错误
- claudeRelayService: 区分客户端断开与实际错误
- droidRelayService: 区分客户端断开与实际错误
2025-12-10 14:18:44 +08:00
QTom
e3ca555df7 fix(security): add authenticateAdmin middleware to concurrency routes
fix(security): add authenticateAdmin middleware to concurrency routes

All concurrency management endpoints were missing authentication,
allowing unauthenticated access to view and clear concurrency data.
2025-12-10 13:59:25 +08:00
QTom
3b9c96dff8 feat(queue): 优化用户消息队列锁释放时机
将队列锁释放时机从"请求完成后"提前到"请求发送后",因为 Claude API
限流(RPM)基于请求发送时刻计算,无需等待响应完成。

主要变更:
- 移除锁续租机制(startLockRenewal、refreshUserMessageLock)
- 所有 relay 服务在请求发送成功后立即释放锁
- 流式请求通过 onResponseStart 回调在收到响应头时释放
- 调整默认配置:timeoutMs 60s→5s,lockTtlMs 120s→5s
- 新增 USER_MESSAGE_QUEUE_LOCK_TTL_MS 环境变量支持
2025-12-10 01:26:00 +08:00
github-actions[bot]
cb94a4260e chore: sync VERSION file with release v1.1.230 [skip ci] 2025-12-09 10:59:05 +00:00
Wesley Liddick
ac9499aa6d Merge pull request #787 from DaydreamCoding/feature/user-message-queue-fix
feat: 修复 userMessageQueue 配置缺失导致的 500 错误
2025-12-09 05:58:35 -05:00
atoz03
fc25840f95 fix: 账户列表默认显示限额/限流账号并加固加载健壮性
- 将账户页状态筛选默认值从 normal 改为 all,额度满/限流/异常账号默认可见
  - appendAccounts 使用 Array.isArray 兜底接口响应,避免空/异常数据导致“加载账户失败”
  - 便于在额度耗尽场景查看并处理账号
2025-12-09 18:49:57 +08:00
QTom
b409adf9d8 feat: 修复 userMessageQueue 配置缺失导致的 500 错误
- 在 config.example.js 添加缺失的 userMessageQueue 配置段
  - 在 userMessageQueueService.js 添加防御性代码,当配置未定义时使用默认值

  修复 #783 合并后新用户安装报错:
  Cannot read properties of undefined (reading 'enabled')
2025-12-09 18:41:13 +08:00
github-actions[bot]
b76776d7b0 chore: sync VERSION file with release v1.1.229 [skip ci] 2025-12-09 09:49:01 +00:00
Wesley Liddick
8499992abd Merge pull request #783 from DaydreamCoding/feature/user-message-queue
feat: 添加用户手动输入的消息串行队列功能,防止同账户并发请求触发限流&封号
2025-12-09 04:48:49 -05:00
QTom
dc96447d72 style: 格式化文件以符合 Prettier 规范 2025-12-09 17:18:43 +08:00
QTom
f5d1c25295 feat: 添加用户消息串行队列功能,防止同账户并发请求触发限流
- 新增 userMessageQueueService.js 实现基于 Redis 的队列锁机制
- 在 claudeRelayService、claudeConsoleRelayService、bedrockRelayService、ccrRelayService 中集成队列锁
- 添加 Redis 原子性 Lua 脚本:acquireUserMessageLock、releaseUserMessageLock、refreshUserMessageLock
- 支持锁续租机制,防止长时间请求锁过期
- 添加可配置参数:USER_MESSAGE_QUEUE_ENABLED、USER_MESSAGE_QUEUE_DELAY_MS、USER_MESSAGE_QUEUE_TIMEOUT_MS
- 添加 Web 管理界面配置入口
- 添加 logger.performance 方法用于结构化性能日志
- 添加完整单元测试 (tests/userMessageQueue.test.js)
2025-12-09 17:04:01 +08:00
github-actions[bot]
95870883a1 chore: sync VERSION file with release v1.1.228 [skip ci] 2025-12-08 13:05:52 +00:00
shaw
aa71c58400 fix: 修复强制会话绑定首次会话的bug 2025-12-08 21:05:21 +08:00
238 changed files with 37723 additions and 9565 deletions

View File

@@ -33,6 +33,59 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🤖 Gemini OAuth / Antigravity 配置(可选)
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret可在此覆盖
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# Gemini CLI OAuth redirect_uri可选默认 https://codeassist.google.com/authcode
# GEMINI_OAUTH_REDIRECT_URI=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# Antigravity OAuth redirect_uri可选默认 http://localhost:45462用于避免 redirect_uri_mismatch
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
# Antigravity 上游地址(可选,默认 sandbox
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
# Antigravity User-Agent可选
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
# Claude CodeAnthropic Messages API路由分流无需额外环境变量
# - /api -> Claude 账号池(默认)
# - /antigravity/api -> Antigravity OAuth
# - /gemini-cli/api -> Gemini CLI OAuth
# ============================================================================
# 🐛 调试 Dump 配置(可选)
# ============================================================================
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
# ⚠️ 生产环境建议关闭,避免磁盘占用。
#
# 📄 输出文件列表:
# - anthropic-requests-dump.jsonl (客户端请求)
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
# - anthropic-tools-dump.jsonl (工具定义快照)
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
#
# 📌 开关配置:
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
#
# 📏 单条记录大小上限(字节),默认 2MB
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
#
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB
# DUMP_MAX_FILE_SIZE_BYTES=10485760
#
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
# (仅 /antigravity/api 分流生效)
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
@@ -61,6 +114,19 @@ PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 🔗 HTTP 连接池配置keep-alive
# 流式请求最大连接数默认65535
# HTTPS_MAX_SOCKETS_STREAM=65535
# 非流式请求最大连接数默认16384
# HTTPS_MAX_SOCKETS_NON_STREAM=16384
# 空闲连接数默认2048
# HTTPS_MAX_FREE_SOCKETS=2048
# 空闲连接超时毫秒默认30000
# HTTPS_FREE_SOCKET_TIMEOUT=30000
# 🔧 请求体大小配置
REQUEST_MAX_SIZE_MB=60
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -75,6 +141,8 @@ TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000
TIMEZONE_OFFSET=8 # UTC偏移小时数默认+8中国时区
METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟
# 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
# 🎨 Web 界面配置
WEB_TITLE=Claude Relay Service

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -22,6 +22,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **权限控制**: API Key支持权限配置all/claude/gemini/openai等控制可访问的服务类型
- **客户端限制**: 基于User-Agent的客户端识别和限制支持ClaudeCode、Gemini-CLI等预定义客户端
- **模型黑名单**: 支持API Key级别的模型访问限制
- **并发请求排队**: 当API Key并发数超限时请求进入队列等待而非立即返回429支持配置最大排队数、超时时间适用于Claude Code Agent并行工具调用场景
### 主要服务组件
@@ -60,6 +61,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **apiKeyService.js**: API Key管理验证、限流、使用统计、成本计算
- **userService.js**: 用户管理系统支持用户注册、登录、API Key管理
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
- **pricingService.js**: 定价服务,模型价格管理和成本计算
- **costInitService.js**: 成本数据初始化服务
- **webhookService.js**: Webhook通知服务
@@ -185,12 +187,17 @@ npm run service:stop # 停止服务
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间分钟0表示禁用
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL小时默认1
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值分钟默认0
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列默认false
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔毫秒默认200
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时毫秒默认5000锁持有时间短无需长等待
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL毫秒默认5000请求发送后立即释放无需长TTL
- `METRICS_WINDOW`: 实时指标统计窗口分钟1-60默认5
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量默认1
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys默认false
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志默认false仅开发环境
- `PROXY_USE_IPV4`: 代理使用IPv4默认true
- `REQUEST_TIMEOUT`: 请求超时时间毫秒默认600000即10分钟
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
#### AWS Bedrock配置可选
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock设置为1启用
@@ -337,6 +344,35 @@ npm run setup # 自动生成密钥并创建管理员账户
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计调整LRU缓存大小配置
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`
15. **并发请求排队问题**:
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理默认10秒
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
### 代理配置要求(并发请求排队)
使用并发请求排队功能时,需要正确配置代理(如 Nginx的超时参数
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
- 当前默认排队超时 10 秒Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
- **Nginx 配置示例**:
```nginx
location /api/ {
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
proxy_connect_timeout 10s;
# ...其他配置
}
```
- **企业防火墙环境**:
- 某些企业防火墙可能静默关闭长时间无数据的连接20-40 秒)
- 如遇此问题,联系网络管理员调整空闲连接超时策略
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
### 调试工具
@@ -449,6 +485,15 @@ npm run setup # 自动生成密钥并创建管理员账户
- **缓存优化**: 多层LRU缓存解密缓存、账户缓存全局缓存监控和统计
- **成本追踪**: 实时token使用统计input/output/cache_create/cache_read和成本计算基于pricingService
- **并发控制**: Redis Sorted Set实现的并发计数支持自动过期清理
- **并发请求排队**: 当API Key并发超限时请求进入队列等待而非直接返回429
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
- **指数退避**: 初始200ms指数增长至最大2秒带±20%抖动防惊群效应
- **智能清理**: 排队计数有TTL保护超时+30秒进程崩溃也能自动清理
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证避免HTTP Keep-Alive连接复用导致的身份混淆
- **健康检查**: P90等待时间超过阈值时快速失败返回429避免新请求在过载时继续排队
- **配置参数**: `concurrentRequestQueueEnabled`默认false、`concurrentRequestQueueMaxSize`默认3、`concurrentRequestQueueMaxSizeMultiplier`默认0、`concurrentRequestQueueTimeoutMs`默认10秒、`concurrentRequestQueueMaxRedisFailCount`默认5、`concurrentRequestQueueHealthCheckEnabled`默认true、`concurrentRequestQueueHealthThreshold`默认0.8
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
- **客户端识别**: 基于User-Agent的客户端限制支持预定义客户端ClaudeCode、Gemini-CLI等
- **错误处理**: 529错误自动标记账户过载状态配置时长内自动排除该账户
@@ -508,8 +553,16 @@ npm run setup # 自动生成密钥并创建管理员账户
- `overload:{accountId}` - 账户过载状态529错误
- **并发控制**:
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **并发请求排队**:
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定
- `concurrency:queue:stats:{apiKeyId}` - 排队统计entered/success/timeout/cancelled
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录用于P50/P90/P99计算
- `concurrency:queue:wait_times:global` - 全局等待时间记录
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **用户消息队列**:
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁当前持有者requestId
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
- **系统信息**:
- `system_info` - 系统状态缓存
- `model_pricing` - 模型价格数据pricingService

View File

@@ -1,5 +1,10 @@
# Claude Relay Service
> [!CAUTION]
> **安全更新通知**v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
>
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -389,6 +394,9 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:**
**使用标准 Claude 账号池**
默认使用标准 Claude 账号池:
```bash
@@ -396,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
**使用 Antigravity 账户池**
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
```bash
# 1. 设置 Base URL 为 Antigravity 专用路径
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
# 2. 设置 API Key在后台创建权限需包含 'all' 或 'gemini'
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
# 3. 指定模型名称(直接使用短名,无需前缀!)
export ANTHROPIC_MODEL="claude-opus-4-5"
# 4. 启动
claude
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
@@ -408,6 +434,8 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
> 💡 **IntelliJ IDEA 用户推荐**[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
**Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问**
@@ -597,6 +625,7 @@ gpt-5 # Codex使用固定模型ID
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/antigravity/api/` - 使用Antigravity账号池推荐用于Claude Code
- `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式

View File

@@ -1,5 +1,10 @@
# Claude Relay Service
> [!CAUTION]
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
>
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

View File

@@ -1 +1 @@
1.1.227
1.1.263

View File

@@ -103,7 +103,7 @@ program
try {
const [, apiKeys, accounts] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
apiKeyService.getAllApiKeysFast(),
claudeAccountService.getAllAccounts()
])
@@ -284,7 +284,7 @@ async function listApiKeys() {
const spinner = ora('正在获取 API Keys...').start()
try {
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
if (apiKeys.length === 0) {
@@ -314,7 +314,7 @@ async function listApiKeys() {
tableData.push([
key.name,
key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
key.maskedKey || '-',
key.isActive ? '🟢 活跃' : '🔴 停用',
expiryStatus,
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
@@ -333,7 +333,7 @@ async function listApiKeys() {
async function updateApiKeyExpiry() {
try {
// 获取所有 API Keys
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'))
@@ -347,7 +347,7 @@ async function updateApiKeyExpiry() {
name: 'selectedKey',
message: '选择要修改的 API Key:',
choices: apiKeys.map((key) => ({
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
value: key
}))
}
@@ -463,7 +463,7 @@ async function renewApiKeys() {
const spinner = ora('正在查找即将过期的 API Keys...').start()
try {
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
const now = new Date()
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
@@ -562,7 +562,7 @@ async function renewApiKeys() {
async function deleteApiKey() {
try {
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'))
@@ -575,7 +575,7 @@ async function deleteApiKey() {
name: 'selectedKeys',
message: '选择要删除的 API Keys (空格选择,回车确认):',
choices: apiKeys.map((key) => ({
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)})`,
value: key.id
}))
}

View File

@@ -123,7 +123,8 @@ const config = {
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8中国时区
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数默认+8
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数默认+8
metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟)
},
// 🎨 Web界面配置
@@ -203,6 +204,30 @@ const config = {
development: {
debug: process.env.DEBUG === 'true',
hotReload: process.env.HOT_RELOAD === 'true'
},
// 💰 账户余额相关配置
accountBalance: {
// 是否允许执行自定义余额脚本(安全开关)
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
// 默认保持开启如需禁用请显式设置BALANCE_SCRIPT_ENABLED=false
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
},
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: {
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL毫秒5秒足以覆盖请求发送
},
// 🎫 额度卡兑换上限配置(防盗刷)
quotaCardLimits: {
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
}
}

64
config/models.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* 模型列表配置
* 用于前端展示和测试功能
*/
const CLAUDE_MODELS = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' },
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }
]
const GEMINI_MODELS = [
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }
]
const OPENAI_MODELS = [
{ value: 'gpt-5', label: 'GPT-5' },
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
{ value: 'gpt-5-nano', label: 'GPT-5 Nano' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'codex-mini', label: 'Codex Mini' }
]
// 其他模型(用于账户编辑的模型映射)
const OTHER_MODELS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'Qwen', label: 'Qwen' },
{ value: 'Kimi', label: 'Kimi' },
{ value: 'GLM', label: 'GLM' }
]
module.exports = {
CLAUDE_MODELS,
GEMINI_MODELS,
OPENAI_MODELS,
OTHER_MODELS,
// 按服务分组
getModelsByService: (service) => {
switch (service) {
case 'claude':
return CLAUDE_MODELS
case 'gemini':
return GEMINI_MODELS
case 'openai':
return OPENAI_MODELS
default:
return []
}
},
// 获取所有模型(用于账户编辑)
getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS]
}

View File

@@ -2,7 +2,8 @@ const repository =
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
const hashFileName =
process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
@@ -11,7 +12,6 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
module.exports = {
pricingFileName,
hashFileName,
pricingUrl:
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
}

View File

@@ -21,6 +21,9 @@ services:
- PORT=3000
- HOST=0.0.0.0
# 🔧 请求体大小配置
- REQUEST_MAX_SIZE_MB=60
# 🔐 安全配置(必填)
- JWT_SECRET=${JWT_SECRET} # 必填至少32字符的随机字符串
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填32字符的加密密钥

18
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -891,7 +892,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -3000,7 +3000,6 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3082,7 +3081,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3538,7 +3536,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4426,7 +4423,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4483,7 +4479,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7034,6 +7029,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7582,7 +7586,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9101,7 +9104,6 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",

View File

@@ -65,6 +65,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",

71
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.1
node-cron:
specifier: ^4.2.1
version: 4.2.1
nodemailer:
specifier: ^7.0.6
version: 7.0.11
@@ -108,6 +111,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.7.4
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.7.4)
supertest:
specifier: ^6.3.3
version: 6.3.4
@@ -2144,6 +2150,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2302,6 +2312,61 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier-plugin-tailwindcss@0.7.2:
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -5692,6 +5757,8 @@ snapshots:
negotiator@0.6.4: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -5840,6 +5907,10 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies:
prettier: 3.7.4
prettier@3.7.4: {}
pretty-format@29.7.0:

View File

@@ -152,62 +152,110 @@ async function exportUsageStats(keyId) {
daily: {},
monthly: {},
hourly: {},
models: {}
models: {},
// 费用统计String 类型)
costTotal: null,
costDaily: {},
costMonthly: {},
costHourly: {},
opusTotal: null,
opusWeekly: {}
}
// 导出总统计
const totalKey = `usage:${keyId}`
const totalData = await redis.client.hgetall(totalKey)
// 导出总统计Hash
const totalData = await redis.client.hgetall(`usage:${keyId}`)
if (totalData && Object.keys(totalData).length > 0) {
stats.total = totalData
}
// 导出每日统计最近30天
const today = new Date()
for (let i = 0; i < 30; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
const dailyKey = `usage:daily:${keyId}:${dateStr}`
// 导出费用总统计String
const costTotal = await redis.client.get(`usage:cost:total:${keyId}`)
if (costTotal) {
stats.costTotal = costTotal
}
const dailyData = await redis.client.hgetall(dailyKey)
if (dailyData && Object.keys(dailyData).length > 0) {
stats.daily[dateStr] = dailyData
// 导出 Opus 费用总统计String
const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`)
if (opusTotal) {
stats.opusTotal = opusTotal
}
// 导出每日统计(扫描现有 key避免时区问题
const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`)
for (const key of dailyKeys) {
const date = key.split(':').pop()
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
stats.daily[date] = data
}
}
// 导出每月统计最近12个月
for (let i = 0; i < 12; i++) {
const date = new Date(today)
date.setMonth(date.getMonth() - i)
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
const monthlyData = await redis.client.hgetall(monthlyKey)
if (monthlyData && Object.keys(monthlyData).length > 0) {
stats.monthly[monthStr] = monthlyData
// 导出每日费用(扫描现有 key
const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`)
for (const key of costDailyKeys) {
const date = key.split(':').pop()
const value = await redis.client.get(key)
if (value) {
stats.costDaily[date] = value
}
}
// 导出小时统计(最近24小时
for (let i = 0; i < 24; i++) {
const date = new Date(today)
date.setHours(date.getHours() - i)
const dateStr = date.toISOString().split('T')[0]
const hour = String(date.getHours()).padStart(2, '0')
const hourKey = `${dateStr}:${hour}`
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
const hourlyData = await redis.client.hgetall(hourlyKey)
if (hourlyData && Object.keys(hourlyData).length > 0) {
stats.hourly[hourKey] = hourlyData
// 导出每月统计(扫描现有 key
const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`)
for (const key of monthlyKeys) {
const month = key.split(':').pop()
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
stats.monthly[month] = data
}
}
// 导出模型统计
// 每日模型统计
const modelDailyPattern = `usage:${keyId}:model:daily:*`
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
// 导出每月费用(扫描现有 key
const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`)
for (const key of costMonthlyKeys) {
const month = key.split(':').pop()
const value = await redis.client.get(key)
if (value) {
stats.costMonthly[month] = value
}
}
// 导出 Opus 周费用(扫描现有 key
const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`)
for (const key of opusWeeklyKeys) {
const week = key.split(':').pop()
const value = await redis.client.get(key)
if (value) {
stats.opusWeekly[week] = value
}
}
// 导出小时统计(扫描现有 key
// key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH}
const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`)
for (const key of hourlyKeys) {
const parts = key.split(':')
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
stats.hourly[hourKey] = data
}
}
// 导出小时费用(扫描现有 key
// key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH}
const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`)
for (const key of costHourlyKeys) {
const parts = key.split(':')
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
const value = await redis.client.get(key)
if (value) {
stats.costHourly[hourKey] = value
}
}
// 导出模型统计(每日)
const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`)
for (const key of modelDailyKeys) {
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) {
@@ -223,9 +271,8 @@ async function exportUsageStats(keyId) {
}
}
// 每月模型统计
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
// 导出模型统计(每月)
const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`)
for (const key of modelMonthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (match) {
@@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) {
const pipeline = redis.client.pipeline()
let importCount = 0
// 导入总统计
// 导入总统计Hash
if (stats.total && Object.keys(stats.total).length > 0) {
for (const [field, value] of Object.entries(stats.total)) {
pipeline.hset(`usage:${keyId}`, field, value)
@@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) {
importCount++
}
// 导入每日统计
// 导入费用总统计String
if (stats.costTotal) {
pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal)
importCount++
}
// 导入 Opus 费用总统计String
if (stats.opusTotal) {
pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal)
importCount++
}
// 导入每日统计Hash
if (stats.daily) {
for (const [date, data] of Object.entries(stats.daily)) {
for (const [field, value] of Object.entries(data)) {
@@ -276,7 +335,15 @@ async function importUsageStats(keyId, stats) {
}
}
// 导入每月统计
// 导入每日费用String
if (stats.costDaily) {
for (const [date, value] of Object.entries(stats.costDaily)) {
pipeline.set(`usage:cost:daily:${keyId}:${date}`, value)
importCount++
}
}
// 导入每月统计Hash
if (stats.monthly) {
for (const [month, data] of Object.entries(stats.monthly)) {
for (const [field, value] of Object.entries(data)) {
@@ -286,7 +353,23 @@ async function importUsageStats(keyId, stats) {
}
}
// 导入小时统计
// 导入每月费用String
if (stats.costMonthly) {
for (const [month, value] of Object.entries(stats.costMonthly)) {
pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value)
importCount++
}
}
// 导入 Opus 周费用String不加 TTL 保留历史全量)
if (stats.opusWeekly) {
for (const [week, value] of Object.entries(stats.opusWeekly)) {
pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value)
importCount++
}
}
// 导入小时统计Hash
if (stats.hourly) {
for (const [hour, data] of Object.entries(stats.hourly)) {
for (const [field, value] of Object.entries(data)) {
@@ -296,10 +379,17 @@ async function importUsageStats(keyId, stats) {
}
}
// 导入模型统计
// 导入小时费用String
if (stats.costHourly) {
for (const [hour, value] of Object.entries(stats.costHourly)) {
pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value)
importCount++
}
}
// 导入模型统计Hash
if (stats.models) {
for (const [model, modelStats] of Object.entries(stats.models)) {
// 每日模型统计
if (modelStats.daily) {
for (const [date, data] of Object.entries(modelStats.daily)) {
for (const [field, value] of Object.entries(data)) {
@@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) {
}
}
// 每月模型统计
if (modelStats.monthly) {
for (const [month, data] of Object.entries(modelStats.monthly)) {
for (const [field, value] of Object.entries(data)) {
@@ -547,13 +636,54 @@ async function exportData() {
const globalStats = {
daily: {},
monthly: {},
hourly: {}
hourly: {},
// 新增:索引和全局统计
monthlyMonths: [], // usage:model:monthly:months Set
globalTotal: null, // usage:global:total Hash
globalDaily: {}, // usage:global:daily:* Hash
globalMonthly: {} // usage:global:monthly:* Hash
}
// 导出全局每日模型统计
const globalDailyPattern = 'usage:model:daily:*'
const globalDailyKeys = await redis.client.keys(globalDailyPattern)
// 导出月份索引
const monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
if (monthlyMonths && monthlyMonths.length > 0) {
globalStats.monthlyMonths = monthlyMonths
logger.info(`📤 Found ${monthlyMonths.length} months in index`)
}
// 导出全局统计
const globalTotal = await redis.client.hgetall('usage:global:total')
if (globalTotal && Object.keys(globalTotal).length > 0) {
globalStats.globalTotal = globalTotal
logger.info('📤 Found global total stats')
}
// 导出全局每日统计
const globalDailyKeys = await redis.client.keys('usage:global:daily:*')
for (const key of globalDailyKeys) {
const date = key.replace('usage:global:daily:', '')
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
globalStats.globalDaily[date] = data
}
}
logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`)
// 导出全局每月统计
const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*')
for (const key of globalMonthlyKeys) {
const month = key.replace('usage:global:monthly:', '')
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
globalStats.globalMonthly[month] = data
}
}
logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`)
// 导出全局每日模型统计
const modelDailyPattern = 'usage:model:daily:*'
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
for (const key of modelDailyKeys) {
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) {
const model = match[1]
@@ -569,9 +699,9 @@ async function exportData() {
}
// 导出全局每月模型统计
const globalMonthlyPattern = 'usage:model:monthly:*'
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
for (const key of globalMonthlyKeys) {
const modelMonthlyPattern = 'usage:model:monthly:*'
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
for (const key of modelMonthlyKeys) {
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
if (match) {
const model = match[1]
@@ -1040,6 +1170,46 @@ async function importData() {
const pipeline = redis.client.pipeline()
let globalStatCount = 0
// 导入月份索引
if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) {
for (const month of globalStats.monthlyMonths) {
pipeline.sadd('usage:model:monthly:months', month)
}
logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`)
}
// 导入全局统计
if (globalStats.globalTotal) {
for (const [field, value] of Object.entries(globalStats.globalTotal)) {
pipeline.hset('usage:global:total', field, value)
}
logger.info('📥 Importing global total stats')
}
// 导入全局每日统计
if (globalStats.globalDaily) {
for (const [date, data] of Object.entries(globalStats.globalDaily)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:global:daily:${date}`, field, value)
}
}
logger.info(
`📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats`
)
}
// 导入全局每月统计
if (globalStats.globalMonthly) {
for (const [month, data] of Object.entries(globalStats.globalMonthly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:global:monthly:${month}`, field, value)
}
}
logger.info(
`📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats`
)
}
// 导入每日统计
if (globalStats.daily) {
for (const [date, models] of Object.entries(globalStats.daily)) {
@@ -1061,6 +1231,8 @@ async function importData() {
}
globalStatCount++
}
// 同时更新月份索引(兼容旧格式导出文件)
pipeline.sadd('usage:model:monthly:months', month)
}
}

View File

@@ -141,7 +141,7 @@ async function cleanTestData() {
logger.info('🧹 Cleaning test data...')
// 获取所有API Keys
const allKeys = await apiKeyService.getAllApiKeys()
const allKeys = await apiKeyService.getAllApiKeysFast()
// 找出所有测试 API Keys
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))

View File

@@ -12,6 +12,7 @@
*/
const redis = require('../src/models/redis')
const apiKeyService = require('../src/services/apiKeyService')
const logger = require('../src/utils/logger')
const readline = require('readline')
@@ -51,7 +52,7 @@ async function migrateApiKeys() {
logger.success('✅ Connected to Redis')
// 获取所有 API Keys
const apiKeys = await redis.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
// 统计信息

View File

@@ -0,0 +1,138 @@
/**
* 历史数据索引迁移脚本
* 为现有的 usage 数据建立索引,加速查询
*/
const Redis = require('ioredis')
const config = require('../config/config')
const redis = new Redis({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
db: config.redis.db || 0
})
async function migrate() {
console.log('开始迁移历史数据索引...')
console.log('Redis DB:', config.redis.db || 0)
const stats = {
dailyIndex: 0,
hourlyIndex: 0,
modelDailyIndex: 0,
modelHourlyIndex: 0
}
// 1. 迁移 usage:daily:{keyId}:{date} 索引
console.log('\n1. 迁移 usage:daily 索引...')
let cursor = '0'
do {
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500)
cursor = newCursor
const pipeline = redis.pipeline()
for (const key of keys) {
// usage:daily:{keyId}:{date}
const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
if (match) {
const [, keyId, date] = match
pipeline.sadd(`usage:daily:index:${date}`, keyId)
pipeline.expire(`usage:daily:index:${date}`, 86400 * 32)
stats.dailyIndex++
}
}
if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0')
console.log(` 已处理 ${stats.dailyIndex}`)
// 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引
console.log('\n2. 迁移 usage:hourly 索引...')
cursor = '0'
do {
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500)
cursor = newCursor
const pipeline = redis.pipeline()
for (const key of keys) {
// usage:hourly:{keyId}:{date}:{hour}
const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
if (match) {
const [, keyId, hourKey] = match
pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId)
pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7)
stats.hourlyIndex++
}
}
if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0')
console.log(` 已处理 ${stats.hourlyIndex}`)
// 3. 迁移 usage:model:daily:{model}:{date} 索引
console.log('\n3. 迁移 usage:model:daily 索引...')
cursor = '0'
do {
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500)
cursor = newCursor
const pipeline = redis.pipeline()
for (const key of keys) {
// usage:model:daily:{model}:{date}
const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
if (match) {
const [, model, date] = match
pipeline.sadd(`usage:model:daily:index:${date}`, model)
pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32)
stats.modelDailyIndex++
}
}
if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0')
console.log(` 已处理 ${stats.modelDailyIndex}`)
// 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引
console.log('\n4. 迁移 usage:model:hourly 索引...')
cursor = '0'
do {
const [newCursor, keys] = await redis.scan(
cursor,
'MATCH',
'usage:model:hourly:*',
'COUNT',
500
)
cursor = newCursor
const pipeline = redis.pipeline()
for (const key of keys) {
// usage:model:hourly:{model}:{date}:{hour}
const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
if (match) {
const [, model, hourKey] = match
pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model)
pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7)
stats.modelHourlyIndex++
}
}
if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0')
console.log(` 已处理 ${stats.modelHourlyIndex}`)
console.log('\n迁移完成!')
console.log('统计:', stats)
redis.disconnect()
}
migrate().catch((err) => {
console.error('迁移失败:', err)
redis.disconnect()
process.exit(1)
})

View File

@@ -11,6 +11,7 @@ const logger = require('./utils/logger')
const redis = require('./models/redis')
const pricingService = require('./services/pricingService')
const cacheMonitor = require('./utils/cacheMonitor')
const { getSafeMessage } = require('./utils/errorSanitizer')
// Import routes
const apiRoutes = require('./routes/api')
@@ -50,7 +51,48 @@ class Application {
// 🔗 连接Redis
logger.info('🔄 Connecting to Redis...')
await redis.connect()
logger.success('Redis connected successfully')
logger.success('Redis connected successfully')
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
const { getAppVersion, versionGt } = require('./utils/commonHelper')
const currentVersion = getAppVersion()
const migratedVersion = await redis.getMigratedVersion()
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
try {
if (await redis.needsGlobalStatsMigration()) {
await redis.migrateGlobalStats()
}
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
} catch (err) {
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
}
await redis.setMigratedVersion(currentVersion)
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
}
// 📅 后台检查月份索引完整性(不阻塞启动)
redis.ensureMonthlyMonthsIndex().catch((err) => {
logger.error('📅 月份索引检查失败:', err.message)
})
// 📊 后台异步迁移 usage 索引(不阻塞启动)
redis.migrateUsageIndex().catch((err) => {
logger.error('📊 Background usage index migration failed:', err)
})
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
await redis.migrateAlltimeModelStats()
// 💳 初始化账户余额查询服务Provider 注册)
try {
const accountBalanceService = require('./services/accountBalanceService')
const { registerAllProviders } = require('./services/balanceProviders')
registerAllProviders(accountBalanceService)
logger.info('✅ 账户余额查询服务已初始化')
} catch (error) {
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
}
// 💰 初始化价格服务
logger.info('🔄 Initializing pricing service...')
@@ -68,6 +110,10 @@ class Application {
logger.info('🔄 Initializing admin credentials...')
await this.initializeAdmin()
// 🔒 安全启动:清理无效/伪造的管理员会话
logger.info('🔒 Cleaning up invalid admin sessions...')
await this.cleanupInvalidSessions()
// 💰 初始化费用数据
logger.info('💰 Checking cost data initialization...')
const costInitService = require('./services/costInitService')
@@ -80,6 +126,15 @@ class Application {
)
}
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
try {
logger.info('💰 Backfilling current-week Claude weekly cost...')
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
} catch (error) {
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
}
// 🕐 初始化Claude账户会话窗口
logger.info('🕐 Initializing Claude account session windows...')
const claudeAccountService = require('./services/claudeAccountService')
@@ -90,6 +145,18 @@ class Application {
const costRankService = require('./services/costRankService')
await costRankService.initialize()
// 🔍 初始化 API Key 索引服务(用于分页查询优化)
logger.info('🔍 Initializing API Key index service...')
const apiKeyIndexService = require('./services/apiKeyIndexService')
apiKeyIndexService.init(redis)
await apiKeyIndexService.checkAndRebuild()
// 📁 确保账户分组反向索引存在(后台执行,不阻塞启动)
const accountGroupService = require('./services/accountGroupService')
accountGroupService.ensureReverseIndexes().catch((err) => {
logger.error('📁 Account group reverse index migration failed:', err)
})
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
this.app.use((req, res, next) => {
if (req.path === '/admin-next/' && req.method === 'GET') {
@@ -165,7 +232,7 @@ class Application {
// 🔧 基础中间件
this.app.use(
express.json({
limit: '10mb',
limit: '100mb',
verify: (req, res, buf, encoding) => {
// 验证JSON格式
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
@@ -174,7 +241,7 @@ class Application {
}
})
)
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
this.app.use(securityMiddleware)
// 🎯 信任代理
@@ -264,6 +331,25 @@ class Application {
this.app.use('/api', apiRoutes)
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
// - /antigravity/api/v1/messages -> Antigravity OAuth
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
this.app.use(
'/antigravity/api',
(req, res, next) => {
req._anthropicVendor = 'antigravity'
next()
},
apiRoutes
)
this.app.use(
'/gemini-cli/api',
(req, res, next) => {
req._anthropicVendor = 'gemini-cli'
next()
},
apiRoutes
)
this.app.use('/admin', adminRoutes)
this.app.use('/users', userRoutes)
// 使用 web 路由(包含 auth 和页面重定向)
@@ -344,7 +430,7 @@ class Application {
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
res.status(503).json({
status: 'unhealthy',
error: error.message,
error: getSafeMessage(error),
timestamp: new Date().toISOString()
})
}
@@ -380,7 +466,7 @@ class Application {
// 🚨 错误处理
this.app.use(errorHandler)
logger.success('Application initialized successfully')
logger.success('Application initialized successfully')
} catch (error) {
logger.error('💥 Application initialization failed:', error)
throw error
@@ -415,7 +501,7 @@ class Application {
await redis.setSession('admin_credentials', adminCredentials)
logger.success('Admin credentials loaded from init.json (single source of truth)')
logger.success('Admin credentials loaded from init.json (single source of truth)')
logger.info(`📋 Admin username: ${adminCredentials.username}`)
} catch (error) {
logger.error('❌ Failed to initialize admin credentials:', {
@@ -426,6 +512,56 @@ class Application {
}
}
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
async cleanupInvalidSessions() {
try {
const client = redis.getClient()
// 获取所有 session:* 键
const sessionKeys = await redis.scanKeys('session:*')
const dataList = await redis.batchHgetallChunked(sessionKeys)
let validCount = 0
let invalidCount = 0
for (let i = 0; i < sessionKeys.length; i++) {
const key = sessionKeys[i]
// 跳过 admin_credentials系统凭据
if (key === 'session:admin_credentials') {
continue
}
const sessionData = dataList[i]
// 检查会话完整性:必须有 username 和 loginTime
const hasUsername = !!sessionData?.username
const hasLoginTime = !!sessionData?.loginTime
if (!hasUsername || !hasLoginTime) {
// 无效会话 - 可能是漏洞利用创建的伪造会话
invalidCount++
logger.security(
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
)
await client.del(key)
} else {
validCount++
}
}
if (invalidCount > 0) {
logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`)
}
logger.success(
`Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
)
} catch (error) {
// 清理失败不应阻止服务启动
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
}
}
// 🔍 Redis健康检查
async checkRedisHealth() {
try {
@@ -468,9 +604,7 @@ class Application {
await this.initialize()
this.server = this.app.listen(config.server.port, config.server.host, () => {
logger.start(
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
)
logger.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`)
logger.info(
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
)
@@ -525,7 +659,7 @@ class Application {
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
}, 5000)
logger.success('Cache monitoring initialized')
logger.success('Cache monitoring initialized')
} catch (error) {
logger.error('❌ Failed to initialize cache monitoring:', error)
// 不阻止应用启动
@@ -574,22 +708,47 @@ class Application {
// 每分钟主动清理所有过期的并发项,不依赖请求触发
setInterval(async () => {
try {
const keys = await redis.keys('concurrency:*')
const keys = await redis.scanKeys('concurrency:*')
if (keys.length === 0) {
return
}
const now = Date.now()
let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:') ||
(key.startsWith('concurrency:queue:') &&
!key.includes(':stats:') &&
!key.includes(':wait_times:'))
) {
continue
}
try {
const cleaned = await redis.client.eval(
// 使用原子 Lua 脚本:先检查类型,再执行清理
// 返回值0 = 正常清理无删除1 = 清理后删除空键,-1 = 遗留键已删除
const result = await redis.client.eval(
`
local key = KEYS[1]
local now = tonumber(ARGV[1])
-- 先检查键类型,只对 Sorted Set 执行清理
local keyType = redis.call('TYPE', key)
if keyType.ok ~= 'zset' then
-- 非 ZSET 类型的遗留键,直接删除
redis.call('DEL', key)
return -1
end
-- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -608,8 +767,10 @@ class Application {
key,
now
)
if (cleaned === 1) {
if (result === 1) {
totalCleaned++
} else if (result === -1) {
legacyCleaned++
}
} catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -619,12 +780,50 @@ class Application {
if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
}
if (legacyCleaned > 0) {
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
}
} catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error)
}
}, 60000) // 每分钟执行一次
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
// 📬 启动用户消息队列服务
const userMessageQueueService = require('./services/userMessageQueueService')
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
userMessageQueueService.cleanupStaleLocks().then(() => {
// 然后启动定时清理任务
userMessageQueueService.startCleanupTask()
})
// 🚦 清理服务重启后残留的并发排队计数器
// 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数
// 可通过 DELETE /admin/concurrency/queue 接口手动清理
const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false'
if (clearQueuesOnStartup) {
redis.clearAllConcurrencyQueues().catch((error) => {
logger.error('❌ Error clearing concurrency queues on startup:', error)
})
} else {
logger.info(
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
)
}
// 🧪 启动账户定时测试调度器
// 根据配置定期测试账户连通性并保存测试历史
const accountTestSchedulerEnabled =
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
config.accountTestScheduler?.enabled !== false
if (accountTestSchedulerEnabled) {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.start()
logger.info('🧪 Account test scheduler service started')
} else {
logger.info('🧪 Account test scheduler service disabled')
}
}
setupGracefulShutdown() {
@@ -661,6 +860,15 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
// 停止用户消息队列清理服务
try {
const userMessageQueueService = require('./services/userMessageQueueService')
userMessageQueueService.stopCleanupTask()
logger.info('📬 User message queue service stopped')
} catch (error) {
logger.error('❌ Error stopping user message queue service:', error)
}
// 停止费用排序索引服务
try {
const costRankService = require('./services/costRankService')
@@ -670,12 +878,21 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error)
}
// 停止账户定时测试调度器
try {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.stop()
logger.info('🧪 Account test scheduler service stopped')
} catch (error) {
logger.error('❌ Error stopping account test scheduler service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try {
logger.info('🔢 Cleaning up all concurrency counters...')
const keys = await redis.keys('concurrency:*')
const keys = await redis.scanKeys('concurrency:*')
if (keys.length > 0) {
await redis.client.del(...keys)
await redis.batchDelChunked(keys)
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
} else {
logger.info('✅ No concurrency keys to clean')
@@ -692,7 +909,7 @@ class Application {
logger.error('❌ Error disconnecting Redis:', error)
}
logger.success('Graceful shutdown completed')
logger.success('Graceful shutdown completed')
process.exit(0)
})

View File

@@ -9,13 +9,16 @@ const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService')
const geminiApiAccountService = require('../services/geminiApiAccountService')
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
const crypto = require('crypto')
const sessionHelper = require('../utils/sessionHelper')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const { parseSSELine } = require('../utils/sseParser')
const axios = require('axios')
const { getSafeMessage } = require('../utils/errorSanitizer')
const ProxyHelper = require('../utils/proxyHelper')
// ============================================================================
@@ -86,8 +89,7 @@ function generateSessionHash(req) {
* 检查 API Key 权限
*/
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
/**
@@ -136,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
const { totalTokens, totalCost } = await updateRateLimitCounters(
req.rateLimitInfo,
usageSummary,
model
model,
req.apiKey?.id,
'gemini'
)
if (totalTokens > 0) {
@@ -353,7 +357,7 @@ async function handleMessages(req, res) {
logger.error('Failed to select Gemini account:', error)
return res.status(503).json({
error: {
message: error.message || 'No available Gemini accounts',
message: getSafeMessage(error) || 'No available Gemini accounts',
type: 'service_unavailable'
}
})
@@ -492,7 +496,8 @@ async function handleMessages(req, res) {
0,
0,
model,
accountId
accountId,
'gemini'
)
}
}
@@ -508,7 +513,23 @@ async function handleMessages(req, res) {
// OAuth 账户:使用现有的 sendGeminiRequest
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
const effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
if (oauthProvider === 'antigravity') {
geminiResponse = await sendAntigravityRequest({
messages,
model,
temperature,
maxTokens: max_tokens,
stream,
accessToken: account.accessToken,
proxy: account.proxy,
apiKeyId: apiKeyData.id,
signal: abortController.signal,
projectId: effectiveProjectId,
accountId: account.id
})
} else {
geminiResponse = await sendGeminiRequest({
messages,
model,
@@ -523,6 +544,7 @@ async function handleMessages(req, res) {
accountId: account.id
})
}
}
if (stream) {
// 设置流式响应头
@@ -579,7 +601,8 @@ async function handleMessages(req, res) {
0,
0,
model,
accountId
accountId,
'gemini'
)
.then(() => {
logger.info(
@@ -597,7 +620,7 @@ async function handleMessages(req, res) {
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
message: getSafeMessage(error) || 'Stream error',
type: 'api_error'
}
})
@@ -645,7 +668,7 @@ async function handleMessages(req, res) {
const status = errorStatus || 500
const errorResponse = {
error: error.error || {
message: error.message || 'Internal server error',
message: getSafeMessage(error) || 'Internal server error',
type: 'api_error'
}
}
@@ -754,8 +777,16 @@ async function handleModels(req, res) {
]
}
} else {
// OAuth 账户:使用 OAuth token 获取模型列表
models = await getAvailableModels(account.accessToken, account.proxy)
// OAuth 账户:根据 OAuth provider 选择上游
const oauthProvider = account.oauthProvider || 'gemini-cli'
models =
oauthProvider === 'antigravity'
? await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
account.proxy,
account.refreshToken
)
: await getAvailableModels(account.accessToken, account.proxy)
}
res.json({
@@ -805,16 +836,18 @@ function handleModelDetails(req, res) {
*/
async function handleUsage(req, res) {
try {
const { usage } = req.apiKey
const keyData = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({
object: 'usage',
total_tokens: usage.total.tokens,
total_requests: usage.total.requests,
daily_tokens: usage.daily.tokens,
daily_requests: usage.daily.requests,
monthly_tokens: usage.monthly.tokens,
monthly_requests: usage.monthly.requests
total_tokens: usage?.total?.tokens || 0,
total_requests: usage?.total?.requests || 0,
daily_tokens: usage?.daily?.tokens || 0,
daily_requests: usage?.daily?.requests || 0,
monthly_tokens: usage?.monthly?.tokens || 0,
monthly_requests: usage?.monthly?.requests || 0
})
} catch (error) {
logger.error('Failed to get usage stats:', error)
@@ -833,17 +866,18 @@ async function handleUsage(req, res) {
async function handleKeyInfo(req, res) {
try {
const keyData = req.apiKey
// 按需查询 usage 数据(仅 key-info 端点需要)
const usage = await redis.getUsageStats(keyData.id)
const tokensUsed = usage?.total?.tokens || 0
res.json({
id: keyData.id,
name: keyData.name,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
tokens_used: tokensUsed,
tokens_remaining:
keyData.tokenLimit > 0
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
rate_limit: {
window: keyData.rateLimitWindow,
requests: keyData.rateLimitRequests
@@ -927,7 +961,8 @@ function handleSimpleEndpoint(apiMethod) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
// 直接转发请求体,不做特殊处理
@@ -1006,7 +1041,12 @@ async function handleLoadCodeAssist(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1104,7 +1144,12 @@ async function handleOnboardUser(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1152,6 +1197,110 @@ async function handleOnboardUser(req, res) {
}
}
/**
* 处理 retrieveUserQuota 请求
* POST /v1internal:retrieveUserQuota
*
* 功能查询用户在各个Gemini模型上的配额使用情况
* 请求体:{ "project": "项目ID" }
* 响应:{ "buckets": [...] }
*/
async function handleRetrieveUserQuota(req, res) {
try {
// 1. 权限检查
if (!ensureGeminiPermission(req, res)) {
return undefined
}
// 2. 会话哈希
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 3. 账户选择
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
const { accountId, accountType } = schedulerResult
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
if (accountType === 'gemini-api') {
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
return res.status(400).json({
error: {
message:
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
type: 'invalid_account_type'
}
})
}
// 5. 获取账户
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: {
message: 'Gemini account not found',
type: 'account_not_found'
}
})
}
const { accessToken, refreshToken, projectId } = account
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject"
const requestProject = req.body.project
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`RetrieveUserQuota request (${version})`, {
requestedProject: requestProject || null,
accountProject: projectId || null,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 7. 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
// 8. 获取OAuth客户端
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 9. 智能处理项目ID与其他 v1internal 接口保持一致)
const effectiveProject = projectId || requestProject || null
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
accountProjectId: projectId,
requestProject,
effectiveProject,
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
})
// 10. 构建请求体(注入 effectiveProject
const requestBody = { ...req.body }
if (effectiveProject) {
requestBody.project = effectiveProject
}
// 11. 调用底层服务转发请求
const response = await geminiAccountService.forwardToCodeAssist(
client,
'retrieveUserQuota',
requestBody,
proxyConfig
)
res.json(response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
error: error.message
})
res.status(500).json({
error: 'Internal server error',
message: error.message
})
}
}
/**
* 处理 countTokens 请求
*/
@@ -1256,7 +1405,8 @@ async function handleCountTokens(req, res) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
}
@@ -1267,7 +1417,7 @@ async function handleCountTokens(req, res) {
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
res.status(500).json({
error: {
message: error.message || 'Internal server error',
message: getSafeMessage(error) || 'Internal server error',
type: 'api_error'
}
})
@@ -1366,13 +1516,20 @@ async function handleGenerateContent(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId) {
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1388,6 +1545,12 @@ async function handleGenerateContent(req, res) {
}
}
if (!effectiveProjectId && oauthProvider === 'antigravity') {
// Antigravity 账号允许没有 projectId生成一个稳定的临时 projectId 并缓存
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
}
// 如果还是没有项目ID返回错误
if (!effectiveProjectId) {
return res.status(403).json({
@@ -1410,7 +1573,17 @@ async function handleGenerateContent(req, res) {
: '从loadCodeAssist获取'
})
const response = await geminiAccountService.generateContent(
const response =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentAntigravity(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
: await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
@@ -1430,7 +1603,8 @@ async function handleGenerateContent(req, res) {
0,
0,
model,
account.id
account.id,
'gemini'
)
logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
@@ -1466,7 +1640,7 @@ async function handleGenerateContent(req, res) {
})
res.status(500).json({
error: {
message: error.message || 'Internal server error',
message: getSafeMessage(error) || 'Internal server error',
type: 'api_error'
}
})
@@ -1578,13 +1752,20 @@ async function handleStreamGenerateContent(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId) {
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1600,6 +1781,11 @@ async function handleStreamGenerateContent(req, res) {
}
}
if (!effectiveProjectId && oauthProvider === 'antigravity') {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
}
// 如果还是没有项目ID返回错误
if (!effectiveProjectId) {
return res.status(403).json({
@@ -1622,7 +1808,18 @@ async function handleStreamGenerateContent(req, res) {
: '从loadCodeAssist获取'
})
const streamResponse = await geminiAccountService.generateContentStream(
const streamResponse =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
: await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
@@ -1727,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) {
0,
0,
model,
account.id
account.id,
'gemini'
),
applyRateLimitTracking(
req,
@@ -1764,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
message: getSafeMessage(error) || 'Stream error',
type: 'api_error'
}
})
@@ -1774,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) {
res.write(
`data: ${JSON.stringify({
error: {
message: error.message || 'Stream error',
message: getSafeMessage(error) || 'Stream error',
type: 'stream_error',
code: error.code
}
@@ -1803,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Internal server error',
message: getSafeMessage(error) || 'Internal server error',
type: 'api_error'
}
})
@@ -1978,15 +2176,23 @@ async function handleStandardGenerateContent(req, res) {
} else {
// OAuth 账户
const { accessToken, refreshToken } = account
const oauthProvider = account.oauthProvider || 'gemini-cli'
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
oauthProvider
)
let effectiveProjectId = account.projectId || account.tempProjectId || null
if (oauthProvider === 'antigravity') {
if (!effectiveProjectId) {
// Antigravity 账号允许没有 projectId生成一个稳定的临时 projectId 并缓存
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2024,6 +2230,16 @@ async function handleStandardGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0`
if (oauthProvider === 'antigravity') {
response = await geminiAccountService.generateContentAntigravity(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
} else {
response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
@@ -2033,6 +2249,7 @@ async function handleStandardGenerateContent(req, res) {
proxyConfig
)
}
}
// 记录使用统计
if (response?.response?.usageMetadata) {
@@ -2045,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) {
0,
0,
model,
accountId
accountId,
'gemini'
)
logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
@@ -2067,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) {
res.status(500).json({
error: {
message: error.message || 'Internal server error',
message: getSafeMessage(error) || 'Internal server error',
type: 'api_error'
}
})
@@ -2263,12 +2481,20 @@ async function handleStandardStreamGenerateContent(req, res) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
if (oauthProvider === 'antigravity') {
if (!effectiveProjectId) {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2306,6 +2532,17 @@ async function handleStandardStreamGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0`
if (oauthProvider === 'antigravity') {
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
} else {
streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
@@ -2316,6 +2553,7 @@ async function handleStandardStreamGenerateContent(req, res) {
proxyConfig
)
}
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
@@ -2454,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) {
0,
0,
model,
accountId
accountId,
'gemini'
)
.then(() => {
logger.info(
@@ -2482,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) {
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
message: getSafeMessage(error) || 'Stream error',
type: 'api_error'
}
})
@@ -2492,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) {
res.write(
`data: ${JSON.stringify({
error: {
message: error.message || 'Stream error',
message: getSafeMessage(error) || 'Stream error',
type: 'stream_error',
code: error.code
}
@@ -2576,6 +2815,7 @@ module.exports = {
handleSimpleEndpoint,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleGenerateContent,
handleStreamGenerateContent,

View File

@@ -8,6 +8,103 @@ const redis = require('../models/redis')
const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
// 工具函数
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 检查排队是否过载,决定是否应该快速失败
* 详见 design.md Decision 7: 排队健康检查与快速失败
*
* @param {string} apiKeyId - API Key ID
* @param {number} timeoutMs - 排队超时时间(毫秒)
* @param {Object} queueConfig - 队列配置
* @param {number} maxQueueSize - 最大排队数
* @returns {Promise<Object>} { reject: boolean, reason?: string, estimatedWaitMs?: number, timeoutMs?: number }
*/
async function shouldRejectDueToOverload(apiKeyId, timeoutMs, queueConfig, maxQueueSize) {
try {
// 如果健康检查被禁用,直接返回不拒绝
if (!queueConfig.concurrentRequestQueueHealthCheckEnabled) {
return { reject: false, reason: 'health_check_disabled' }
}
// 🔑 先检查当前队列长度
const currentQueueCount = await redis.getConcurrencyQueueCount(apiKeyId).catch(() => 0)
// 队列为空,说明系统已恢复,跳过健康检查
if (currentQueueCount === 0) {
return { reject: false, reason: 'queue_empty', currentQueueCount: 0 }
}
// 🔑 关键改进:只有当队列接近满载时才进行健康检查
// 队列长度 <= maxQueueSize * 0.5 时,认为系统有足够余量,跳过健康检查
// 这避免了在队列较短时过于保守地拒绝请求
// 使用 ceil 确保小队列(如 maxQueueSize=3时阈值为 2即队列 <=1 时跳过
const queueLoadThreshold = Math.ceil(maxQueueSize * 0.5)
if (currentQueueCount <= queueLoadThreshold) {
return {
reject: false,
reason: 'queue_not_loaded',
currentQueueCount,
queueLoadThreshold,
maxQueueSize
}
}
// 获取该 API Key 的等待时间样本
const waitTimes = await redis.getQueueWaitTimes(apiKeyId)
const stats = calculateWaitTimeStats(waitTimes)
// 样本不足(< 10跳过健康检查避免冷启动误判
if (!stats || stats.sampleCount < 10) {
return { reject: false, reason: 'insufficient_samples', sampleCount: stats?.sampleCount || 0 }
}
// P90 不可靠时也跳过(虽然 sampleCount >= 10 时 p90Unreliable 应该是 false
if (stats.p90Unreliable) {
return { reject: false, reason: 'p90_unreliable', sampleCount: stats.sampleCount }
}
// 计算健康阈值P90 >= 超时时间 × 阈值 时拒绝
const threshold = queueConfig.concurrentRequestQueueHealthThreshold || 0.8
const maxAllowedP90 = timeoutMs * threshold
if (stats.p90 >= maxAllowedP90) {
return {
reject: true,
reason: 'queue_overloaded',
estimatedWaitMs: stats.p90,
timeoutMs,
threshold,
sampleCount: stats.sampleCount,
currentQueueCount,
maxQueueSize
}
}
return { reject: false, p90: stats.p90, sampleCount: stats.sampleCount, currentQueueCount }
} catch (error) {
// 健康检查出错时不阻塞请求,记录警告并继续
logger.warn(`Health check failed for ${apiKeyId}:`, error.message)
return { reject: false, reason: 'health_check_error', error: error.message }
}
}
// 排队轮询配置常量(可通过配置文件覆盖)
// 性能权衡:初始间隔越短响应越快,但 Redis QPS 越高
// 当前配置100 个等待者时约 250-300 QPS指数退避后
const QUEUE_POLLING_CONFIG = {
pollIntervalMs: 200, // 初始轮询间隔(毫秒)- 平衡响应速度和 Redis 压力
maxPollIntervalMs: 2000, // 最大轮询间隔(毫秒)- 长时间等待时降低 Redis 压力
backoffFactor: 1.5, // 指数退避系数
jitterRatio: 0.2, // 抖动比例±20%- 防止惊群效应
maxRedisFailCount: 5 // 连续 Redis 失败阈值(从 3 提高到 5提高网络抖动容忍度
}
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
@@ -128,9 +225,223 @@ function isTokenCountRequest(req) {
return false
}
/**
* 等待并发槽位(排队机制核心)
*
* 采用「先占后检查」模式避免竞态条件:
* - 每次轮询时尝试 incrConcurrency 占位
* - 如果超限则 decrConcurrency 释放并继续等待
* - 成功获取槽位后返回,调用方无需再次 incrConcurrency
*
* ⚠️ 重要清理责任说明:
* - 排队计数:此函数的 finally 块负责调用 decrConcurrencyQueue 清理
* - 并发槽位:当返回 acquired=true 时,槽位已被占用(通过 incrConcurrency
* 调用方必须在请求结束时调用 decrConcurrency 释放槽位
* (已在 authenticateApiKey 的 finally 块中处理)
*
* @param {Object} req - Express 请求对象
* @param {Object} res - Express 响应对象
* @param {string} apiKeyId - API Key ID
* @param {Object} queueOptions - 配置参数
* @returns {Promise<Object>} { acquired: boolean, reason?: string, waitTimeMs: number }
*/
async function waitForConcurrencySlot(req, res, apiKeyId, queueOptions) {
const {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs,
pollIntervalMs,
maxPollIntervalMs,
backoffFactor,
jitterRatio,
maxRedisFailCount: configMaxRedisFailCount
} = queueOptions
let clientDisconnected = false
// 追踪轮询过程中是否临时占用了槽位(用于异常时清理)
// 工作流程:
// 1. incrConcurrency 成功且 count <= limit 时,设置 internalSlotAcquired = true
// 2. 统计记录完成后,设置 internalSlotAcquired = false 并返回(所有权转移给调用方)
// 3. 如果在步骤 1-2 之间发生异常finally 块会检测到 internalSlotAcquired = true 并释放槽位
let internalSlotAcquired = false
// 监听客户端断开事件
// ⚠️ 重要:必须监听 socket 的事件,而不是 req 的事件!
// 原因:对于 POST 请求,当 body-parser 读取完请求体后reqIncomingMessage 可读流)
// 的 'close' 事件会立即触发,但这不代表客户端断开连接!客户端仍在等待响应。
// socket 的 'close' 事件才是真正的连接关闭信号。
const { socket } = req
const onSocketClose = () => {
clientDisconnected = true
logger.debug(
`🔌 [Queue] Socket closed during queue wait for API key ${apiKeyId}, requestId: ${requestId}`
)
}
if (socket) {
socket.once('close', onSocketClose)
}
// 检查 socket 是否在监听器注册前已被销毁(边界情况)
if (socket?.destroyed) {
clientDisconnected = true
}
const startTime = Date.now()
let pollInterval = pollIntervalMs
let redisFailCount = 0
// 优先使用配置中的值,否则使用默认值
const maxRedisFailCount = configMaxRedisFailCount || QUEUE_POLLING_CONFIG.maxRedisFailCount
try {
while (Date.now() - startTime < timeoutMs) {
// 检测客户端是否断开(双重检查:事件标记 + socket 状态)
// socket.destroyed 是同步检查,确保即使事件处理有延迟也能及时检测
if (clientDisconnected || socket?.destroyed) {
redis
.incrConcurrencyQueueStats(apiKeyId, 'cancelled')
.catch((e) => logger.warn('Failed to record cancelled stat:', e))
return {
acquired: false,
reason: 'client_disconnected',
waitTimeMs: Date.now() - startTime
}
}
// 尝试获取槽位(先占后检查)
try {
const count = await redis.incrConcurrency(apiKeyId, requestId, leaseSeconds)
redisFailCount = 0 // 重置失败计数
if (count <= concurrencyLimit) {
// 成功获取槽位!
const waitTimeMs = Date.now() - startTime
// 槽位所有权转移说明:
// 1. 此时槽位已通过 incrConcurrency 获取
// 2. 先标记 internalSlotAcquired = true确保异常时 finally 块能清理
// 3. 统计操作完成后,清除标记并返回,所有权转移给调用方
// 4. 调用方authenticateApiKey负责在请求结束时释放槽位
// 标记槽位已获取(用于异常时 finally 块清理)
internalSlotAcquired = true
// 记录统计非阻塞fire-and-forget 模式)
// ⚠️ 设计说明:
// - 故意不 await 这些 Promise因为统计记录不应阻塞请求处理
// - 每个 Promise 都有独立的 .catch(),确保单个失败不影响其他
// - 外层 .catch() 是防御性措施,处理 Promise.all 本身的异常
// - 即使统计记录在函数返回后才完成/失败,也是安全的(仅日志记录)
// - 统计数据丢失可接受,不影响核心业务逻辑
Promise.all([
redis
.recordQueueWaitTime(apiKeyId, waitTimeMs)
.catch((e) => logger.warn('Failed to record queue wait time:', e)),
redis
.recordGlobalQueueWaitTime(waitTimeMs)
.catch((e) => logger.warn('Failed to record global wait time:', e)),
redis
.incrConcurrencyQueueStats(apiKeyId, 'success')
.catch((e) => logger.warn('Failed to increment success stats:', e))
]).catch((e) => logger.warn('Failed to record queue stats batch:', e))
// 成功返回前清除标记(所有权转移给调用方,由其负责释放)
internalSlotAcquired = false
return { acquired: true, waitTimeMs }
}
// 超限,释放槽位继续等待
try {
await redis.decrConcurrency(apiKeyId, requestId)
} catch (decrError) {
// 释放失败时记录警告但继续轮询
// 下次 incrConcurrency 会自然覆盖同一 requestId 的条目
logger.warn(
`Failed to release slot during polling for ${apiKeyId}, will retry:`,
decrError
)
}
} catch (redisError) {
redisFailCount++
logger.error(
`Redis error in queue polling (${redisFailCount}/${maxRedisFailCount}):`,
redisError
)
if (redisFailCount >= maxRedisFailCount) {
// 连续 Redis 失败,放弃排队
return {
acquired: false,
reason: 'redis_error',
waitTimeMs: Date.now() - startTime
}
}
}
// 指数退避等待
await sleep(pollInterval)
// 计算下一次轮询间隔(指数退避 + 抖动)
// 1. 先应用指数退避
let nextInterval = pollInterval * backoffFactor
// 2. 添加抖动防止惊群效应±jitterRatio 范围内的随机偏移)
// 抖动范围:[-jitterRatio, +jitterRatio],例如 jitterRatio=0.2 时为 ±20%
// 这是预期行为:负抖动可使间隔略微缩短,正抖动可使间隔略微延长
// 目的是分散多个等待者的轮询时间点,避免同时请求 Redis
const jitter = nextInterval * jitterRatio * (Math.random() * 2 - 1)
nextInterval = nextInterval + jitter
// 3. 确保在合理范围内:最小 1ms最大 maxPollIntervalMs
// Math.max(1, ...) 保证即使负抖动也不会产生 ≤0 的间隔
pollInterval = Math.max(1, Math.min(nextInterval, maxPollIntervalMs))
}
// 超时
redis
.incrConcurrencyQueueStats(apiKeyId, 'timeout')
.catch((e) => logger.warn('Failed to record timeout stat:', e))
return { acquired: false, reason: 'timeout', waitTimeMs: Date.now() - startTime }
} finally {
// 确保清理:
// 1. 减少排队计数(排队计数在调用方已增加,这里负责减少)
try {
await redis.decrConcurrencyQueue(apiKeyId)
} catch (cleanupError) {
// 清理失败记录错误(可能导致计数泄漏,但有 TTL 保护)
logger.error(
`Failed to decrement queue count in finally block for ${apiKeyId}:`,
cleanupError
)
}
// 2. 如果内部获取了槽位但未正常返回(异常路径),释放槽位
if (internalSlotAcquired) {
try {
await redis.decrConcurrency(apiKeyId, requestId)
logger.warn(
`⚠️ Released orphaned concurrency slot in finally block for ${apiKeyId}, requestId: ${requestId}`
)
} catch (slotCleanupError) {
logger.error(
`Failed to release orphaned concurrency slot for ${apiKeyId}:`,
slotCleanupError
)
}
}
// 清理 socket 事件监听器
if (socket) {
socket.removeListener('close', onSocketClose)
}
}
}
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
const startTime = Date.now()
let authErrored = false
let concurrencyCleanup = null
let hasConcurrencySlot = false
try {
// 安全提取API Key支持多种格式包括Gemini CLI支持
@@ -141,7 +452,7 @@ const authenticateApiKey = async (req, res, next) => {
}
if (!apiKey) {
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
logger.security(`Missing API key attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing API key',
message:
@@ -151,7 +462,7 @@ const authenticateApiKey = async (req, res, next) => {
// 基本API Key格式验证
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`)
logger.security(`Invalid API key format from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
@@ -163,7 +474,7 @@ const authenticateApiKey = async (req, res, next) => {
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`)
logger.security(`Invalid API key attempt: ${validation.error} from ${clientIP}`)
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
@@ -265,33 +576,86 @@ const authenticateApiKey = async (req, res, next) => {
}
const requestId = uuidv4()
// ⚠️ 优化后的 Connection: close 设置策略
// 问题背景HTTP Keep-Alive 使多个请求共用同一个 TCP 连接
// 当第一个请求正在处理,第二个请求进入排队时,它们共用同一个 socket
// 如果客户端超时关闭连接,两个请求都会受影响
// 优化方案:只有在请求实际进入排队时才设置 Connection: close
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
// 详见 design.md Decision 2: Connection: close 设置时机
// 注意Connection: close 将在下方代码实际进入排队时设置(第 637 行左右)
// ============================================================
// 🔒 并发槽位状态管理说明
// ============================================================
// 此函数中有两个关键状态变量:
// - hasConcurrencySlot: 当前是否持有并发槽位
// - concurrencyCleanup: 错误时调用的清理函数
//
// 状态转换流程:
// 1. incrConcurrency 成功 → hasConcurrencySlot=true, 设置临时清理函数
// 2. 若超限 → 释放槽位hasConcurrencySlot=false, concurrencyCleanup=null
// 3. 若排队成功 → hasConcurrencySlot=true, 升级为完整清理函数(含 interval 清理)
// 4. 请求结束res.close/req.close→ 调用 decrementConcurrency 释放
// 5. 认证错误 → finally 块调用 concurrencyCleanup 释放
//
// 为什么需要两种清理函数?
// - 临时清理:在排队/认证过程中出错时使用,只释放槽位
// - 完整清理:请求正常开始后使用,还需清理 leaseRenewInterval
// ============================================================
const setTemporaryConcurrencyCleanup = () => {
concurrencyCleanup = async () => {
if (!hasConcurrencySlot) {
return
}
hasConcurrencySlot = false
try {
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (cleanupError) {
logger.error(
`Failed to decrement concurrency after auth error for key ${validation.keyData.id}:`,
cleanupError
)
}
}
}
const currentConcurrency = await redis.incrConcurrency(
validation.keyData.id,
requestId,
leaseSeconds
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
logger.api(
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
)
if (currentConcurrency > concurrencyLimit) {
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
// 1. 先释放刚占用的槽位
try {
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
logger.api(
`📉 Decremented concurrency (429 rejected) for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
)
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (error) {
logger.error(
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
error
)
}
hasConcurrencySlot = false
concurrencyCleanup = null
// 2. 获取排队配置
const queueConfig = await claudeRelayConfigService.getConfig()
// 3. 排队功能未启用,直接返回 429保持现有行为
if (!queueConfig.concurrentRequestQueueEnabled) {
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
)
// 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放)
res.set('Retry-After', '1')
return res.status(429).json({
error: 'Concurrency limit exceeded',
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
@@ -300,6 +664,260 @@ const authenticateApiKey = async (req, res, next) => {
})
}
// 4. 计算最大排队数
const maxQueueSize = Math.max(
concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier,
queueConfig.concurrentRequestQueueMaxSize
)
// 4.5 排队健康检查:过载时快速失败
// 详见 design.md Decision 7: 排队健康检查与快速失败
const overloadCheck = await shouldRejectDueToOverload(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs,
queueConfig,
maxQueueSize
)
if (overloadCheck.reject) {
// 使用健康检查返回的当前排队数,避免重复调用 Redis
const currentQueueCount = overloadCheck.currentQueueCount || 0
logger.api(
`🚨 Queue overloaded for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`P90=${overloadCheck.estimatedWaitMs}ms, timeout=${overloadCheck.timeoutMs}ms, ` +
`threshold=${overloadCheck.threshold}, samples=${overloadCheck.sampleCount}, ` +
`concurrency=${concurrencyLimit}, queue=${currentQueueCount}/${maxQueueSize}`
)
// 记录被拒绝的过载统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'rejected_overload')
.catch((e) => logger.warn('Failed to record rejected_overload stat:', e))
// 返回 429 + Retry-After让客户端稍后重试
const retryAfterSeconds = 30
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue overloaded',
message: `Queue is overloaded. Estimated wait time (${overloadCheck.estimatedWaitMs}ms) exceeds threshold. Limit: ${concurrencyLimit} concurrent requests, queue: ${currentQueueCount}/${maxQueueSize}. Please retry later.`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: currentQueueCount,
maxQueueSize,
estimatedWaitMs: overloadCheck.estimatedWaitMs,
timeoutMs: overloadCheck.timeoutMs,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 5. 尝试进入排队(原子操作:先增加再检查,避免竞态条件)
let queueIncremented = false
try {
const newQueueCount = await redis.incrConcurrencyQueue(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs
)
queueIncremented = true
if (newQueueCount > maxQueueSize) {
// 超过最大排队数,立即释放并返回 429
await redis.decrConcurrencyQueue(validation.keyData.id)
queueIncremented = false
logger.api(
`🚦 Concurrency queue full for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue: ${newQueueCount - 1}, maxQueue: ${maxQueueSize}`
)
// 队列已满,建议客户端在排队超时时间后重试
const retryAfterSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Concurrency queue full',
message: `Too many requests waiting in queue. Limit: ${concurrencyLimit} concurrent requests, queue: ${newQueueCount - 1}/${maxQueueSize}, timeout: ${retryAfterSeconds}s`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: newQueueCount - 1,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 6. 已成功进入排队,记录统计并开始等待槽位
logger.api(
`⏳ Request entering queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue position: ${newQueueCount}`
)
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'entered')
.catch((e) => logger.warn('Failed to record entered stat:', e))
// ⚠️ 仅在请求实际进入排队时设置 Connection: close
// 详见 design.md Decision 2: Connection: close 设置时机
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
if (!res.headersSent) {
res.setHeader('Connection', 'close')
logger.api(
`🔌 [Queue] Set Connection: close for queued request, key: ${validation.keyData.id}`
)
}
// ⚠️ 记录排队开始时的 socket 标识,用于排队完成后验证
// 问题背景HTTP Keep-Alive 连接复用时,长时间排队可能导致 socket 被其他请求使用
// 验证方法:使用 UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
req._crService = req._crService || {}
req._crService.queueToken = uuidv4()
req._crService.originalSocket = req.socket
req._crService.startTime = Date.now()
const savedToken = req._crService.queueToken
const savedSocket = req._crService.originalSocket
// ⚠️ 重要:在调用前将 queueIncremented 设为 false
// 因为 waitForConcurrencySlot 的 finally 块会负责清理排队计数
// 如果在调用后设置,当 waitForConcurrencySlot 抛出异常时
// 外层 catch 块会重复减少计数finally 已经减过一次)
queueIncremented = false
const slot = await waitForConcurrencySlot(req, res, validation.keyData.id, {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
pollIntervalMs: QUEUE_POLLING_CONFIG.pollIntervalMs,
maxPollIntervalMs: QUEUE_POLLING_CONFIG.maxPollIntervalMs,
backoffFactor: QUEUE_POLLING_CONFIG.backoffFactor,
jitterRatio: QUEUE_POLLING_CONFIG.jitterRatio,
maxRedisFailCount: queueConfig.concurrentRequestQueueMaxRedisFailCount
})
// 7. 处理排队结果
if (!slot.acquired) {
if (slot.reason === 'client_disconnected') {
// 客户端已断开,不返回响应(连接已关闭)
logger.api(
`🔌 Client disconnected while queuing for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return
}
if (slot.reason === 'redis_error') {
// Redis 连续失败,返回 503
logger.error(
`❌ Redis error during queue wait for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return res.status(503).json({
error: 'Service temporarily unavailable',
message: 'Failed to acquire concurrency slot due to internal error'
})
}
// 排队超时(使用 api 级别,与其他排队日志保持一致)
logger.api(
`⏰ Queue timeout for key: ${validation.keyData.id} (${validation.keyData.name}), waited: ${slot.waitTimeMs}ms`
)
// 已等待超时,建议客户端稍后重试
// ⚠️ Retry-After 策略优化:
// - 请求已经等了完整的 timeout 时间,说明系统负载较高
// - 过早重试(如固定 5 秒)会加剧拥塞,导致更多超时
// - 合理策略:使用 timeout 时间的一半作为重试间隔
// - 最小值 5 秒,最大值 30 秒,避免极端情况
const timeoutSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
const retryAfterSeconds = Math.max(5, Math.min(30, Math.ceil(timeoutSeconds / 2)))
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue timeout',
message: `Request timed out waiting for concurrency slot. Limit: ${concurrencyLimit} concurrent requests, maxQueue: ${maxQueueSize}, Queue timeout: ${timeoutSeconds}s, waited: ${slot.waitTimeMs}ms`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
waitTimeMs: slot.waitTimeMs,
retryAfterSeconds
})
}
// 8. 排队成功slot.acquired 表示已在 waitForConcurrencySlot 中获取到槽位
logger.api(
`✅ Queue wait completed for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms`
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
// 9. ⚠️ 关键检查:排队等待结束后,验证客户端是否还在等待响应
// 长时间排队后,客户端可能在应用层已放弃(如 Claude Code 的超时机制),
// 但 TCP 连接仍然存活。此时继续处理请求是浪费资源。
// 注意如果发送了心跳headersSent 会是 true但这是正常的
const postQueueSocket = req.socket
// 只检查连接是否真正断开destroyed/writableEnded/socketDestroyed
// headersSent 在心跳场景下是正常的,不应该作为放弃的依据
if (res.destroyed || res.writableEnded || postQueueSocket?.destroyed) {
logger.warn(
`⚠️ Client no longer waiting after queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | destroyed: ${res.destroyed}, ` +
`writableEnded: ${res.writableEnded}, socketDestroyed: ${postQueueSocket?.destroyed}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after client abandoned:', e))
// 不返回响应(客户端已不在等待)
return
}
// 10. ⚠️ 关键检查:验证 socket 身份是否改变
// HTTP Keep-Alive 连接复用可能导致排队期间 socket 被其他请求使用
// 验证方法UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
const queueData = req._crService
const socketIdentityChanged =
!queueData ||
queueData.queueToken !== savedToken ||
queueData.originalSocket !== savedSocket
if (socketIdentityChanged) {
logger.error(
`❌ [Queue] Socket identity changed during queue wait! ` +
`key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | ` +
`tokenMatch: ${queueData?.queueToken === savedToken}, ` +
`socketMatch: ${queueData?.originalSocket === savedSocket}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after socket identity change:', e))
// 记录 socket_changed 统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'socket_changed')
.catch((e) => logger.warn('Failed to record socket_changed stat:', e))
// 不返回响应socket 已被其他请求使用)
return
}
} catch (queueError) {
// 异常时清理资源,防止泄漏
// 1. 清理排队计数(如果还没被 waitForConcurrencySlot 的 finally 清理)
if (queueIncremented) {
await redis
.decrConcurrencyQueue(validation.keyData.id)
.catch((e) => logger.error('Failed to cleanup queue count after error:', e))
}
// 2. 防御性清理:如果 waitForConcurrencySlot 内部获取了槽位但在返回前异常
// 虽然这种情况极少发生(统计记录的异常会被内部捕获),但为了安全起见
// 尝试释放可能已获取的槽位。decrConcurrency 使用 ZREM即使成员不存在也安全
if (hasConcurrencySlot) {
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) =>
logger.error('Failed to cleanup concurrency slot after queue error:', e)
)
}
throw queueError
}
}
const renewIntervalMs =
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0
@@ -358,6 +976,7 @@ const authenticateApiKey = async (req, res, next) => {
const decrementConcurrency = async () => {
if (!concurrencyDecremented) {
concurrencyDecremented = true
hasConcurrencySlot = false
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
@@ -372,6 +991,11 @@ const authenticateApiKey = async (req, res, next) => {
}
}
}
// 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑)
// 此时请求已通过认证,后续由 res.close/req.close 事件触发清理
if (hasConcurrencySlot) {
concurrencyCleanup = decrementConcurrency
}
// 监听最可靠的事件(避免重复监听)
// res.on('close') 是最可靠的,会在连接关闭时触发
@@ -572,12 +1196,16 @@ const authenticateApiKey = async (req, res, next) => {
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
)
return res.status(429).json({
error: 'Daily cost limit exceeded',
// 使用 402 Payment Required 而非 429避免客户端自动重试
return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到每日费用限制 ($${dailyCostLimit})`,
code: 'daily_cost_limit_exceeded'
},
currentCost: dailyCost,
costLimit: dailyCostLimit,
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString()
})
}
@@ -601,9 +1229,13 @@ const authenticateApiKey = async (req, res, next) => {
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
)
return res.status(429).json({
error: 'Total cost limit exceeded',
// 使用 402 Payment Required 而非 429避免客户端自动重试
return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到总费用限制 ($${totalCostLimit})`,
code: 'total_cost_limit_exceeded'
},
currentCost: totalCost,
costLimit: totalCostLimit
})
@@ -616,20 +1248,20 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
// 检查 Claude 周费用限制
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息
const requestBody = req.body || {}
const model = requestBody.model || ''
// 判断是否为 Opus 模型
if (model && model.toLowerCase().includes('claude-opus')) {
// 判断是否为 Claude 模型
if (isClaudeFamilyModel(model)) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
@@ -642,18 +1274,22 @@ const authenticateApiKey = async (req, res, next) => {
resetDate.setDate(now.getDate() + daysUntilMonday)
resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({
error: 'Weekly Opus cost limit exceeded',
// 使用 402 Payment Required 而非 429避免客户端自动重试
return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
code: 'weekly_opus_cost_limit_exceeded'
},
currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置
resetAt: resetDate.toISOString()
})
}
// 记录当前 Opus 费用使用情况
// 记录当前 Claude 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
@@ -683,10 +1319,8 @@ const authenticateApiKey = async (req, res, next) => {
dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost,
totalCostLimit: validation.keyData.totalCostLimit,
totalCost: validation.keyData.totalCost,
usage: validation.keyData.usage
totalCost: validation.keyData.totalCost
}
req.usage = validation.keyData.usage
const authDuration = Date.now() - startTime
const userAgent = req.headers['user-agent'] || 'No User-Agent'
@@ -697,6 +1331,7 @@ const authenticateApiKey = async (req, res, next) => {
return next()
} catch (error) {
authErrored = true
const authDuration = Date.now() - startTime
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
error: error.message,
@@ -710,6 +1345,14 @@ const authenticateApiKey = async (req, res, next) => {
error: 'Authentication error',
message: 'Internal server error during authentication'
})
} finally {
if (authErrored && typeof concurrencyCleanup === 'function') {
try {
await concurrencyCleanup()
} catch (cleanupError) {
logger.error('Failed to cleanup concurrency after auth error:', cleanupError)
}
}
}
}
@@ -725,7 +1368,7 @@ const authenticateAdmin = async (req, res, next) => {
req.headers['x-admin-token']
if (!token) {
logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`)
logger.security(`Missing admin token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing admin token',
message: 'Please provide an admin token'
@@ -734,7 +1377,7 @@ const authenticateAdmin = async (req, res, next) => {
// 基本token格式验证
if (typeof token !== 'string' || token.length < 32 || token.length > 512) {
logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`)
logger.security(`Invalid admin token format from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid admin token format',
message: 'Admin token format is invalid'
@@ -750,13 +1393,25 @@ const authenticateAdmin = async (req, res, next) => {
])
if (!adminSession || Object.keys(adminSession).length === 0) {
logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`)
logger.security(`Invalid admin token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid admin token',
message: 'Invalid or expired admin session'
})
}
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 检查会话活跃性(可选:检查最后活动时间)
const now = new Date()
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
@@ -790,14 +1445,13 @@ const authenticateAdmin = async (req, res, next) => {
// 设置管理员信息(只包含必要信息)
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: token,
loginTime: adminSession.loginTime
}
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
} catch (error) {
@@ -828,7 +1482,7 @@ const authenticateUser = async (req, res, next) => {
req.headers['x-user-token']
if (!sessionToken) {
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`)
logger.security(`Missing user session token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing user session token',
message: 'Please login to access this resource'
@@ -837,7 +1491,7 @@ const authenticateUser = async (req, res, next) => {
// 基本token格式验证
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) {
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`)
logger.security(`Invalid user session token format from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid session token format',
message: 'Session token format is invalid'
@@ -848,7 +1502,7 @@ const authenticateUser = async (req, res, next) => {
const sessionValidation = await userService.validateUserSession(sessionToken)
if (!sessionValidation) {
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`)
logger.security(`Invalid user session token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid session token',
message: 'Invalid or expired user session'
@@ -923,8 +1577,15 @@ const authenticateUserOrAdmin = async (req, res, next) => {
try {
const adminSession = await redis.getSession(adminToken)
if (adminSession && Object.keys(adminSession).length > 0) {
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
// 不返回 401继续尝试用户认证
} else {
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
@@ -932,9 +1593,10 @@ const authenticateUserOrAdmin = async (req, res, next) => {
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
}
}
} catch (error) {
logger.debug('Admin authentication failed, trying user authentication:', error.message)
}
@@ -972,7 +1634,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
}
// 如果都失败了,返回未授权
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`)
logger.security(`Authentication failed from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Authentication required',
message: 'Please login as user or admin to access this resource'
@@ -1112,10 +1774,14 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none'
// 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') {
// 避免健康检查日志过多
if (isDebugRoute) {
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
}
}
res.on('finish', () => {
const duration = Date.now() - start
@@ -1146,8 +1812,15 @@ const requestLogger = (req, res, next) => {
logMetadata
)
} else if (req.originalUrl !== '/health') {
if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
}
}
// API Key相关日志
if (req.apiKey) {
@@ -1388,7 +2061,8 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const maxSize = 60 * 1024 * 1024 // 60MB
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) {
@@ -1396,7 +2070,7 @@ const requestSizeLimit = (req, res, next) => {
return res.status(413).json({
error: 'Payload Too Large',
message: 'Request body size exceeds limit',
limit: '10MB'
limit: `${MAX_SIZE_MB}MB`
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const accountBalanceService = require('../../services/accountBalanceService')
const balanceScriptService = require('../../services/balanceScriptService')
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
const router = express.Router()
const ensureValidPlatform = (rawPlatform) => {
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
if (!normalized) {
return { ok: false, status: 400, error: '缺少 platform 参数' }
}
const supported = accountBalanceService.getSupportedPlatforms()
if (!supported.includes(normalized)) {
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
}
return { ok: true, platform: normalized }
}
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform, queryApi } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
queryApi
})
if (!balance) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
return res.json(balance)
} catch (error) {
logger.error('获取账户余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 2) 强制刷新账户余额强制触发查询优先脚本Provider 仅为降级)
// POST /admin/accounts/:accountId/balance/refresh
// Body: { platform: 'xxx' }
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.body || {}
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
if (!balance) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
return res.json(balance)
} catch (error) {
logger.error('刷新账户余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 3) 批量获取平台所有账户余额
// GET /admin/accounts/balance/platform/:platform?queryApi=false
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
try {
const { platform } = req.params
const { queryApi } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
return res.json({ success: true, data: balances })
} catch (error) {
logger.error('批量获取余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 4) 获取余额汇总Dashboard 用)
// GET /admin/accounts/balance/summary
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
try {
const summary = await accountBalanceService.getBalanceSummary()
return res.json({ success: true, data: summary })
} catch (error) {
logger.error('获取余额汇总失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 5) 清除缓存
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
await accountBalanceService.clearCache(accountId, valid.platform)
return res.json({ success: true, message: '缓存已清除' })
} catch (error) {
logger.error('清除缓存失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 6) 获取/保存/测试余额脚本配置(单账户)
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const config = await accountBalanceService.redis.getBalanceScriptConfig(
valid.platform,
accountId
)
return res.json({ success: true, data: config || null })
} catch (error) {
logger.error('获取余额脚本配置失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const payload = req.body || {}
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
return res.json({ success: true, data: payload })
} catch (error) {
logger.error('保存余额脚本配置失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
if (!isBalanceScriptEnabled()) {
return res.status(403).json({
success: false,
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
})
}
const payload = req.body || {}
const { scriptBody } = payload
if (!scriptBody) {
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
}
const result = await balanceScriptService.execute({
scriptBody,
timeoutSeconds: payload.timeoutSeconds || 10,
variables: {
baseUrl: payload.baseUrl || '',
apiKey: payload.apiKey || '',
token: payload.token || '',
accountId,
platform: valid.platform,
extra: payload.extra || ''
}
})
return res.json({ success: true, data: result })
} catch (error) {
logger.error('测试余额脚本失败', error)
return res.status(400).json({ success: false, error: error.message })
}
})
module.exports = router

View File

@@ -8,6 +8,64 @@ const config = require('../../../config/config')
const router = express.Router()
// 有效的权限值列表
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
/**
* 验证权限数组格式
* @param {any} permissions - 权限值(可以是数组或其他)
* @returns {string|null} - 返回错误消息null 表示验证通过
*/
function validatePermissions(permissions) {
// 空值或未定义表示全部服务
if (permissions === undefined || permissions === null || permissions === '') {
return null
}
// 兼容旧格式字符串
if (typeof permissions === 'string') {
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
return null
}
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
}
// 新格式数组
if (Array.isArray(permissions)) {
// 空数组表示全部服务
if (permissions.length === 0) {
return null
}
// 验证数组中的每个值
for (const perm of permissions) {
if (!VALID_PERMISSIONS.includes(perm)) {
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
}
return null
}
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
/**
* 验证 serviceRates 格式
* @param {any} serviceRates - 服务倍率对象
* @returns {string|null} - 返回错误消息null 表示验证通过
*/
function validateServiceRates(serviceRates) {
if (serviceRates === undefined || serviceRates === null) {
return null
}
if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) {
return 'Service rates must be an object'
}
for (const [service, rate] of Object.entries(serviceRates)) {
const numRate = Number(rate)
if (!Number.isFinite(numRate) || numRate < 0) {
return `Invalid rate for service "${service}": must be a non-negative number`
}
}
return null
}
// 👥 用户管理 (用于API Key分配)
// 获取所有用户列表用于API Key分配
@@ -79,14 +137,14 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
const costStats = await redis.getCostStats(keyId)
const dailyCost = await redis.getDailyCost(keyId)
const today = redis.getDateStringInTimezone()
const client = redis.getClientSafe()
// 获取所有相关的Redis键
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`)
const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`)
const costValues = await redis.batchGetChunked(costKeys)
const keyValues = {}
for (const key of costKeys) {
keyValues[key] = await client.get(key)
for (let i = 0; i < costKeys.length; i++) {
keyValues[costKeys[i]] = costValues[i]
}
return res.json({
@@ -287,20 +345,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
})
}
// 为每个API Key添加owner的displayName
// 为每个API Key添加owner的displayName(批量获取优化)
const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))]
const userMap = new Map()
if (userIdsToFetch.length > 0) {
// 批量获取用户信息
const users = await Promise.all(
userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null))
)
userIdsToFetch.forEach((id, i) => {
if (users[i]) {
userMap.set(id, users[i])
}
})
}
for (const apiKey of result.items) {
if (apiKey.userId) {
try {
const user = await userService.getUserById(apiKey.userId, false)
if (user) {
if (apiKey.userId && userMap.has(apiKey.userId)) {
const user = userMap.get(apiKey.userId)
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
} else {
} else if (apiKey.userId) {
apiKey.ownerDisplayName = 'Unknown User'
}
} catch (error) {
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
apiKey.ownerDisplayName = 'Unknown User'
}
} else {
apiKey.ownerDisplayName =
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
@@ -571,6 +637,56 @@ router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) =>
}
})
// 获取 API Key 索引状态
router.get('/api-keys/index-status', authenticateAdmin, async (req, res) => {
try {
const apiKeyIndexService = require('../../services/apiKeyIndexService')
const status = await apiKeyIndexService.getStatus()
return res.json({ success: true, data: status })
} catch (error) {
logger.error('❌ Failed to get API Key index status:', error)
return res.status(500).json({
success: false,
error: 'Failed to get index status',
message: error.message
})
}
})
// 手动重建 API Key 索引
router.post('/api-keys/index-rebuild', authenticateAdmin, async (req, res) => {
try {
const apiKeyIndexService = require('../../services/apiKeyIndexService')
const status = await apiKeyIndexService.getStatus()
if (status.building) {
return res.status(409).json({
success: false,
error: 'INDEX_BUILDING',
message: '索引正在重建中,请稍后再试',
progress: status.progress
})
}
// 异步重建,不等待完成
apiKeyIndexService.rebuildIndexes().catch((err) => {
logger.error('❌ Failed to rebuild API Key index:', err)
})
return res.json({
success: true,
message: 'API Key 索引重建已开始'
})
} catch (error) {
logger.error('❌ Failed to trigger API Key index rebuild:', error)
return res.status(500).json({
success: false,
error: 'Failed to trigger rebuild',
message: error.message
})
}
})
// 强制刷新费用排序索引
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
try {
@@ -636,22 +752,7 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
// 获取已存在的标签列表
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
try {
const apiKeys = await apiKeyService.getAllApiKeys()
const tagSet = new Set()
// 收集所有API Keys的标签
for (const apiKey of apiKeys) {
if (apiKey.tags && Array.isArray(apiKey.tags)) {
apiKey.tags.forEach((tag) => {
if (tag && tag.trim()) {
tagSet.add(tag.trim())
}
})
}
}
// 转换为数组并排序
const tags = Array.from(tagSet).sort()
const tags = await apiKeyService.getAllTags()
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
return res.json({ success: true, data: tags })
@@ -661,6 +762,93 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
}
})
// 获取标签详情(含使用数量)
router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => {
try {
const tagDetails = await apiKeyService.getTagsWithCount()
logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`)
return res.json({ success: true, data: tagDetails })
} catch (error) {
logger.error('❌ Failed to get tag details:', error)
return res.status(500).json({ error: 'Failed to get tag details', message: error.message })
}
})
// 创建新标签
router.post('/api-keys/tags', authenticateAdmin, async (req, res) => {
try {
const { name } = req.body
if (!name || !name.trim()) {
return res.status(400).json({ error: '标签名称不能为空' })
}
const result = await apiKeyService.createTag(name.trim())
if (!result.success) {
return res.status(400).json({ error: result.error })
}
logger.info(`🏷️ Created new tag: ${name}`)
return res.json({ success: true, message: '标签创建成功' })
} catch (error) {
logger.error('❌ Failed to create tag:', error)
return res.status(500).json({ error: 'Failed to create tag', message: error.message })
}
})
// 删除标签(从所有 API Key 中移除)
router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
try {
const { tagName } = req.params
if (!tagName) {
return res.status(400).json({ error: 'Tag name is required' })
}
const decodedTagName = decodeURIComponent(tagName)
const result = await apiKeyService.removeTagFromAllKeys(decodedTagName)
logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`)
return res.json({
success: true,
message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`,
affectedCount: result.affectedCount
})
} catch (error) {
logger.error('❌ Failed to delete tag:', error)
return res.status(500).json({ error: 'Failed to delete tag', message: error.message })
}
})
// 重命名标签
router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
try {
const { tagName } = req.params
const { newName } = req.body
if (!tagName || !newName || !newName.trim()) {
return res.status(400).json({ error: 'Tag name and new name are required' })
}
const decodedTagName = decodeURIComponent(tagName)
const trimmedNewName = newName.trim()
const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName)
if (result.error) {
return res.status(400).json({ error: result.error })
}
logger.info(
`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`
)
return res.json({
success: true,
message: `Tag renamed in ${result.affectedCount} API keys`,
affectedCount: result.affectedCount
})
} catch (error) {
logger.error('❌ Failed to rename tag:', error)
return res.status(500).json({ error: 'Failed to rename tag', message: error.message })
}
})
/**
* 获取账户绑定的 API Key 数量统计
* GET /admin/accounts/binding-counts
@@ -945,6 +1133,30 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
}
// 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost
// 因为 usage:*:model:daily:* 键有 30 天 TTL旧数据已经过期
if (timeRange === 'all' && allTimeCost > 0) {
logger.debug(`📊 使用 allTimeCost 计算 timeRange='all': ${allTimeCost}`)
return {
requests: 0, // 旧数据详情不可用
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: allTimeCost,
formattedCost: CostCalculator.formatCost(allTimeCost),
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
// 只在启用了窗口限制时查询窗口数据
if (rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
@@ -1006,12 +1218,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const modelStatsMap = new Map()
let totalRequests = 0
// 用于去重:统计数据,避免与数据重复
// 用于去重:统计数据,避免与数据重复
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
// 检查是否有日数据
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
@@ -1038,8 +1248,12 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
continue
}
// 如果有日数据,则跳过月数据以避免重复
if (hasDailyData && isMonthly) {
// 跳过当前月的月数据
if (isMonthly && key.includes(`:${currentMonth}`)) {
continue
}
// 跳过非当前月的日数据
if (!isMonthly && !key.includes(`:${currentMonth}-`)) {
continue
}
@@ -1235,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
activationDays, // 新增:激活后有效天数
activationUnit, // 新增:激活时间单位 (hours/days)
expirationMode, // 新增:过期模式
icon // 新增:图标
icon, // 新增:图标
serviceRates // API Key 级别服务倍率
} = req.body
// 输入验证
@@ -1356,16 +1571,16 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
}
}
// 验证服务权限字段
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const permissionsError = validatePermissions(permissions)
if (permissionsError) {
return res.status(400).json({ error: permissionsError })
}
// 验证服务倍率
const serviceRatesError = validateServiceRates(serviceRates)
if (serviceRatesError) {
return res.status(400).json({ error: serviceRatesError })
}
const newKey = await apiKeyService.generateApiKey({
@@ -1395,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
activationDays,
activationUnit,
expirationMode,
icon
icon,
serviceRates
})
logger.success(`🔑 Admin created new API key: ${name}`)
@@ -1437,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays,
activationUnit,
expirationMode,
icon
icon,
serviceRates
} = req.body
// 输入验证
@@ -1455,15 +1672,16 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
}
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const batchPermissionsError = validatePermissions(permissions)
if (batchPermissionsError) {
return res.status(400).json({ error: batchPermissionsError })
}
// 验证服务倍率
const batchServiceRatesError = validateServiceRates(serviceRates)
if (batchServiceRatesError) {
return res.status(400).json({ error: batchServiceRatesError })
}
// 生成批量API Keys
@@ -1500,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays,
activationUnit,
expirationMode,
icon
icon,
serviceRates
})
// 保留原始 API Key 供返回
@@ -1566,13 +1785,20 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
})
}
if (
updates.permissions !== undefined &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
if (updates.permissions !== undefined) {
const updatePermissionsError = validatePermissions(updates.permissions)
if (updatePermissionsError) {
return res.status(400).json({ error: updatePermissionsError })
}
}
// 验证服务倍率
if (updates.serviceRates !== undefined) {
const updateServiceRatesError = validateServiceRates(updates.serviceRates)
if (updateServiceRatesError) {
return res.status(400).json({ error: updateServiceRatesError })
}
}
logger.info(
@@ -1643,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
if (updates.enabled !== undefined) {
finalUpdates.enabled = updates.enabled
}
if (updates.serviceRates !== undefined) {
finalUpdates.serviceRates = updates.serviceRates
}
// 处理账户绑定
if (updates.claudeAccountId !== undefined) {
@@ -1699,7 +1928,7 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
// 执行更新
await apiKeyService.updateApiKey(keyId, finalUpdates)
results.successCount++
logger.success(`Batch edit: API key ${keyId} updated successfully`)
logger.success(`Batch edit: API key ${keyId} updated successfully`)
} catch (error) {
results.failedCount++
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
@@ -1760,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
totalCostLimit,
weeklyOpusCostLimit,
tags,
ownerId // 新增所有者ID字段
ownerId, // 新增所有者ID字段
serviceRates // API Key 级别服务倍率
} = req.body
// 只允许更新指定字段
@@ -1847,11 +2077,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
if (permissions !== undefined) {
// 验证权限值
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const singlePermissionsError = validatePermissions(permissions)
if (singlePermissionsError) {
return res.status(400).json({ error: singlePermissionsError })
}
updates.permissions = permissions
}
@@ -1947,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.tags = tags
}
// 处理服务倍率
if (serviceRates !== undefined) {
const singleServiceRatesError = validateServiceRates(serviceRates)
if (singleServiceRatesError) {
return res.status(400).json({ error: singleServiceRatesError })
}
updates.serviceRates = serviceRates
}
// 处理活跃/禁用状态状态, 放在过期处理后以确保后续增加禁用key功能
if (isActive !== undefined) {
if (typeof isActive !== 'boolean') {
@@ -2150,7 +2388,7 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
await apiKeyService.deleteApiKey(keyId)
results.successCount++
logger.success(`Batch delete: API key ${keyId} deleted successfully`)
logger.success(`Batch delete: API key ${keyId} deleted successfully`)
} catch (error) {
results.failedCount++
results.errors.push({
@@ -2205,13 +2443,13 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
// 📋 获取已删除的API Keys
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
try {
const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true')
const deletedApiKeys = await apiKeyService.getAllApiKeysFast(true) // Include deleted
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === true)
// Add additional metadata for deleted keys
const enrichedKeys = onlyDeleted.map((key) => ({
...key,
isDeleted: key.isDeleted === 'true',
isDeleted: key.isDeleted === true,
deletedAt: key.deletedAt,
deletedBy: key.deletedBy,
deletedByType: key.deletedByType,
@@ -2238,7 +2476,7 @@ router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
if (result.success) {
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
return res.json({
success: true,
message: 'API Key 已成功恢复',

View File

@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
}
})
// 测试 Azure OpenAI 账户连通性
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const startTime = Date.now()
try {
// 获取账户信息
const account = await azureOpenaiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 获取解密后的 API Key
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
if (!apiKey) {
return res.status(401).json({ error: 'API Key not found or decryption failed' })
}
// 构造测试请求
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
const { getProxyAgent } = require('../../utils/proxyHelper')
const deploymentName = account.deploymentName || 'gpt-4o-mini'
const apiVersion = account.apiVersion || '2024-02-15-preview'
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
const payload = createOpenAITestPayload(deploymentName)
const requestConfig = {
headers: {
'Content-Type': 'application/json',
'api-key': apiKey
},
timeout: 30000
}
// 配置代理
if (account.proxy) {
const agent = getProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime
// 提取响应文本
let responseText = ''
if (response.data?.choices?.[0]?.message?.content) {
responseText = response.data.choices[0].message.content
}
logger.success(
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
)
return res.json({
success: true,
data: {
accountId,
accountName: account.name,
model: deploymentName,
latency,
responseText: responseText.substring(0, 200)
}
})
} catch (error) {
const latency = Date.now() - startTime
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
return res.status(500).json({
success: false,
error: 'Test failed',
message: error.response?.data?.error?.message || error.message,
latency
})
}
})
module.exports = router

View File

@@ -0,0 +1,41 @@
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const balanceScriptService = require('../../services/balanceScriptService')
const router = express.Router()
// 获取全部脚本配置列表
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
const items = balanceScriptService.listConfigs()
return res.json({ success: true, data: items })
})
// 获取单个脚本配置
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
const { name } = req.params
const config = balanceScriptService.getConfig(name || 'default')
return res.json({ success: true, data: config })
})
// 保存脚本配置
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
try {
const { name } = req.params
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
return res.json({ success: true, data: saved })
} catch (error) {
return res.status(400).json({ success: false, error: error.message })
}
})
// 测试脚本(不落库)
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
try {
const { name } = req.params
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
return res.json({ success: true, data: result })
} catch (error) {
return res.status(400).json({ success: false, error: error.message })
}
})
module.exports = router

View File

@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
description,
region,
awsCredentials,
bearerToken,
defaultModel,
priority,
accountType,
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
}
// 验证credentialType的有效性
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
})
}
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
description: description || '',
region: region || 'us-east-1',
awsCredentials,
bearerToken,
defaultModel,
priority: priority || 50,
accountType: accountType || 'shared',
credentialType: credentialType || 'default'
credentialType: credentialType || 'access_key'
})
if (!result.success) {
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
// 验证credentialType的有效性
if (
mappedUpdates.credentialType &&
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
})
}
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
}
})
// 测试Bedrock账户连接
// 测试Bedrock账户连接SSE 流式)
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await bedrockAccountService.testAccount(accountId)
if (!result.success) {
return res.status(500).json({ error: 'Account test failed', message: result.error })
}
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
return res.json({ success: true, data: result.data })
await bedrockAccountService.testAccountConnection(accountId, res)
} catch (error) {
logger.error('❌ Failed to test Bedrock account:', error)
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
// 错误已在服务层处理,这里仅做日志记录
}
})

View File

@@ -377,7 +377,7 @@ router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
await ccrAccountService.resetDailyUsage(accountId)
logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`)
logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`)
return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset CCR account daily usage:', error)
@@ -390,7 +390,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await ccrAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for CCR account: ${accountId}`)
logger.success(`Admin reset status for CCR account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset CCR account status:', error)
@@ -403,7 +403,7 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
try {
await ccrAccountService.resetAllDailyUsage()
logger.success('Admin manually reset daily usage for all CCR accounts')
logger.success('Admin manually reset daily usage for all CCR accounts')
return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
}
})
// 测试 CCR 账户连通性
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { model = 'claude-sonnet-4-20250514' } = req.body
const startTime = Date.now()
try {
// 获取账户信息
const account = await ccrAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 获取解密后的凭据
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
if (!credentials) {
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
}
// 构造测试请求
const axios = require('axios')
const { getProxyAgent } = require('../../utils/proxyHelper')
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
const apiUrl = `${baseUrl}/v1/messages`
const payload = {
model,
max_tokens: 100,
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
}
const requestConfig = {
headers: {
'Content-Type': 'application/json',
'x-api-key': credentials.apiKey,
'anthropic-version': '2023-06-01'
},
timeout: 30000
}
// 配置代理
if (account.proxy) {
const agent = getProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime
// 提取响应文本
let responseText = ''
if (response.data?.content?.[0]?.text) {
responseText = response.data.content[0].text
}
logger.success(
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
)
return res.json({
success: true,
data: {
accountId,
accountName: account.name,
model,
latency,
responseText: responseText.substring(0, 200)
}
})
} catch (error) {
const latency = Date.now() - startTime
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
return res.status(500).json({
success: false,
error: 'Test failed',
message: error.response?.data?.error?.message || error.message,
latency
})
}
})
module.exports = router

View File

@@ -9,6 +9,7 @@ const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService')
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
@@ -35,7 +36,7 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req,
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
})
logger.success('🔗 Generated OAuth authorization URL with proxy support')
logger.success('Generated OAuth authorization URL with proxy support')
return res.json({
success: true,
data: {
@@ -151,7 +152,7 @@ router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, asyn
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
})
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
logger.success('Generated Setup Token authorization URL with proxy support')
return res.json({
success: true,
data: {
@@ -277,7 +278,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId,
unifiedClientId,
expiresAt,
extInfo
extInfo,
maxConcurrency,
interceptWarmup
} = req.body
if (!name) {
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null, // 账户订阅到期时间
extInfo: extInfo || null
extInfo: extInfo || null,
maxConcurrency: maxConcurrency || 0, // 账户级串行队列0=使用全局配置,>0=强制启用
interceptWarmup: interceptWarmup === true // 拦截预热请求默认为false
})
// 如果是分组类型,将账户添加到分组
@@ -781,7 +786,7 @@ router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, asy
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
logger.success(`Updated profile for Claude account: ${accountId}`)
logger.success(`Updated profile for Claude account: ${accountId}`)
return res.json({
success: true,
message: 'Account profile updated successfully',
@@ -800,7 +805,7 @@ router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (re
try {
const result = await claudeAccountService.updateAllAccountProfiles()
logger.success('Batch profile update completed')
logger.success('Batch profile update completed')
return res.json({
success: true,
message: 'Batch profile update completed',
@@ -836,7 +841,7 @@ router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async
const result = await claudeAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for Claude account: ${accountId}`)
logger.success(`Admin reset status for Claude account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Claude account status:', error)
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
}
})
// ============================================================================
// 账户定时测试相关端点
// ============================================================================
// 获取账户测试历史
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const history = await redis.getAccountTestHistory(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
history
}
})
} catch (error) {
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test history',
message: error.message
})
}
})
// 获取账户定时测试配置
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
config: testConfig || {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
}
})
} catch (error) {
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test config',
message: error.message
})
}
})
// 设置账户定时测试配置
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { enabled, cronExpression, model } = req.body
try {
// 验证 enabled 参数
if (typeof enabled !== 'boolean') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'enabled must be a boolean'
})
}
// 验证 cronExpression 参数
if (!cronExpression || typeof cronExpression !== 'string') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'cronExpression is required and must be a string'
})
}
// 限制 cronExpression 长度防止 DoS
const MAX_CRON_LENGTH = 100
if (cronExpression.length > MAX_CRON_LENGTH) {
return res.status(400).json({
error: 'Invalid parameter',
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
})
}
// 使用 service 的方法验证 cron 表达式
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
return res.status(400).json({
error: 'Invalid parameter',
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
})
}
// 验证模型参数
const testModel = model || 'claude-sonnet-4-5-20250929'
if (typeof testModel !== 'string' || testModel.length > 256) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'model must be a valid string (max 256 characters)'
})
}
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
// 保存配置
await redis.saveAccountTestConfig(accountId, 'claude', {
enabled,
cronExpression,
model: testModel
})
logger.success(
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
)
return res.json({
success: true,
message: 'Test config updated successfully',
data: {
accountId,
platform: 'claude',
config: { enabled, cronExpression, model: testModel }
}
})
} catch (error) {
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to update test config',
message: error.message
})
}
})
// 手动触发账户测试非流式返回JSON结果
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
// 执行测试
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
// 保存测试结果到历史
await redis.saveAccountTestResult(accountId, 'claude', testResult)
await redis.setAccountLastTestTime(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
result: testResult
}
})
} catch (error) {
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to run test',
message: error.message
})
}
})
// 批量获取多个账户的测试历史
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
const { accountIds } = req.body
try {
if (!Array.isArray(accountIds) || accountIds.length === 0) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'accountIds must be a non-empty array'
})
}
// 限制批量查询数量
const limitedIds = accountIds.slice(0, 100)
const accounts = limitedIds.map((accountId) => ({
accountId,
platform: 'claude'
}))
const historyMap = await redis.getAccountsTestHistory(accounts)
return res.json({
success: true,
data: historyMap
})
} catch (error) {
logger.error('❌ Failed to get batch test history:', error)
return res.status(500).json({
error: 'Failed to get batch test history',
message: error.message
})
}
})
module.exports = router

View File

@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
dailyQuota,
quotaResetTime,
maxConcurrentTasks,
disableAutoProtection
disableAutoProtection,
interceptWarmup
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0,
disableAutoProtection: normalizedDisableAutoProtection
disableAutoProtection: normalizedDisableAutoProtection,
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
@@ -439,7 +441,7 @@ router.post(
const { accountId } = req.params
await claudeConsoleAccountService.resetDailyUsage(accountId)
logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`)
logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`)
return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
@@ -456,7 +458,7 @@ router.post(
try {
const { accountId } = req.params
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for Claude Console account: ${accountId}`)
logger.success(`Admin reset status for Claude Console account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Claude Console account status:', error)
@@ -470,7 +472,7 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
try {
await claudeConsoleAccountService.resetAllDailyUsage()
logger.success('Admin manually reset daily usage for all Claude Console accounts')
logger.success('Admin manually reset daily usage for all Claude Console accounts')
return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)

View File

@@ -40,7 +40,14 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
claudeCodeOnlyEnabled,
globalSessionBindingEnabled,
sessionBindingErrorMessage,
sessionBindingTtlDays
sessionBindingTtlDays,
userMessageQueueEnabled,
userMessageQueueDelayMs,
userMessageQueueTimeoutMs,
concurrentRequestQueueEnabled,
concurrentRequestQueueMaxSize,
concurrentRequestQueueMaxSizeMultiplier,
concurrentRequestQueueTimeoutMs
} = req.body
// 验证输入
@@ -78,15 +85,117 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
}
}
// 验证用户消息队列配置
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
}
if (userMessageQueueDelayMs !== undefined) {
if (
typeof userMessageQueueDelayMs !== 'number' ||
userMessageQueueDelayMs < 0 ||
userMessageQueueDelayMs > 10000
) {
return res
.status(400)
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
}
}
if (userMessageQueueTimeoutMs !== undefined) {
if (
typeof userMessageQueueTimeoutMs !== 'number' ||
userMessageQueueTimeoutMs < 1000 ||
userMessageQueueTimeoutMs > 300000
) {
return res
.status(400)
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
}
}
// 验证并发请求排队配置
if (
concurrentRequestQueueEnabled !== undefined &&
typeof concurrentRequestQueueEnabled !== 'boolean'
) {
return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' })
}
if (concurrentRequestQueueMaxSize !== undefined) {
if (
typeof concurrentRequestQueueMaxSize !== 'number' ||
!Number.isInteger(concurrentRequestQueueMaxSize) ||
concurrentRequestQueueMaxSize < 1 ||
concurrentRequestQueueMaxSize > 100
) {
return res
.status(400)
.json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' })
}
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
// 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型
if (
!Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) ||
concurrentRequestQueueMaxSizeMultiplier < 0 ||
concurrentRequestQueueMaxSizeMultiplier > 10
) {
return res.status(400).json({
error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10'
})
}
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
if (
typeof concurrentRequestQueueTimeoutMs !== 'number' ||
!Number.isInteger(concurrentRequestQueueTimeoutMs) ||
concurrentRequestQueueTimeoutMs < 5000 ||
concurrentRequestQueueTimeoutMs > 300000
) {
return res.status(400).json({
error:
'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)'
})
}
}
const updateData = {}
if (claudeCodeOnlyEnabled !== undefined)
if (claudeCodeOnlyEnabled !== undefined) {
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
if (globalSessionBindingEnabled !== undefined)
}
if (globalSessionBindingEnabled !== undefined) {
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
if (sessionBindingErrorMessage !== undefined)
}
if (sessionBindingErrorMessage !== undefined) {
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
if (sessionBindingTtlDays !== undefined)
}
if (sessionBindingTtlDays !== undefined) {
updateData.sessionBindingTtlDays = sessionBindingTtlDays
}
if (userMessageQueueEnabled !== undefined) {
updateData.userMessageQueueEnabled = userMessageQueueEnabled
}
if (userMessageQueueDelayMs !== undefined) {
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
}
if (userMessageQueueTimeoutMs !== undefined) {
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
}
if (concurrentRequestQueueEnabled !== undefined) {
updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled
}
if (concurrentRequestQueueMaxSize !== undefined) {
updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs
}
const updatedConfig = await claudeRelayConfigService.updateConfig(
updateData,

View File

@@ -7,26 +7,40 @@ const express = require('express')
const router = express.Router()
const redis = require('../../models/redis')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
/**
* GET /admin/concurrency
* 获取所有并发状态
*/
router.get('/concurrency', async (req, res) => {
router.get('/concurrency', authenticateAdmin, async (req, res) => {
try {
const status = await redis.getAllConcurrencyStatus()
// 为每个 API Key 获取排队计数
const statusWithQueue = await Promise.all(
status.map(async (s) => {
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
return {
...s,
queueCount
}
})
)
// 计算汇总统计
const summary = {
totalKeys: status.length,
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
totalKeys: statusWithQueue.length,
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
}
res.json({
success: true,
summary,
concurrencyStatus: status
concurrencyStatus: statusWithQueue
})
} catch (error) {
logger.error('❌ Failed to get concurrency status:', error)
@@ -39,17 +53,171 @@ router.get('/concurrency', async (req, res) => {
})
/**
* GET /admin/concurrency/:apiKeyId
* 获取特定 API Key 的并发状态详情
* GET /admin/concurrency-queue/stats
* 获取排队统计信息
*/
router.get('/concurrency/:apiKeyId', async (req, res) => {
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const status = await redis.getConcurrencyStatus(apiKeyId)
// 获取所有有统计数据的 API Key
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
const queueKeys = await redis.scanConcurrencyQueueKeys()
// 合并所有相关的 API Key
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
// 获取各 API Key 的详细统计
const perKeyStats = await Promise.all(
allApiKeyIds.map(async (apiKeyId) => {
const [queueCount, stats, waitTimes] = await Promise.all([
redis.getConcurrencyQueueCount(apiKeyId),
redis.getConcurrencyQueueStats(apiKeyId),
redis.getQueueWaitTimes(apiKeyId)
])
return {
apiKeyId,
currentQueueCount: queueCount,
stats,
waitTimeStats: calculateWaitTimeStats(waitTimes)
}
})
)
// 获取全局等待时间统计
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
// 计算全局汇总
const globalStats = {
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
totalRejectedOverload: perKeyStats.reduce(
(sum, s) => sum + (s.stats.rejected_overload || 0),
0
),
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
// 队列资源利用率指标
peakQueueSize:
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
avgQueueSize:
perKeyStats.length > 0
? Math.round(
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
)
: 0,
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
}
// 计算成功率
if (globalStats.totalEntered > 0) {
globalStats.successRate = Math.round(
(globalStats.totalSuccess / globalStats.totalEntered) * 100
)
globalStats.timeoutRate = Math.round(
(globalStats.totalTimeout / globalStats.totalEntered) * 100
)
globalStats.cancelledRate = Math.round(
(globalStats.totalCancelled / globalStats.totalEntered) * 100
)
}
// 从全局等待时间统计中提取关键指标
if (globalWaitTimeStats) {
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
// 多实例采样策略标记(详见 design.md Decision 9
// 全局 P90 仅用于可视化和监控,不用于系统决策
// 健康检查使用 API Key 级别的 P90每 Key 独立采样)
globalWaitTimeStats.globalP90ForVisualizationOnly = true
}
res.json({
success: true,
concurrencyStatus: status
globalStats,
globalWaitTimeStats,
perKeyStats
})
} catch (error) {
logger.error('❌ Failed to get queue stats:', error)
res.status(500).json({
success: false,
error: 'Failed to get queue stats',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue/:apiKeyId
* 清理特定 API Key 的排队计数
*/
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
await redis.clearConcurrencyQueue(apiKeyId)
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
res.json({
success: true,
message: `Successfully cleared queue for API key ${apiKeyId}`
})
} catch (error) {
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
res.status(500).json({
success: false,
error: 'Failed to clear queue',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue
* 清理所有排队计数
*/
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
try {
const cleared = await redis.clearAllConcurrencyQueues()
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
res.json({
success: true,
message: 'Successfully cleared all queues',
cleared
})
} catch (error) {
logger.error('❌ Failed to clear all queues:', error)
res.status(500).json({
success: false,
error: 'Failed to clear all queues',
message: error.message
})
}
})
/**
* GET /admin/concurrency/:apiKeyId
* 获取特定 API Key 的并发状态详情
*/
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const status = await redis.getConcurrencyStatus(apiKeyId)
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
res.json({
success: true,
concurrencyStatus: {
...status,
queueCount
}
})
} catch (error) {
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
@@ -65,7 +233,7 @@ router.get('/concurrency/:apiKeyId', async (req, res) => {
* DELETE /admin/concurrency/:apiKeyId
* 强制清理特定 API Key 的并发计数
*/
router.delete('/concurrency/:apiKeyId', async (req, res) => {
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const result = await redis.forceClearConcurrency(apiKeyId)
@@ -93,7 +261,7 @@ router.delete('/concurrency/:apiKeyId', async (req, res) => {
* DELETE /admin/concurrency
* 强制清理所有并发计数
*/
router.delete('/concurrency', async (req, res) => {
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
try {
const result = await redis.forceClearAllConcurrency()
@@ -118,7 +286,7 @@ router.delete('/concurrency', async (req, res) => {
* POST /admin/concurrency/cleanup
* 清理过期的并发条目(不影响活跃请求)
*/
router.post('/concurrency/cleanup', async (req, res) => {
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.body
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)

View File

@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator')
const pricingService = require('../../services/pricingService')
const config = require('../../../config/config')
const router = express.Router()
@@ -22,9 +20,14 @@ const router = express.Router()
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
// 先检查是否有全局预聚合数据
const globalStats = await redis.getGlobalStats()
// 根据是否有全局统计决定查询策略
let apiKeys = null
let apiKeyCount = null
const [
,
apiKeys,
claudeAccounts,
claudeConsoleAccounts,
geminiAccounts,
@@ -37,8 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
systemAverages,
realtimeMetrics
] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
@@ -52,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getRealtimeSystemMetrics()
])
// 有全局统计时只获取计数,否则拉全量
if (globalStats) {
apiKeyCount = await redis.getApiKeyCount()
} else {
apiKeys = await apiKeyService.getAllApiKeysFast()
}
// 处理Bedrock账户数据
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
const normalizeBoolean = (value) => value === true || value === 'true'
@@ -68,250 +76,118 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
return false
}
const normalDroidAccounts = droidAccounts.filter(
(acc) =>
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
normalizeBoolean(acc.schedulable) &&
!isRateLimitedFlag(acc.rateLimitStatus)
).length
const abnormalDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.schedulable) &&
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
isRateLimitedFlag(acc.rateLimitStatus)
).length
// 通用账户统计函数 - 单次遍历完成所有统计
const countAccountStats = (accounts, opts = {}) => {
const { isStringType = false, checkGeminiRateLimit = false } = opts
let normal = 0,
abnormal = 0,
paused = 0,
rateLimited = 0
// 计算使用统计统一使用allTokens
const totalTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
0
)
const totalRequestsUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.requests || 0),
0
)
const totalInputTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
0
)
const totalOutputTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
0
)
const totalCacheCreateTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
0
)
const totalCacheReadTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
0
)
const totalAllTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
0
)
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
// Claude账户统计 - 根据账户管理页面的判断逻辑
const normalClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalClaudeAccounts = claudeAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedClaudeAccounts = claudeAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// Claude Console账户统计
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// Gemini账户统计
const normalGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(
acc.rateLimitStatus === 'limited' ||
for (const acc of accounts) {
const isActive = isStringType
? acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
: acc.isActive
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
const isSchedulable = isStringType
? acc.schedulable !== 'false' && acc.schedulable !== false
: acc.schedulable !== false
const isRateLimited = checkGeminiRateLimit
? acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
)
).length
const abnormalGeminiAccounts = geminiAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
// Bedrock账户统计
const normalBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalBedrockAccounts = bedrockAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
if (!isActive || isBlocked) {
abnormal++
} else if (!isSchedulable) {
paused++
} else if (isRateLimited) {
rateLimited++
} else {
normal++
}
}
return { normal, abnormal, paused, rateLimited }
}
// OpenAI账户统计
// 注意OpenAI账户的isActive和schedulable是字符串类型默认值为'true'
const normalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false && // 包括'true'、true和undefined
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// Droid 账户统计(特殊逻辑)
let normalDroidAccounts = 0,
abnormalDroidAccounts = 0,
pausedDroidAccounts = 0,
rateLimitedDroidAccounts = 0
for (const acc of droidAccounts) {
const isActive = normalizeBoolean(acc.isActive)
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
const isSchedulable = normalizeBoolean(acc.schedulable)
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
// CCR账户统计
const normalCcrAccounts = ccrAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalCcrAccounts = ccrAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedCcrAccounts = ccrAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedCcrAccounts = ccrAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
if (!isActive || isBlocked) {
abnormalDroidAccounts++
} else if (!isSchedulable) {
pausedDroidAccounts++
} else if (isRateLimited) {
rateLimitedDroidAccounts++
} else {
normalDroidAccounts++
}
}
// OpenAI-Responses账户统计
// 注意OpenAI-Responses账户的isActive和schedulable也是字符串类型
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// 计算使用统计
let totalTokensUsed = 0,
totalRequestsUsed = 0,
totalInputTokensUsed = 0,
totalOutputTokensUsed = 0,
totalCacheCreateTokensUsed = 0,
totalCacheReadTokensUsed = 0,
totalAllTokensUsed = 0,
activeApiKeys = 0,
totalApiKeys = 0
if (globalStats) {
// 使用预聚合数据(快速路径)
totalRequestsUsed = globalStats.requests
totalInputTokensUsed = globalStats.inputTokens
totalOutputTokensUsed = globalStats.outputTokens
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
totalCacheReadTokensUsed = globalStats.cacheReadTokens
totalAllTokensUsed = globalStats.allTokens
totalTokensUsed = totalAllTokensUsed
totalApiKeys = apiKeyCount.total
activeApiKeys = apiKeyCount.active
} else {
// 回退到遍历(兼容旧数据)
totalApiKeys = apiKeys.length
for (const key of apiKeys) {
const usage = key.usage?.total
if (usage) {
totalTokensUsed += usage.allTokens || 0
totalRequestsUsed += usage.requests || 0
totalInputTokensUsed += usage.inputTokens || 0
totalOutputTokensUsed += usage.outputTokens || 0
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
totalAllTokensUsed += usage.allTokens || 0
}
if (key.isActive) {
activeApiKeys++
}
}
}
// 各平台账户统计(单次遍历)
const claudeStats = countAccountStats(claudeAccounts)
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts)
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true })
const bedrockStats = countAccountStats(bedrockAccounts)
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true })
const ccrStats = countAccountStats(ccrAccounts)
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true })
const dashboard = {
overview: {
totalApiKeys: apiKeys.length,
totalApiKeys,
activeApiKeys,
// 总账户统计(所有平台)
totalAccounts:
@@ -323,90 +199,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
openaiResponsesAccounts.length +
ccrAccounts.length,
normalAccounts:
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalOpenAIAccounts +
normalOpenAIResponsesAccounts +
normalCcrAccounts,
claudeStats.normal +
claudeConsoleStats.normal +
geminiStats.normal +
bedrockStats.normal +
openaiStats.normal +
openaiResponsesStats.normal +
ccrStats.normal,
abnormalAccounts:
abnormalClaudeAccounts +
abnormalClaudeConsoleAccounts +
abnormalGeminiAccounts +
abnormalBedrockAccounts +
abnormalOpenAIAccounts +
abnormalOpenAIResponsesAccounts +
abnormalCcrAccounts +
claudeStats.abnormal +
claudeConsoleStats.abnormal +
geminiStats.abnormal +
bedrockStats.abnormal +
openaiStats.abnormal +
openaiResponsesStats.abnormal +
ccrStats.abnormal +
abnormalDroidAccounts,
pausedAccounts:
pausedClaudeAccounts +
pausedClaudeConsoleAccounts +
pausedGeminiAccounts +
pausedBedrockAccounts +
pausedOpenAIAccounts +
pausedOpenAIResponsesAccounts +
pausedCcrAccounts +
claudeStats.paused +
claudeConsoleStats.paused +
geminiStats.paused +
bedrockStats.paused +
openaiStats.paused +
openaiResponsesStats.paused +
ccrStats.paused +
pausedDroidAccounts,
rateLimitedAccounts:
rateLimitedClaudeAccounts +
rateLimitedClaudeConsoleAccounts +
rateLimitedGeminiAccounts +
rateLimitedBedrockAccounts +
rateLimitedOpenAIAccounts +
rateLimitedOpenAIResponsesAccounts +
rateLimitedCcrAccounts +
claudeStats.rateLimited +
claudeConsoleStats.rateLimited +
geminiStats.rateLimited +
bedrockStats.rateLimited +
openaiStats.rateLimited +
openaiResponsesStats.rateLimited +
ccrStats.rateLimited +
rateLimitedDroidAccounts,
// 各平台详细统计
accountsByPlatform: {
claude: {
total: claudeAccounts.length,
normal: normalClaudeAccounts,
abnormal: abnormalClaudeAccounts,
paused: pausedClaudeAccounts,
rateLimited: rateLimitedClaudeAccounts
normal: claudeStats.normal,
abnormal: claudeStats.abnormal,
paused: claudeStats.paused,
rateLimited: claudeStats.rateLimited
},
'claude-console': {
total: claudeConsoleAccounts.length,
normal: normalClaudeConsoleAccounts,
abnormal: abnormalClaudeConsoleAccounts,
paused: pausedClaudeConsoleAccounts,
rateLimited: rateLimitedClaudeConsoleAccounts
normal: claudeConsoleStats.normal,
abnormal: claudeConsoleStats.abnormal,
paused: claudeConsoleStats.paused,
rateLimited: claudeConsoleStats.rateLimited
},
gemini: {
total: geminiAccounts.length,
normal: normalGeminiAccounts,
abnormal: abnormalGeminiAccounts,
paused: pausedGeminiAccounts,
rateLimited: rateLimitedGeminiAccounts
normal: geminiStats.normal,
abnormal: geminiStats.abnormal,
paused: geminiStats.paused,
rateLimited: geminiStats.rateLimited
},
bedrock: {
total: bedrockAccounts.length,
normal: normalBedrockAccounts,
abnormal: abnormalBedrockAccounts,
paused: pausedBedrockAccounts,
rateLimited: rateLimitedBedrockAccounts
normal: bedrockStats.normal,
abnormal: bedrockStats.abnormal,
paused: bedrockStats.paused,
rateLimited: bedrockStats.rateLimited
},
openai: {
total: openaiAccounts.length,
normal: normalOpenAIAccounts,
abnormal: abnormalOpenAIAccounts,
paused: pausedOpenAIAccounts,
rateLimited: rateLimitedOpenAIAccounts
normal: openaiStats.normal,
abnormal: openaiStats.abnormal,
paused: openaiStats.paused,
rateLimited: openaiStats.rateLimited
},
ccr: {
total: ccrAccounts.length,
normal: normalCcrAccounts,
abnormal: abnormalCcrAccounts,
paused: pausedCcrAccounts,
rateLimited: rateLimitedCcrAccounts
normal: ccrStats.normal,
abnormal: ccrStats.abnormal,
paused: ccrStats.paused,
rateLimited: ccrStats.rateLimited
},
'openai-responses': {
total: openaiResponsesAccounts.length,
normal: normalOpenAIResponsesAccounts,
abnormal: abnormalOpenAIResponsesAccounts,
paused: pausedOpenAIResponsesAccounts,
rateLimited: rateLimitedOpenAIResponsesAccounts
normal: openaiResponsesStats.normal,
abnormal: openaiResponsesStats.abnormal,
paused: openaiResponsesStats.paused,
rateLimited: openaiResponsesStats.rateLimited
},
droid: {
total: droidAccounts.length,
@@ -418,20 +294,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
},
// 保留旧字段以兼容
activeAccounts:
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalOpenAIAccounts +
normalOpenAIResponsesAccounts +
normalCcrAccounts +
claudeStats.normal +
claudeConsoleStats.normal +
geminiStats.normal +
bedrockStats.normal +
openaiStats.normal +
openaiResponsesStats.normal +
ccrStats.normal +
normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
totalGeminiAccounts: geminiAccounts.length,
activeGeminiAccounts: normalGeminiAccounts,
rateLimitedGeminiAccounts,
activeGeminiAccounts: geminiStats.normal,
rateLimitedGeminiAccounts: geminiStats.rateLimited,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
@@ -461,8 +337,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
geminiAccountsHealthy: normalGeminiAccounts > 0,
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
geminiAccountsHealthy: geminiStats.normal > 0,
droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime()
},
@@ -482,7 +358,7 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
const { period = 'daily' } = req.query // daily, monthly
// 获取基础API Key统计
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeys = await apiKeyService.getAllApiKeysFast()
const stats = apiKeys.map((key) => ({
keyId: key.id,
@@ -512,55 +388,48 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
)
const client = redis.getClientSafe()
// 获取所有模型的统计数据
let searchPatterns = []
// 收集所有需要扫描的日期
const datePatterns = []
if (startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式
// 自定义日期范围
const start = new Date(startDate)
const end = new Date(endDate)
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
}
// 限制最大范围为365天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
// 生成日期范围内所有日期的搜索模式
const currentDate = new Date(start)
while (currentDate <= end) {
const dateStr = redis.getDateStringInTimezone(currentDate)
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
currentDate.setDate(currentDate.getDate() + 1)
}
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`)
} else {
// 使用默认的period
const pattern =
period === 'daily'
? `usage:model:daily:*:${today}`
: `usage:model:monthly:*:${currentMonth}`
searchPatterns = [pattern]
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
}
logger.info('📊 Searching patterns:', searchPatterns)
// 获取所有匹配的keys
const allKeys = []
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern)
allKeys.push(...keys)
// 按日期集合扫描,串行避免并行触发多次全库 SCAN
const allResults = []
for (const { pattern } of datePatterns) {
const results = await redis.scanAndGetAllChunked(pattern)
allResults.push(...results)
}
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
logger.info(`📊 Found ${allResults.length} matching keys in total`)
// 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => {
@@ -570,23 +439,23 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
// 对于Bedrock模型去掉区域前缀进行统一
if (model.includes('.anthropic.') || model.includes('.claude')) {
// 匹配所有AWS区域格式region.anthropic.model-name-v1:0 -> claude-model-name
// 支持所有AWS区域格式us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等
let normalized = model.replace(/^[a-z0-9-]+\./, '')
normalized = normalized.replace('anthropic.', '')
normalized = normalized.replace(/-v\d+:\d+$/, '')
return normalized
}
// 对于其他模型,去掉常见的版本后缀
return model.replace(/-v\d+:\d+$|:latest$/, '')
}
// 聚合相同模型的数据
const modelStatsMap = new Map()
for (const key of allKeys) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
for (const { key, data } of allResults) {
// 支持 daily 和 monthly 两种格式
const match =
key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/)
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`)
@@ -595,7 +464,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const stats = modelStatsMap.get(normalizedModel) || {

View File

@@ -2,6 +2,7 @@ const express = require('express')
const crypto = require('crypto')
const droidAccountService = require('../../services/droidAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
@@ -142,38 +143,128 @@ router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res)
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await droidAccountService.getAllAccounts()
const allApiKeys = await redis.getAllApiKeys()
const accountIds = accounts.map((a) => a.id)
// 添加使用统计
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (groupError) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
groupInfos = []
// 并行获取:轻量 API Keys + 分组信息 + daily cost
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
apiKeyService.getAllApiKeysLite(),
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'),
redis.batchGetAccountDailyCost(accountIds)
])
// 构建绑定数映射droid 需要展开 group 绑定)
// 1. 先构建 groupId -> accountIds 映射
const groupToAccountIds = new Map()
for (const [accountId, groups] of allGroupInfosMap) {
for (const group of groups) {
if (!groupToAccountIds.has(group.id)) {
groupToAccountIds.set(group.id, [])
}
groupToAccountIds.get(group.id).push(accountId)
}
}
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
// 2. 单次遍历构建绑定数
const directBindingCount = new Map()
const groupBindingCount = new Map()
for (const key of allApiKeys) {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
continue
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)
} else {
directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1)
}
}
return count
}, 0)
// 批量获取使用统计
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const statsPipeline = client.pipeline()
for (const accountId of accountIds) {
statsPipeline.hgetall(`account_usage:${accountId}`)
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
// 构建 accountId -> createdAt 映射用于计算 averages
const accountCreatedAtMap = new Map()
for (const account of accounts) {
accountCreatedAtMap.set(
account.id,
account.createdAt ? new Date(account.createdAt) : new Date()
)
}
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const totalData = errTotal ? {} : parseUsage(total)
const totalTokens = totalData.tokens || 0
const totalRequests = totalData.requests || 0
// 计算 averages
const createdAt = accountCreatedAtMap.get(accountId)
const now = new Date()
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
allUsageStatsMap.set(accountId, {
total: totalData,
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly),
averages: {
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
}
})
}
// 处理账户数据
const accountsWithStats = accounts.map((account) => {
const groupInfos = allGroupInfosMap.get(account.id) || []
const usageStats = allUsageStatsMap.get(account.id) || {
daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
monthly: { tokens: 0, requests: 0 },
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
}
const dailyCost = dailyCostMap.get(account.id) || 0
// 计算绑定数:直接绑定 + 通过 group 绑定
let boundApiKeysCount = directBindingCount.get(account.id) || 0
for (const group of groupInfos) {
boundApiKeysCount += groupBindingCount.get(group.id) || 0
}
const formattedAccount = formatAccountExpiry(account)
return {
@@ -182,27 +273,13 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
boundApiKeysCount,
groupInfos,
usage: {
daily: usageStats.daily,
daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total,
monthly: usageStats.monthly,
averages: usageStats.averages
}
}
} catch (error) {
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
boundApiKeysCount: 0,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
@@ -434,7 +511,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
}
// 获取绑定的 API Key 数量
const allApiKeys = await redis.getAllApiKeys()
const allApiKeys = await apiKeyService.getAllApiKeysFast()
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
@@ -524,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
}
})
// 测试 Droid 账户连通性
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { model = 'claude-sonnet-4-20250514' } = req.body
const startTime = Date.now()
try {
// 获取账户信息
const account = await droidAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 确保 token 有效
const tokenResult = await droidAccountService.ensureValidToken(accountId)
if (!tokenResult.success) {
return res.status(401).json({
error: 'Token refresh failed',
message: tokenResult.error
})
}
const { accessToken } = tokenResult
// 构造测试请求
const axios = require('axios')
const { getProxyAgent } = require('../../utils/proxyHelper')
const apiUrl = 'https://api.factory.ai/v1/messages'
const payload = {
model,
max_tokens: 100,
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
}
const requestConfig = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
},
timeout: 30000
}
// 配置代理
if (account.proxy) {
const agent = getProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime
// 提取响应文本
let responseText = ''
if (response.data?.content?.[0]?.text) {
responseText = response.data.content[0].text
}
logger.success(
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
)
return res.json({
success: true,
data: {
accountId,
accountName: account.name,
model,
latency,
responseText: responseText.substring(0, 200)
}
})
} catch (error) {
const latency = Date.now() - startTime
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
return res.status(500).json({
success: false,
error: 'Test failed',
message: error.response?.data?.error?.message || error.message,
latency
})
}
})
module.exports = router

View File

@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// 🤖 Gemini OAuth 账户管理
function getDefaultRedirectUri(oauthProvider) {
if (oauthProvider === 'antigravity') {
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
}
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
}
// 生成 Gemini OAuth 授权 URL
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { state, proxy } = req.body // 接收代理配置
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
// 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode'
const redirectUri = getDefaultRedirectUri(oauthProvider)
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
authUrl,
state: authState,
codeVerifier,
redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
redirectUri: finalRedirectUri,
oauthProvider: resolvedOauthProvider
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
oauthProvider: resolvedOauthProvider,
createdAt: new Date().toISOString()
})
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
success: true,
data: {
authUrl,
sessionId
sessionId,
oauthProvider: resolvedOauthProvider
}
})
} catch (error) {
@@ -66,7 +74,7 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
if (result.success) {
logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`)
logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`)
return res.json({ success: true, data: { tokens: result.tokens } })
} else {
return res.json({ success: false, error: result.error })
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
// 交换 Gemini 授权码
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId, proxy: requestProxy } = req.body
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
let resolvedOauthProvider = oauthProvider
if (!code) {
return res.status(400).json({ error: 'Authorization code is required' })
}
let redirectUri = 'https://codeassist.google.com/authcode'
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
let codeVerifier = null
let proxyConfig = null
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
proxy,
oauthProvider: sessionOauthProvider
} = sessionData
redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
if (!resolvedOauthProvider && sessionOauthProvider) {
// 会话里保存的 provider 仅作为兜底
resolvedOauthProvider = sessionOauthProvider
}
logger.info(
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
)
@@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
code,
redirectUri,
codeVerifier,
proxyConfig // 传递代理配置
proxyConfig, // 传递代理配置
resolvedOauthProvider
)
// 清理 OAuth 会话
@@ -128,8 +143,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
await redis.deleteOAuthSession(sessionId)
}
logger.success('Successfully exchanged Gemini authorization code')
return res.json({ success: true, data: { tokens } })
logger.success('Successfully exchanged Gemini authorization code')
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
} catch (error) {
logger.error('❌ Failed to exchange Gemini authorization code:', error)
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
@@ -483,7 +498,7 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
const result = await geminiAccountService.resetAccountStatus(id)
logger.success(`Admin reset status for Gemini account: ${id}`)
logger.success(`Admin reset status for Gemini account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Gemini account status:', error)
@@ -491,4 +506,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
}
})
// 测试 Gemini 账户连通性
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { model = 'gemini-2.5-flash' } = req.body
const startTime = Date.now()
try {
// 获取账户信息
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 确保 token 有效
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
if (!tokenResult.success) {
return res.status(401).json({
error: 'Token refresh failed',
message: tokenResult.error
})
}
const { accessToken } = tokenResult
// 构造测试请求
const axios = require('axios')
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
const { getProxyAgent } = require('../../utils/proxyHelper')
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
const payload = createGeminiTestPayload(model)
const requestConfig = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
},
timeout: 30000
}
// 配置代理
if (account.proxy) {
const agent = getProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime
// 提取响应文本
let responseText = ''
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
responseText = response.data.candidates[0].content.parts[0].text
}
logger.success(
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
)
return res.json({
success: true,
data: {
accountId,
accountName: account.name,
model,
latency,
responseText: responseText.substring(0, 200)
}
})
} catch (error) {
const latency = Date.now() - startTime
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
return res.status(500).json({
success: false,
error: 'Test failed',
message: error.response?.data?.error?.message || error.message,
latency
})
}
})
module.exports = router

View File

@@ -31,53 +31,108 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
}
}
// 处理使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
// 检查并清除过期的限流状态
await geminiApiAccountService.checkAndClearRateLimit(account.id)
const accountIds = accounts.map((a) => a.id)
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
} catch (error) {
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
usageStats = {
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
apiKeyService.getAllApiKeysLite(),
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'),
redis.batchGetAccountDailyCost(accountIds),
// 批量清除限流状态
Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id)))
])
// 单次遍历构建绑定数映射(只算直连,不算 group
const bindingCountMap = new Map()
for (const key of allApiKeys) {
const binding = key.geminiAccountId
if (!binding) {
continue
}
// 处理 api: 前缀
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
}
// 批量获取使用统计
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const statsPipeline = client.pipeline()
for (const accountId of accountIds) {
statsPipeline.hgetall(`account_usage:${accountId}`)
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
allUsageStatsMap.set(accountId, {
total: errTotal ? {} : parseUsage(total),
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly)
})
}
// 处理账户数据
const accountsWithStats = accounts.map((account) => {
const groupInfos = allGroupInfosMap.get(account.id) || []
const usageStats = allUsageStatsMap.get(account.id) || {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
const dailyCost = dailyCostMap.get(account.id) || 0
const boundCount = bindingCountMap.get(account.id) || 0
// 计算绑定的API Key数量支持 api: 前缀
const allKeys = await redis.getAllApiKeys()
let boundCount = 0
for (const key of allKeys) {
if (key.geminiAccountId) {
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
if (key.geminiAccountId === `api:${account.id}`) {
boundCount++
}
}
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
// 计算 averagesrpm/tpm
const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
const daysSinceCreated = Math.max(
1,
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
)
const totalMinutes = daysSinceCreated * 24 * 60
const totalRequests = usageStats.total.requests || 0
const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
return {
...account,
groupInfos,
usage: {
daily: usageStats.daily,
daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total,
averages: usageStats.averages || usageStats.monthly
averages: {
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
}
},
boundApiKeys: boundCount
}
})
)
res.json({ success: true, data: accountsWithStats })
} catch (error) {
@@ -275,7 +330,7 @@ router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) =>
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`${message}`)
logger.success(`${message}`)
res.json({
success: true,
@@ -389,7 +444,7 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
const result = await geminiApiAccountService.resetAccountStatus(id)
logger.success(`Admin reset status for Gemini-API account: ${id}`)
logger.success(`Admin reset status for Gemini-API account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Gemini-API account status:', error)

View File

@@ -21,9 +21,13 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
const droidAccountsRoutes = require('./droidAccounts')
const dashboardRoutes = require('./dashboard')
const usageStatsRoutes = require('./usageStats')
const accountBalanceRoutes = require('./accountBalance')
const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
const syncRoutes = require('./sync')
const serviceRatesRoutes = require('./serviceRates')
const quotaCardsRoutes = require('./quotaCards')
// 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径)
@@ -36,9 +40,13 @@ router.use('/', openaiResponsesAccountsRoutes)
router.use('/', droidAccountsRoutes)
router.use('/', dashboardRoutes)
router.use('/', usageStatsRoutes)
router.use('/', accountBalanceRoutes)
router.use('/', systemRoutes)
router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes)
router.use('/', syncRoutes)
router.use('/', serviceRatesRoutes)
router.use('/', quotaCardsRoutes)
// 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes)

View File

@@ -80,7 +80,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
logger.success('🔗 Generated OpenAI OAuth authorization URL')
logger.success('Generated OpenAI OAuth authorization URL')
return res.json({
success: true,
@@ -191,7 +191,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
// 清理 Redis 会话
await redis.deleteOAuthSession(sessionId)
logger.success('OpenAI OAuth token exchange successful')
logger.success('OpenAI OAuth token exchange successful')
return res.json({
success: true,
@@ -386,7 +386,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
delete refreshedAccount.accessToken
delete refreshedAccount.refreshToken
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
return res.json({
success: true,
@@ -450,7 +450,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
}
}
logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
return res.json({
success: true,
@@ -541,7 +541,7 @@ router.put('/:id', authenticateAdmin, async (req, res) => {
})
}
logger.success(`Token 验证成功,继续更新账户信息`)
logger.success(`Token 验证成功,继续更新账户信息`)
} catch (refreshError) {
// 刷新失败,恢复原始 token
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
@@ -755,7 +755,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
const result = await openaiAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for OpenAI account: ${accountId}`)
logger.success(`Admin reset status for OpenAI account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset OpenAI account status:', error)

View File

@@ -39,64 +39,84 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
}
}
// 处理额度信息、使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
// 检查是否需要重置额度
const today = redis.getDateStringInTimezone()
if (account.lastResetDate !== today) {
// 今天还没重置过,需要重置
await openaiResponsesAccountService.updateAccount(account.id, {
dailyUsage: '0',
lastResetDate: today,
quotaStoppedAt: ''
})
account.dailyUsage = '0'
account.lastResetDate = today
account.quotaStoppedAt = ''
const accountIds = accounts.map((a) => a.id)
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
apiKeyService.getAllApiKeysLite(),
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
redis.batchGetAccountDailyCost(accountIds),
// 批量清理限流状态
Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
])
// 单次遍历构建绑定数映射(只算直连,不算 group
const bindingCountMap = new Map()
for (const key of allApiKeys) {
const binding = key.openaiAccountId
if (!binding) {
continue
}
// 处理 responses: 前缀
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
}
// 检查并清除过期的限流状态
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
// 批量获取使用统计(不含 daily cost已单独获取
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
} catch (error) {
logger.debug(
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
error
)
usageStats = {
const statsPipeline = client.pipeline()
for (const accountId of accountIds) {
statsPipeline.hgetall(`account_usage:${accountId}`)
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
allUsageStatsMap.set(accountId, {
total: errTotal ? {} : parseUsage(total),
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly)
})
}
// 处理额度信息、使用统计和绑定的 API Key 数量
const accountsWithStats = accounts.map((account) => {
const usageStats = allUsageStatsMap.get(account.id) || {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
// 计算绑定的API Key数量支持 responses: 前缀)
const allKeys = await redis.getAllApiKeys()
let boundCount = 0
for (const key of allKeys) {
// 检查是否绑定了该账户(包括 responses: 前缀)
if (
key.openaiAccountId === account.id ||
key.openaiAccountId === `responses:${account.id}`
) {
boundCount++
}
}
// 调试日志:检查绑定计数
if (boundCount > 0) {
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const groupInfos = allGroupInfosMap.get(account.id) || []
const boundCount = bindingCountMap.get(account.id) || 0
const dailyCost = dailyCostMap.get(account.id) || 0
const formattedAccount = formatAccountExpiry(account)
return {
@@ -104,27 +124,12 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
groupInfos,
boundApiKeysCount: boundCount,
usage: {
daily: usageStats.daily,
daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total,
monthly: usageStats.monthly
}
}
} catch (error) {
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos: [],
boundApiKeysCount: 0,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
}
})
)
res.json({ success: true, data: accountsWithStats })
} catch (error) {
@@ -413,7 +418,7 @@ router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, as
const result = await openaiResponsesAccountService.resetAccountStatus(id)
logger.success(`Admin reset status for OpenAI-Responses account: ${id}`)
logger.success(`Admin reset status for OpenAI-Responses account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
@@ -432,7 +437,7 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
quotaStoppedAt: ''
})
logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`)
logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`)
res.json({
success: true,
@@ -447,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
}
})
// 测试 OpenAI-Responses 账户连通性
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { model = 'gpt-4o-mini' } = req.body
const startTime = Date.now()
try {
// 获取账户信息
const account = await openaiResponsesAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 获取解密后的 API Key
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
if (!apiKey) {
return res.status(401).json({ error: 'API Key not found or decryption failed' })
}
// 构造测试请求
const axios = require('axios')
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
const { getProxyAgent } = require('../../utils/proxyHelper')
const baseUrl = account.baseUrl || 'https://api.openai.com'
const apiUrl = `${baseUrl}/v1/chat/completions`
const payload = createOpenAITestPayload(model)
const requestConfig = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
timeout: 30000
}
// 配置代理
if (account.proxy) {
const agent = getProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime
// 提取响应文本
let responseText = ''
if (response.data?.choices?.[0]?.message?.content) {
responseText = response.data.choices[0].message.content
}
logger.success(
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
)
return res.json({
success: true,
data: {
accountId,
accountName: account.name,
model,
latency,
responseText: responseText.substring(0, 200)
}
})
} catch (error) {
const latency = Date.now() - startTime
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
return res.status(500).json({
success: false,
error: 'Test failed',
message: error.response?.data?.error?.message || error.message,
latency
})
}
})
module.exports = router

View File

@@ -0,0 +1,242 @@
/**
* 额度卡/时间卡管理路由
*/
const express = require('express')
const router = express.Router()
const quotaCardService = require('../../services/quotaCardService')
const apiKeyService = require('../../services/apiKeyService')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
// ═══════════════════════════════════════════════════════════════════════════
// 额度卡管理
// ═══════════════════════════════════════════════════════════════════════════
// 获取额度卡上限配置
router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => {
try {
const config = await quotaCardService.getLimitsConfig()
res.json({ success: true, data: config })
} catch (error) {
logger.error('❌ Failed to get quota card limits:', error)
res.status(500).json({ success: false, error: error.message })
}
})
// 更新额度卡上限配置
router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => {
try {
const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body
const config = await quotaCardService.saveLimitsConfig({
enabled,
maxExpiryDays,
maxTotalCostLimit
})
res.json({ success: true, data: config })
} catch (error) {
logger.error('❌ Failed to save quota card limits:', error)
res.status(500).json({ success: false, error: error.message })
}
})
// 获取额度卡列表
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
try {
const { status, limit = 100, offset = 0 } = req.query
const result = await quotaCardService.getAllCards({
status,
limit: parseInt(limit),
offset: parseInt(offset)
})
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to get quota cards:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 获取额度卡统计
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
try {
const stats = await quotaCardService.getCardStats()
res.json({
success: true,
data: stats
})
} catch (error) {
logger.error('❌ Failed to get quota card stats:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 获取单个额度卡详情
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
try {
const card = await quotaCardService.getCardById(req.params.id)
if (!card) {
return res.status(404).json({
success: false,
error: 'Card not found'
})
}
res.json({
success: true,
data: card
})
} catch (error) {
logger.error('❌ Failed to get quota card:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 创建额度卡
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
try {
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
if (!type) {
return res.status(400).json({
success: false,
error: 'type is required'
})
}
const createdBy = req.session?.username || 'admin'
const options = {
type,
quotaAmount: parseFloat(quotaAmount || 0),
timeAmount: parseInt(timeAmount || 0),
timeUnit: timeUnit || 'days',
expiresAt,
note,
createdBy
}
let result
if (count > 1) {
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
} else {
result = await quotaCardService.createCard(options)
}
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to create quota card:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 删除未使用的额度卡
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
try {
const result = await quotaCardService.deleteCard(req.params.id)
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to delete quota card:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 核销记录管理
// ═══════════════════════════════════════════════════════════════════════════
// 获取核销记录列表
router.get('/redemptions', authenticateAdmin, async (req, res) => {
try {
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
const result = await quotaCardService.getRedemptions({
userId,
apiKeyId,
limit: parseInt(limit),
offset: parseInt(offset)
})
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to get redemptions:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 撤销核销
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
try {
const { reason } = req.body
const revokedBy = req.session?.username || 'admin'
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to revoke redemption:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 延长有效期
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
try {
const { amount, unit = 'days' } = req.body
if (!amount || amount <= 0) {
return res.status(400).json({
success: false,
error: 'amount must be a positive number'
})
}
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to extend expiry:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
module.exports = router

View File

@@ -0,0 +1,72 @@
/**
* 服务倍率配置管理路由
*/
const express = require('express')
const router = express.Router()
const serviceRatesService = require('../../services/serviceRatesService')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
// 获取服务倍率配置
router.get('/service-rates', authenticateAdmin, async (req, res) => {
try {
const rates = await serviceRatesService.getRates()
res.json({
success: true,
data: rates
})
} catch (error) {
logger.error('❌ Failed to get service rates:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 更新服务倍率配置
router.put('/service-rates', authenticateAdmin, async (req, res) => {
try {
const { rates, baseService } = req.body
if (!rates || typeof rates !== 'object') {
return res.status(400).json({
success: false,
error: 'rates is required and must be an object'
})
}
const updatedBy = req.session?.username || 'admin'
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to update service rates:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 获取可用服务列表
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
try {
const services = await serviceRatesService.getAvailableServices()
res.json({
success: true,
data: services
})
} catch (error) {
logger.error('❌ Failed to get available services:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
module.exports = router

464
src/routes/admin/sync.js Normal file
View File

@@ -0,0 +1,464 @@
/**
* Admin Routes - Sync / Export (for migration)
* Exports account data (including secrets) for safe server-to-server syncing.
*/
const express = require('express')
const router = express.Router()
const { authenticateAdmin } = require('../../middleware/auth')
const redis = require('../../models/redis')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const logger = require('../../utils/logger')
function toBool(value, defaultValue = false) {
if (value === undefined || value === null || value === '') {
return defaultValue
}
if (value === true || value === 'true') {
return true
}
if (value === false || value === 'false') {
return false
}
return defaultValue
}
function normalizeProxy(proxy) {
if (!proxy || typeof proxy !== 'object') {
return null
}
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
const host = proxy.host || ''
const port = Number(proxy.port || 0)
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
return null
}
return {
protocol: String(protocol),
host: String(host),
port,
username: proxy.username ? String(proxy.username) : '',
password: proxy.password ? String(proxy.password) : ''
}
}
function buildModelMappingFromSupportedModels(supportedModels) {
if (!supportedModels) {
return null
}
if (Array.isArray(supportedModels)) {
const mapping = {}
for (const model of supportedModels) {
if (typeof model === 'string' && model.trim()) {
mapping[model.trim()] = model.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
if (typeof supportedModels === 'object') {
const mapping = {}
for (const [from, to] of Object.entries(supportedModels)) {
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
mapping[from.trim()] = to.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
return null
}
function safeParseJson(raw, fallback = null) {
if (!raw || typeof raw !== 'string') {
return fallback
}
try {
return JSON.parse(raw)
} catch (_) {
return fallback
}
}
// Export accounts for migration (includes secrets).
// GET /admin/sync/export-accounts?include_secrets=true
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
try {
const includeSecrets = toBool(req.query.include_secrets, false)
if (!includeSecrets) {
return res.status(400).json({
success: false,
error: 'include_secrets_required',
message: 'Set include_secrets=true to export secrets'
})
}
// ===== Claude official OAuth / Setup Token accounts =====
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
const claudeAccounts = rawClaudeAccounts.map((account) => {
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
let decryptedClaudeAiOauth = null
if (account.claudeAiOauth) {
try {
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
} catch (_) {
decryptedClaudeAiOauth = null
}
}
const rawScopes =
account.scopes && account.scopes.trim()
? account.scopes
: decryptedClaudeAiOauth?.scopes
? decryptedClaudeAiOauth.scopes.join(' ')
: ''
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
const authType = isOAuth ? 'oauth' : 'setup-token'
const accessToken =
account.accessToken && String(account.accessToken).trim()
? claudeAccountService._decryptSensitiveData(account.accessToken)
: decryptedClaudeAiOauth?.accessToken || ''
const refreshToken =
account.refreshToken && String(account.refreshToken).trim()
? claudeAccountService._decryptSensitiveData(account.refreshToken)
: decryptedClaudeAiOauth?.refreshToken || ''
let expiresAt = null
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
expiresAt = new Date(expiresAtMs).toISOString()
} else if (decryptedClaudeAiOauth?.expiresAt) {
try {
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
} catch (_) {
expiresAt = null
}
}
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
let orgUuid = null
let accountUuid = null
if (account.subscriptionInfo) {
try {
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
orgUuid = subscriptionInfo.organizationUuid || null
accountUuid = subscriptionInfo.accountUuid || null
} catch (_) {
// Ignore parse errors
}
}
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (expiresAt) {
try {
const expiresAtTime = new Date(expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
if (!expiresIn && isOAuth) {
expiresIn = 28800 // 8 hours
}
const credentials = {
access_token: accessToken,
refresh_token: refreshToken || undefined,
expires_at: expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (orgUuid) {
credentials.org_uuid = orgUuid
}
if (accountUuid) {
credentials.account_uuid = accountUuid
}
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'claude-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'claude',
crs_auth_type: authType,
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_subscription_info: account.subscriptionInfo || undefined
}
return {
kind: 'claude-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'claude',
authType,
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
}
})
// ===== Claude Console API Key accounts =====
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
const claudeConsoleAccounts = []
for (const summary of claudeConsoleSummaries) {
const full = await claudeConsoleAccountService.getAccount(summary.id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
const credentials = {
api_key: full.apiKey,
base_url: full.apiUrl
}
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
claudeConsoleAccounts.push({
kind: 'claude-console-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'claude-console',
isActive: full.isActive === true,
schedulable: full.schedulable !== false,
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'claude-console-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'claude-console',
crs_is_active: full.isActive === true,
crs_schedulable: full.schedulable !== false,
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
// ===== OpenAI OAuth accounts =====
const openaiOAuthAccounts = []
{
const openaiIds = await redis.getAllIdsByIndex(
'openai:account:index',
'openai:account:*',
/^openai:account:(.+)$/
)
for (const id of openaiIds) {
const account = await openaiAccountService.getAccount(id)
if (!account) {
continue
}
const accessToken = account.accessToken
? openaiAccountService.decrypt(account.accessToken)
: ''
if (!accessToken) {
// Skip broken/legacy records without decryptable token
continue
}
const scopes =
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
? account.scopes.trim().split(' ')
: []
const proxy = normalizeProxy(account.proxy)
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (account.expiresAt) {
try {
const expiresAtTime = new Date(account.expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
if (!expiresIn) {
expiresIn = 864000 // 10 days
}
const credentials = {
access_token: accessToken,
refresh_token: account.refreshToken || undefined,
id_token: account.idToken || undefined,
expires_at: account.expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (account.accountId) {
credentials.chatgpt_account_id = account.accountId
}
if (account.chatgptUserId) {
credentials.chatgpt_user_id = account.chatgptUserId
}
if (account.organizationId) {
credentials.organization_id = account.organizationId
}
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'openai-oauth-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'openai',
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_email: account.email || undefined,
crs_chatgpt_account_id: account.accountId || undefined,
crs_chatgpt_user_id: account.chatgptUserId || undefined,
crs_organization_id: account.organizationId || undefined
}
openaiOAuthAccounts.push({
kind: 'openai-oauth-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'openai',
authType: 'oauth',
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
})
}
}
// ===== OpenAI Responses API Key accounts =====
const openaiResponsesAccounts = []
const openaiResponseIds = await redis.getAllIdsByIndex(
'openai_responses_account:index',
'openai_responses_account:*',
/^openai_responses_account:(.+)$/
)
for (const id of openaiResponseIds) {
const full = await openaiResponsesAccountService.getAccount(id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const credentials = {
api_key: full.apiKey,
base_url: full.baseApi
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
openaiResponsesAccounts.push({
kind: 'openai-responses-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'openai-responses',
isActive: full.isActive === 'true',
schedulable: full.schedulable !== 'false',
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'openai-responses-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'openai-responses',
crs_is_active: full.isActive === 'true',
crs_schedulable: full.schedulable !== 'false',
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
return res.json({
success: true,
data: {
exportedAt: new Date().toISOString(),
claudeAccounts,
claudeConsoleAccounts,
openaiOAuthAccounts,
openaiResponsesAccounts
}
})
} catch (error) {
logger.error('❌ Failed to export accounts for sync:', error)
return res.status(500).json({
success: false,
error: 'export_failed',
message: error.message
})
}
})
module.exports = router

View File

@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
siteIcon: '',
siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮
apiStatsNotice: {
enabled: false,
title: '',
content: ''
},
updatedAt: new Date().toISOString()
}
@@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => {
// 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try {
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body
// 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true
apiStatsNotice: {
enabled: apiStatsNotice?.enabled === true,
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
},
updatedAt: new Date().toISOString()
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,17 +12,36 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const claudeAccountService = require('../services/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
} = require('../utils/warmupInterceptor')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
const {
handleAnthropicMessagesToGemini,
handleAnthropicCountTokensToGemini
} = require('../services/anthropicGeminiBridgeService')
const router = express.Router()
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
function queueRateLimitUpdate(
rateLimitInfo,
usageSummary,
model,
context = '',
keyId = null,
accountType = null
) {
if (!rateLimitInfo) {
return Promise.resolve({ totalTokens: 0, totalCost: 0 })
}
const label = context ? ` (${context})` : ''
return updateRateLimitCounters(rateLimitInfo, usageSummary, model)
return updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
.then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
@@ -38,21 +57,90 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
})
}
/**
* 判断是否为旧会话(污染的会话)
* Claude Code 发送的请求特点:
* - messages 数组通常只有 1 个元素
* - 历史对话记录嵌套在单个 message 的 content 数组中
* - content 数组中包含 <system-reminder> 开头的系统注入内容
*
* 污染会话的特征:
* 1. messages.length > 1
* 2. messages.length === 1 但 content 中有多个用户输入
* 3. "warmup" 请求:单条简单消息 + 无 tools真正新会话会带 tools
*
* @param {Object} body - 请求体
* @returns {boolean} 是否为旧会话
*/
function isOldSession(body) {
const messages = body?.messages
const tools = body?.tools
if (!messages || messages.length === 0) {
return false
}
// 1. 多条消息 = 旧会话
if (messages.length > 1) {
return true
}
// 2. 单条消息,分析 content
const firstMessage = messages[0]
const content = firstMessage?.content
if (!content) {
return false
}
// 如果 content 是字符串,只有一条输入,需要检查 tools
if (typeof content === 'string') {
// 有 tools = 正常新会话,无 tools = 可疑
return !tools || tools.length === 0
}
// 如果 content 是数组,统计非 system-reminder 的元素
if (Array.isArray(content)) {
const userInputs = content.filter((item) => {
if (item.type !== 'text') {
return false
}
const text = item.text || ''
// 剔除以 <system-reminder> 开头的
return !text.trimStart().startsWith('<system-reminder>')
})
// 多个用户输入 = 旧会话
if (userInputs.length > 1) {
return true
}
// Warmup 检测:单个消息 + 无 tools = 旧会话
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
return true
}
}
return false
}
// 🔧 共享的消息处理函数
async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
const forcedVendor = req._anthropicVendor || null
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
message:
requiredService === 'gemini'
? '此 API Key 无权访问 Gemini 服务'
: '此 API Key 无权访问 Claude 服务'
}
})
}
@@ -101,6 +189,25 @@ async function handleMessagesRequest(req, res) {
}
}
logger.api('📥 /v1/messages request received', {
model: req.body.model || null,
forcedVendor,
stream: req.body.stream === true
})
dumpAnthropicMessagesRequest(req, {
route: '/v1/messages',
forcedVendor,
model: req.body?.model || null,
stream: req.body?.stream === true
})
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -123,12 +230,42 @@ async function handleMessagesRequest(req, res) {
)
if (isStream) {
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before stream response could start for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
// 流式响应 - 只使用官方真实usage数据
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
// ⚠️ 检查 headers 是否已发送(可能在排队心跳时已设置)
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
// 这里只在没有设置过 Connection 头时才设置 keep-alive
const existingConnection = res.getHeader('Connection')
if (!existingConnection) {
res.setHeader('Connection', 'keep-alive')
} else {
logger.api(
`🔌 [STREAM] Preserving existing Connection header: ${existingConnection} for key: ${req.apiKey?.name || 'unknown'}`
)
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
} else {
logger.debug(
`📤 [STREAM] Headers already sent, skipping setHeader for key: ${req.apiKey?.name || 'unknown'}`
)
}
// 禁用 Nagle 算法,确保数据立即发送
if (res.socket && typeof res.socket.setNoDelay === 'function') {
@@ -233,19 +370,18 @@ async function handleMessagesRequest(req, res) {
}
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
// claude-official 只接受1) 新会话(messages.length=1) 2) 已绑定的会话
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBinding &&
originalSessionIdForBinding &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 新会话必须 messages.length === 1
const messages = req.body?.messages
if (messages && messages.length > 1) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}`
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
@@ -267,14 +403,38 @@ async function handleMessagesRequest(req, res) {
}
}
// 🔥 预热请求拦截检查(在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
if (isStream) {
return sendMockWarmupStream(res, req.body.model)
} else {
return res.json(buildMockWarmupResponse(req.body.model))
}
}
}
// 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
const _apiKeyId = req.apiKey.id
const _rateLimitInfo = req.rateLimitInfo
const _requestBody = req.body // 传递后清除引用
const _apiKey = req.apiKey
const _headers = req.headers
await claudeRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBody,
_apiKey,
res,
req.headers,
_headers,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -324,13 +484,13 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType)
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfo,
{
inputTokens,
outputTokens,
@@ -338,7 +498,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens
},
model,
'claude-stream'
'claude-stream',
_apiKeyId,
accountType
)
usageDataCaptured = true
@@ -355,11 +517,18 @@ async function handleMessagesRequest(req, res) {
)
} else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdConsole = req.apiKey.id
const _rateLimitInfoConsole = req.rateLimitInfo
const _requestBodyConsole = req.body
const _apiKeyConsole = req.apiKey
const _headersConsole = req.headers
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBodyConsole,
_apiKeyConsole,
res,
req.headers,
_headersConsole,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -410,7 +579,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService
.recordUsageWithDetails(
req.apiKey.id,
_apiKeyIdConsole,
usageObject,
model,
usageAccountId,
@@ -421,7 +590,7 @@ async function handleMessagesRequest(req, res) {
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoConsole,
{
inputTokens,
outputTokens,
@@ -429,7 +598,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens
},
model,
'claude-console-stream'
'claude-console-stream',
_apiKeyIdConsole,
accountType
)
usageDataCaptured = true
@@ -447,6 +618,11 @@ async function handleMessagesRequest(req, res) {
)
} else if (accountType === 'bedrock') {
// Bedrock账号使用Bedrock转发服务
// 🧹 内存优化:提取需要的值
const _apiKeyIdBedrock = req.apiKey.id
const _rateLimitInfoBedrock = req.rateLimitInfo
const _requestBodyBedrock = req.body
try {
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
if (!bedrockAccountResult.success) {
@@ -454,7 +630,7 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleStreamRequest(
req.body,
_requestBodyBedrock,
bedrockAccountResult.data,
res
)
@@ -465,13 +641,22 @@ async function handleMessagesRequest(req, res) {
const outputTokens = result.usage.output_tokens || 0
apiKeyService
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
.recordUsage(
_apiKeyIdBedrock,
inputTokens,
outputTokens,
0,
0,
result.model,
accountId,
'bedrock'
)
.catch((error) => {
logger.error('❌ Failed to record Bedrock stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoBedrock,
{
inputTokens,
outputTokens,
@@ -479,7 +664,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens: 0
},
result.model,
'bedrock-stream'
'bedrock-stream',
_apiKeyIdBedrock,
'bedrock'
)
usageDataCaptured = true
@@ -496,11 +683,18 @@ async function handleMessagesRequest(req, res) {
}
} else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdCcr = req.apiKey.id
const _rateLimitInfoCcr = req.rateLimitInfo
const _requestBodyCcr = req.body
const _apiKeyCcr = req.apiKey
const _headersCcr = req.headers
await ccrRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBodyCcr,
_apiKeyCcr,
res,
req.headers,
_headersCcr,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -550,13 +744,13 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
.catch((error) => {
logger.error('❌ Failed to record CCR stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoCcr,
{
inputTokens,
outputTokens,
@@ -564,7 +758,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens
},
model,
'ccr-stream'
'ccr-stream',
_apiKeyIdCcr,
'ccr'
)
usageDataCaptured = true
@@ -591,12 +787,69 @@ async function handleMessagesRequest(req, res) {
}
}, 1000) // 1秒后检查
} else {
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
const _apiKeyIdNonStream = req.apiKey.id
const _apiKeyNameNonStream = req.apiKey.name
const _rateLimitInfoNonStream = req.rateLimitInfo
const _requestBodyNonStream = req.body
const _apiKeyNonStream = req.apiKey
const _headersNonStream = req.headers
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
)
return undefined
}
// 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id,
apiKeyName: req.apiKey.name
apiKeyId: _apiKeyIdNonStream,
apiKeyName: _apiKeyNameNonStream
})
// 📊 监听 socket 事件以追踪连接状态变化
const nonStreamSocket = res.socket
let _clientClosedConnection = false
let _socketCloseTime = null
if (nonStreamSocket) {
const onSocketEnd = () => {
_clientClosedConnection = true
_socketCloseTime = Date.now()
logger.warn(
`⚠️ [NON-STREAM] Socket 'end' event - client sent FIN | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms`
)
}
const onSocketClose = () => {
_clientClosedConnection = true
logger.warn(
`⚠️ [NON-STREAM] Socket 'close' event | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms, ` +
`hadError: ${nonStreamSocket.destroyed}`
)
}
const onSocketError = (err) => {
logger.error(
`❌ [NON-STREAM] Socket error | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, error: ${err.message}`
)
}
nonStreamSocket.once('end', onSocketEnd)
nonStreamSocket.once('close', onSocketClose)
nonStreamSocket.once('error', onSocketError)
// 清理监听器(在响应结束后)
res.once('finish', () => {
nonStreamSocket.removeListener('end', onSocketEnd)
nonStreamSocket.removeListener('close', onSocketClose)
nonStreamSocket.removeListener('error', onSocketError)
})
}
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -684,19 +937,18 @@ async function handleMessagesRequest(req, res) {
}
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
// claude-official 只接受1) 新会话(messages.length=1) 2) 已绑定的会话
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBindingNonStream &&
originalSessionIdForBindingNonStream &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 新会话必须 messages.length === 1
const messages = req.body?.messages
if (messages && messages.length > 1) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}`
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
@@ -718,6 +970,21 @@ async function handleMessagesRequest(req, res) {
}
}
// 🔥 预热请求拦截检查(非流式,在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
}
}
// 根据账号类型选择对应的转发服务
let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -727,11 +994,11 @@ async function handleMessagesRequest(req, res) {
if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务
response = await claudeRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 用于断开检测,保留但服务层已优化
res,
req.headers
_headersNonStream
)
} else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务
@@ -739,11 +1006,11 @@ async function handleMessagesRequest(req, res) {
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
)
response = await claudeConsoleRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 保留用于断开检测
res,
req.headers,
_headersNonStream,
accountId
)
} else if (accountType === 'bedrock') {
@@ -755,9 +1022,9 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleNonStreamRequest(
req.body,
_requestBodyNonStream,
bedrockAccountResult.data,
req.headers
_headersNonStream
)
// 构建标准响应格式
@@ -787,11 +1054,11 @@ async function handleMessagesRequest(req, res) {
// CCR账号使用CCR转发服务
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
response = await ccrRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 保留用于断开检测
res,
req.headers,
_headersNonStream,
accountId
)
}
@@ -802,6 +1069,15 @@ async function handleMessagesRequest(req, res) {
bodyLength: response.body ? response.body.length : 0
})
// 🔍 检查客户端连接是否仍然有效
// 在长时间请求过程中,客户端可能已经断开连接(超时、用户取消等)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream response could be sent for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
res.status(response.statusCode)
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
@@ -831,24 +1107,25 @@ async function handleMessagesRequest(req, res) {
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
const rawModel = jsonData.model || req.body.model || 'unknown'
const { baseModel } = parseVendorPrefixedModel(rawModel)
const model = baseModel || rawModel
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response
await apiKeyService.recordUsage(
req.apiKey.id,
_apiKeyIdNonStream,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model,
responseAccountId
responseAccountId,
accountType
)
await queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoNonStream,
{
inputTokens,
outputTokens,
@@ -856,7 +1133,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens
},
model,
'claude-non-stream'
'claude-non-stream',
_apiKeyIdNonStream,
accountType
)
usageRecorded = true
@@ -867,10 +1146,12 @@ async function handleMessagesRequest(req, res) {
logger.warn('⚠️ No usage data found in Claude API JSON response')
}
// 使用 Express 内建的 res.json() 发送响应(简单可靠)
res.json(jsonData)
} catch (parseError) {
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
logger.info('📄 Raw response body:', response.body)
// 使用 Express 内建的 res.send() 发送响应(简单可靠)
res.send(response.body)
}
@@ -1007,6 +1288,65 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const geminiAccountService = require('../services/geminiAccountService')
let accountSelection
try {
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
null,
null,
{ oauthProvider: 'antigravity' }
)
} catch (error) {
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
}
const account = await geminiAccountService.getAccount(accountSelection.accountId)
if (!account) {
return res.status(503).json({ error: 'Gemini OAuth account not found' })
}
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig =
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
proxyConfig,
account.refreshToken
)
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
let filteredModels = models
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
}
return res.json({ object: 'list', data: filteredModels })
}
const modelService = require('../services/modelService')
// 从 modelService 获取所有支持的模型
@@ -1143,20 +1483,62 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
// 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
message:
requiredService === 'gemini'
? 'This API key does not have permission to access Gemini'
: 'This API key does not have permission to access Claude'
}
})
}
if (requiredService === 'gemini') {
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 🔗 会话绑定验证(与 messages 端点保持一致)
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
const sessionValidation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!sessionValidation.valid) {
logger.warn(
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: sessionValidation.error
}
})
}
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
if (sessionValidation.isNewSession && originalSessionId) {
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
}
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -1164,9 +1546,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2
let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
@@ -1362,5 +1741,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
})
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
router.post('/api/event_logging/batch', (req, res) => {
res.status(200).json({ success: true })
})
module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -5,10 +5,39 @@ const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const serviceRatesService = require('../services/serviceRatesService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const modelsConfig = require('../../config/models')
const { getSafeMessage } = require('../utils/errorSanitizer')
const router = express.Router()
// 📋 获取可用模型列表(公开接口)
router.get('/models', (req, res) => {
const { service } = req.query
if (service) {
// 返回指定服务的模型
const models = modelsConfig.getModelsByService(service)
return res.json({
success: true,
data: models
})
}
// 返回所有模型(按服务分组)
res.json({
success: true,
data: {
claude: modelsConfig.CLAUDE_MODELS,
gemini: modelsConfig.GEMINI_MODELS,
openai: modelsConfig.OPENAI_MODELS,
other: modelsConfig.OTHER_MODELS,
all: modelsConfig.getAllModels()
}
})
})
// 🏠 重定向页面请求到新版 admin-spa
router.get('/', (req, res) => {
res.redirect(301, '/admin-next/api-stats')
@@ -39,7 +68,7 @@ router.post('/api/get-key-id', async (req, res) => {
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
logger.security(`Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
@@ -87,7 +116,7 @@ router.post('/api/user-stats', async (req, res) => {
keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
@@ -155,7 +184,7 @@ router.post('/api/user-stats', async (req, res) => {
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
@@ -166,7 +195,7 @@ router.post('/api/user-stats', async (req, res) => {
} else if (apiKey) {
// 通过 apiKey 查询(保持向后兼容)
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
logger.security(`Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
return res.status(400).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
@@ -191,7 +220,7 @@ router.post('/api/user-stats', async (req, res) => {
keyData = validatedKeyData
keyId = keyData.id
} else {
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
logger.security(`Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
return res.status(400).json({
error: 'API Key or ID is required',
message: 'Please provide your API Key or API ID'
@@ -206,25 +235,34 @@ router.post('/api/user-stats', async (req, res) => {
// 获取验证结果中的完整keyData包含isActive状态和cost信息
const fullKeyData = keyData
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
// 计算总费用 - 优先使用持久化的总费用计数器
let totalCost = 0
let formattedCost = '$0.000000'
try {
const client = redis.getClientSafe()
// 获取所有月度模型统计与model-stats接口相同的逻辑
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
// 读取累积的总费用(没有 TTL 的持久键
const totalCostKey = `usage:cost:total:${keyId}`
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
if (allTimeCost > 0) {
totalCost = allTimeCost
formattedCost = CostCalculator.formatCost(allTimeCost)
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
for (const key of allModelKeys) {
for (const { key, data } of allModelResults) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
@@ -272,8 +310,9 @@ router.post('/api/user-stats', async (req, res) => {
}
formattedCost = CostCalculator.formatCost(totalCost)
}
} catch (error) {
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
// 回退到简单计算
if (fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
@@ -464,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => {
restrictedModels: fullKeyData.restrictedModels || [],
enableClientRestriction: fullKeyData.enableClientRestriction || false,
allowedClients: fullKeyData.allowedClients || []
},
// Key 级别的服务倍率
serviceRates: (() => {
try {
return fullKeyData.serviceRates
? typeof fullKeyData.serviceRates === 'string'
? JSON.parse(fullKeyData.serviceRates)
: fullKeyData.serviceRates
: {}
} catch (e) {
return {}
}
})()
}
return res.json({
@@ -587,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => {
...usage.monthly,
cost: costStats.monthly
},
totalCost: costStats.total
totalCost: costStats.total,
serviceRates: (() => {
try {
return keyData.serviceRates
? typeof keyData.serviceRates === 'string'
? JSON.parse(keyData.serviceRates)
: keyData.serviceRates
: {}
} catch (e) {
return {}
}
})()
}
})
)
@@ -691,7 +754,7 @@ router.post('/api/batch-model-stats', async (req, res) => {
})
}
const client = redis.getClientSafe()
const _client = redis.getClientSafe()
const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
@@ -706,9 +769,9 @@ router.post('/api/batch-model-stats', async (req, res) => {
? `usage:${apiId}:model:daily:*:${today}`
: `usage:${apiId}:model:monthly:*:${currentMonth}`
const keys = await client.keys(pattern)
const results = await redis.scanAndGetAllChunked(pattern)
for (const key of keys) {
for (const { key, data } of results) {
const match = key.match(
period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
@@ -720,7 +783,6 @@ router.post('/api/batch-model-stats', async (req, res) => {
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
@@ -730,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
allTokens: 0,
realCostMicro: 0,
ratedCostMicro: 0,
hasStoredCost: false
})
}
@@ -741,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
modelUsage.allTokens += parseInt(data.allTokens) || 0
modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0
modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0
// 检查 Redis 数据是否包含成本字段
if ('realCostMicro' in data || 'ratedCostMicro' in data) {
modelUsage.hasStoredCost = true
}
}
}
})
)
// 转换为数组并计算费用
// 转换为数组并处理费用
const modelStats = []
for (const [model, usage] of modelUsageMap) {
const usageData = {
@@ -756,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
cache_read_input_tokens: usage.cacheReadTokens
}
// 优先使用存储的费用,否则回退到重新计算
const { hasStoredCost } = usage
const costData = CostCalculator.calculateCost(usageData, model)
// 如果有存储的费用,覆盖计算的费用
if (hasStoredCost) {
costData.costs.real = (usage.realCostMicro || 0) / 1000000
costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000
costData.costs.total = costData.costs.real // 保持兼容
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
}
modelStats.push({
model,
requests: usage.requests,
@@ -768,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
allTokens: usage.allTokens,
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
pricing: costData.pricing,
isLegacy: !hasStoredCost
})
}
@@ -791,13 +873,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
}
})
// maxTokens 白名单
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
const sanitizeMaxTokens = (value) =>
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
router.post('/api-key/test', async (req, res) => {
const config = require('../../config/config')
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try {
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
if (!apiKey) {
return res.status(400).json({
@@ -830,7 +918,7 @@ router.post('/api-key/test', async (req, res) => {
apiUrl,
authorization: apiKey,
responseStream: res,
payload: createClaudeTestPayload(model, { stream: true }),
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
timeout: 60000,
extraHeaders: { 'x-api-key': apiKey }
})
@@ -840,13 +928,317 @@ router.post('/api-key/test', async (req, res) => {
if (!res.headersSent) {
return res.status(500).json({
error: 'Test failed',
message: error.message || 'Internal server error'
message: getSafeMessage(error)
})
}
res.write(
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
res.end()
}
})
// 🧪 Gemini API Key 端点测试接口
router.post('/api-key/test-gemini', async (req, res) => {
const config = require('../../config/config')
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
try {
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
if (!apiKey) {
return res.status(400).json({
error: 'API Key is required',
message: 'Please provide your API Key'
})
}
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
return res.status(400).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
})
}
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) {
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
})
}
// 检查 Gemini 权限
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) {
return res.status(403).json({
error: 'Permission denied',
message: 'This API key does not have Gemini permission'
})
}
logger.api(
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
)
const port = config.server.port || 3000
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
// 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
const axios = require('axios')
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
try {
const response = await axios.post(apiUrl, payload, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
timeout: 60000,
responseType: 'stream',
validateStatus: () => true
})
if (response.status !== 200) {
const chunks = []
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', () => {
const errorData = Buffer.concat(chunks).toString()
let errorMsg = `API Error: ${response.status}`
try {
const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg
} catch {
if (errorData.length < 200) {
errorMsg = errorData || errorMsg
}
}
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
)
res.end()
})
return
}
let buffer = ''
response.data.on('data', (chunk) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data:')) {
continue
}
const jsonStr = line.substring(5).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
// Gemini 格式: candidates[0].content.parts[0].text
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
} catch {
// ignore
}
}
})
response.data.on('end', () => {
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
res.end()
})
response.data.on('error', (err) => {
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
)
res.end()
})
} catch (axiosError) {
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
)
res.end()
}
} catch (error) {
logger.error('❌ Gemini API Key test failed:', error)
if (!res.headersSent) {
return res.status(500).json({
error: 'Test failed',
message: getSafeMessage(error)
})
}
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
res.end()
}
})
// 🧪 OpenAI/Codex API Key 端点测试接口
router.post('/api-key/test-openai', async (req, res) => {
const config = require('../../config/config')
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
try {
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
if (!apiKey) {
return res.status(400).json({
error: 'API Key is required',
message: 'Please provide your API Key'
})
}
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
return res.status(400).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
})
}
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) {
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
})
}
// 检查 OpenAI 权限
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) {
return res.status(403).json({
error: 'Permission denied',
message: 'This API key does not have OpenAI permission'
})
}
logger.api(
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
)
const port = config.server.port || 3000
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
// 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
const axios = require('axios')
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
try {
const response = await axios.post(apiUrl, payload, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'User-Agent': 'codex_cli_rs/1.0.0'
},
timeout: 60000,
responseType: 'stream',
validateStatus: () => true
})
if (response.status !== 200) {
const chunks = []
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', () => {
const errorData = Buffer.concat(chunks).toString()
let errorMsg = `API Error: ${response.status}`
try {
const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg
} catch {
if (errorData.length < 200) {
errorMsg = errorData || errorMsg
}
}
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
)
res.end()
})
return
}
let buffer = ''
response.data.on('data', (chunk) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data:')) {
continue
}
const jsonStr = line.substring(5).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
// OpenAI Responses 格式: output[].content[].text 或 delta
if (data.type === 'response.output_text.delta' && data.delta) {
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
}
} catch {
// ignore
}
}
})
response.data.on('end', () => {
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
res.end()
})
response.data.on('error', (err) => {
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
)
res.end()
})
} catch (axiosError) {
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
)
res.end()
}
} catch (error) {
logger.error('❌ OpenAI API Key test failed:', error)
if (!res.headersSent) {
return res.status(500).json({
error: 'Test failed',
message: getSafeMessage(error)
})
}
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
res.end()
}
})
@@ -875,7 +1267,7 @@ router.post('/api/user-model-stats', async (req, res) => {
keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
@@ -931,33 +1323,37 @@ router.post('/api/user-model-stats', async (req, res) => {
)
// 重用管理后台的模型统计逻辑但只返回该API Key的数据
const client = redis.getClientSafe()
const _client = redis.getClientSafe()
// 使用与管理页面相同的时区处理逻辑
const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
const pattern =
period === 'daily'
? `usage:${keyId}:model:daily:*:${today}`
: `usage:${keyId}:model:monthly:*:${currentMonth}`
let pattern
let matchRegex
if (period === 'daily') {
pattern = `usage:${keyId}:model:daily:*:${today}`
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
} else if (period === 'alltime') {
pattern = `usage:${keyId}:model:alltime:*`
matchRegex = /usage:.+:model:alltime:(.+)$/
} else {
// monthly
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
}
const keys = await client.keys(pattern)
const results = await redis.scanAndGetAllChunked(pattern)
const modelStats = []
for (const key of keys) {
const match = key.match(
period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
)
for (const { key, data } of results) {
const match = key.match(matchRegex)
if (!match) {
continue
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const usage = {
@@ -967,8 +1363,30 @@ router.post('/api/user-model-stats', async (req, res) => {
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
}
// 优先使用存储的费用,否则回退到重新计算
// 检查字段是否存在(而非 > 0以支持真正的零成本场景
const realCostMicro = parseInt(data.realCostMicro) || 0
const ratedCostMicro = parseInt(data.ratedCostMicro) || 0
const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data
const costData = CostCalculator.calculateCost(usage, model)
// 如果有存储的费用,覆盖计算的费用
if (hasStoredCost) {
costData.costs.real = realCostMicro / 1000000
costData.costs.rated = ratedCostMicro / 1000000
costData.costs.total = costData.costs.real
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
}
// alltime 键不存储 allTokens需要计算
const allTokens =
period === 'alltime'
? usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
: parseInt(data.allTokens) || 0
modelStats.push({
model,
requests: parseInt(data.requests) || 0,
@@ -976,10 +1394,11 @@ router.post('/api/user-model-stats', async (req, res) => {
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: parseInt(data.allTokens) || 0,
allTokens,
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
pricing: costData.pricing,
isLegacy: !hasStoredCost
})
}
}
@@ -1007,4 +1426,170 @@ router.post('/api/user-model-stats', async (req, res) => {
}
})
// 📊 获取服务倍率配置(公开接口)
router.get('/service-rates', async (req, res) => {
try {
const rates = await serviceRatesService.getRates()
res.json({
success: true,
data: rates
})
} catch (error) {
logger.error('❌ Failed to get service rates:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve service rates'
})
}
})
// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份)
router.post('/api/redeem-card', async (req, res) => {
const quotaCardService = require('../services/quotaCardService')
try {
const { apiId, code } = req.body
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
const hour = new Date().toISOString().slice(0, 13)
// 防暴力破解:检查失败锁定
const failKey = `redeem_card:fail:${clientIP}`
const failCount = parseInt((await redis.client.get(failKey)) || '0')
if (failCount >= 5) {
logger.security(`🔒 Card redemption locked for IP: ${clientIP}`)
return res.status(403).json({
success: false,
error: '失败次数过多请1小时后再试'
})
}
// 防暴力破解:检查 IP 速率限制
const ipKey = `redeem_card:ip:${clientIP}:${hour}`
const ipCount = await redis.client.incr(ipKey)
await redis.client.expire(ipKey, 3600)
if (ipCount > 10) {
logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`)
return res.status(429).json({
success: false,
error: '请求过于频繁,请稍后再试'
})
}
if (!apiId || !code) {
return res.status(400).json({
success: false,
error: '请输入卡号'
})
}
// 验证 apiId 格式
if (
typeof apiId !== 'string' ||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
) {
return res.status(400).json({
success: false,
error: 'API ID 格式无效'
})
}
// 验证 API Key 存在且有效
const keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
return res.status(404).json({
success: false,
error: 'API Key 不存在'
})
}
if (keyData.isActive !== 'true') {
return res.status(403).json({
success: false,
error: 'API Key 已禁用'
})
}
// 调用兑换服务
const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats')
// 成功时清除失败计数(静默处理,不影响成功响应)
redis.client.del(failKey).catch(() => {})
logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`)
res.json({
success: true,
data: result
})
} catch (error) {
// 失败时增加失败计数(静默处理,不影响错误响应)
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
const failKey = `redeem_card:fail:${clientIP}`
redis.client
.incr(failKey)
.then(() => redis.client.expire(failKey, 3600))
.catch(() => {})
logger.error('❌ Failed to redeem card:', error)
res.status(400).json({
success: false,
error: error.message
})
}
})
// 📋 公开的兑换记录查询接口(通过 apiId 验证身份)
router.get('/api/redemption-history', async (req, res) => {
const quotaCardService = require('../services/quotaCardService')
try {
const { apiId, limit = 50, offset = 0 } = req.query
if (!apiId) {
return res.status(400).json({
success: false,
error: '缺少 API ID'
})
}
// 验证 apiId 格式
if (
typeof apiId !== 'string' ||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
) {
return res.status(400).json({
success: false,
error: 'API ID 格式无效'
})
}
// 验证 API Key 存在
const keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
return res.status(404).json({
success: false,
error: 'API Key 不存在'
})
}
// 获取该 API Key 的兑换记录
const result = await quotaCardService.getRedemptions({
apiKeyId: apiId,
limit: parseInt(limit),
offset: parseInt(offset)
})
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Failed to get redemption history:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
module.exports = router

View File

@@ -86,7 +86,8 @@ class AtomicUsageReporter {
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
accountId
accountId,
'azure-openai'
)
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量

View File

@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService')
const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger')
const apiKeyService = require('../services/apiKeyService')
const router = express.Router()
function hasDroidPermission(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'droid'
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
}
/**

View File

@@ -29,6 +29,7 @@ const {
handleStreamGenerateContent,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleStandardGenerateContent,
handleStandardStreamGenerateContent,
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
router.get('/key-info', authenticateApiKey, handleKeyInfo)
// ============================================================================
// v1internal 独有路由listExperiments
// v1internal 独有路由
// ============================================================================
/**
@@ -81,6 +82,12 @@ router.post(
handleSimpleEndpoint('listExperiments')
)
/**
* POST /v1internal:retrieveUserQuota
* 获取用户配额信息Gemini CLI 0.22.2+ 需要)
*/
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
/**
* POST /v1beta/models/:modelName:listExperiments
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)

View File

@@ -8,10 +8,12 @@ const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const claudeRelayService = require('../services/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
const openaiToClaude = require('../services/openaiToClaude')
const apiKeyService = require('../services/apiKeyService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const { getSafeMessage } = require('../utils/errorSanitizer')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const pricingService = require('../services/pricingService')
@@ -19,18 +21,24 @@ const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
function queueRateLimitUpdate(
rateLimitInfo,
usageSummary,
model,
context = '',
keyId = null,
accountType = null
) {
if (!rateLimitInfo) {
return
}
const label = context ? ` (${context})` : ''
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
.then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
@@ -235,7 +243,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
}
throw error
}
const { accountId } = accountSelection
const { accountId, accountType } = accountSelection
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
@@ -265,13 +273,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
}
})
// 使用转换后的响应流 (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
await claudeRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
(usage) => {
// 使用转换后的响应流 (根据账户类型选择转发服务)
// 创建 usage 回调函数
const usageCallback = (usage) => {
// 记录使用统计
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
const model = usage.model || claudeRequest.model
@@ -288,7 +292,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
model,
accountId
accountId,
accountType
)
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
@@ -303,27 +308,64 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens
},
model,
'openai-claude-stream'
`openai-${accountType}-stream`,
req.apiKey?.id,
accountType
)
}
},
// 流转换器
(() => {
// 为每个请求创建独立的会话ID
}
// 创建流转换器
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
})(),
const streamTransformer = (chunk) =>
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
// 根据账户类型选择转发服务
if (accountType === 'claude-console') {
// Claude Console 账户使用 Console 转发服务
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
accountId,
streamTransformer
)
} else {
// Claude Official 账户使用标准转发服务
await claudeRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
streamTransformer,
{
betaHeader:
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
}
)
}
} else {
// 非流式请求
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
// 发送请求到 Claude (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
const claudeResponse = await claudeRelayService.relayRequest(
// 根据账户类型选择转发服务
let claudeResponse
if (accountType === 'claude-console') {
// Claude Console 账户使用 Console 转发服务
claudeResponse = await claudeConsoleRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
res,
claudeCodeHeaders,
accountId
)
} else {
// Claude Official 账户使用标准转发服务
claudeResponse = await claudeRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
@@ -331,6 +373,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
claudeCodeHeaders,
{ betaHeader: 'oauth-2025-04-20' }
)
}
// 解析 Claude 响应
let claudeData
@@ -376,7 +419,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
claudeRequest.model,
accountId
accountId,
accountType
)
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
@@ -391,7 +435,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens
},
claudeRequest.model,
'openai-claude-non-stream'
`openai-${accountType}-non-stream`,
req.apiKey?.id,
accountType
)
}
@@ -402,16 +448,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
const duration = Date.now() - startTime
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
} catch (error) {
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
} else {
logger.error('❌ OpenAI-Claude request error:', error)
}
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
if (!res.headersSent) {
// 客户端断开使用 499 状态码 (Client Closed Request)
if (error.message === 'Client disconnected') {
res.status(499).end()
} else {
const status = error.status || 500
res.status(status).json({
error: {
message: error.message || 'Internal server error',
message: getSafeMessage(error),
type: 'server_error',
code: 'internal_error'
}
})
}
}
} finally {
// 清理资源
if (abortController) {

View File

@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const { getAvailableModels } = require('../services/geminiRelayService')
const crypto = require('crypto')
const apiKeyService = require('../services/apiKeyService')
// 生成会话哈希
function generateSessionHash(req) {
@@ -19,10 +20,19 @@ function generateSessionHash(req) {
return crypto.createHash('sha256').update(sessionData).digest('hex')
}
function ensureAntigravityProjectId(account) {
if (account.projectId) {
return account.projectId
}
if (account.tempProjectId) {
return account.tempProjectId
}
return `ag-${crypto.randomBytes(8).toString('hex')}`
}
// 检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
// 转换 OpenAI 消息格式到 Gemini 格式
@@ -335,21 +345,44 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const client = await geminiAccountService.getOauthClient(
account.accessToken,
account.refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
if (actualStream) {
// 流式响应
const oauthProvider = account.oauthProvider || 'gemini-cli'
let { projectId } = account
if (oauthProvider === 'antigravity') {
projectId = ensureAntigravityProjectId(account)
if (!account.projectId && account.tempProjectId !== projectId) {
await geminiAccountService.updateTempProjectId(account.id, projectId)
account.tempProjectId = projectId
}
}
logger.info('StreamGenerateContent request', {
model,
projectId: account.projectId,
projectId,
apiKeyId: apiKeyData.id
})
const streamResponse = await geminiAccountService.generateContentStream(
const streamResponse =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
account.projectId, // 使用有权限的项目ID
projectId,
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
: await geminiAccountService.generateContentStream(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
@@ -499,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计
if (!usageReported && totalUsage.totalTokenCount > 0) {
try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage(
apiKeyData.id,
totalUsage.promptTokenCount || 0,
@@ -507,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
account.id,
'gemini'
)
logger.info(
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
@@ -559,17 +592,38 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
})
} else {
// 非流式响应
const oauthProvider = account.oauthProvider || 'gemini-cli'
let { projectId } = account
if (oauthProvider === 'antigravity') {
projectId = ensureAntigravityProjectId(account)
if (!account.projectId && account.tempProjectId !== projectId) {
await geminiAccountService.updateTempProjectId(account.id, projectId)
account.tempProjectId = projectId
}
}
logger.info('GenerateContent request', {
model,
projectId: account.projectId,
projectId,
apiKeyId: apiKeyData.id
})
const response = await geminiAccountService.generateContent(
const response =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentAntigravity(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
account.projectId, // 使用有权限的项目ID
projectId,
apiKeyData.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
: await geminiAccountService.generateContent(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
@@ -580,7 +634,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计
if (openaiResponse.usage) {
try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage(
apiKeyData.id,
openaiResponse.usage.prompt_tokens || 0,
@@ -588,7 +641,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
account.id,
'gemini'
)
logger.info(
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`
@@ -604,7 +658,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const duration = Date.now() - startTime
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
} catch (error) {
logger.error('OpenAI-Gemini request error:', error)
const statusForLog = error?.status || error?.response?.status
logger.error('OpenAI-Gemini request error', {
message: error?.message,
status: statusForLog,
code: error?.code,
requestUrl: error?.config?.url,
requestMethod: error?.config?.method,
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
})
// 处理速率限制
if (error.status === 429) {
@@ -613,6 +675,12 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
}
}
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
if (!res.headersSent) {
// 客户端断开使用 499 状态码 (Client Closed Request)
if (error.message === 'Client disconnected') {
res.status(499).end()
} else {
// 返回 OpenAI 格式的错误响应
const status = error.status || 500
const errorResponse = {
@@ -622,8 +690,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
code: 'internal_error'
}
}
res.status(status).json(errorResponse)
}
}
} finally {
// 清理资源
if (abortController) {
@@ -633,8 +702,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
return undefined
})
// OpenAI 兼容的模型列表端点
router.get('/v1/models', authenticateApiKey, async (req, res) => {
// 获取可用模型列表的共享处理器
async function handleGetModels(req, res) {
try {
const apiKeyData = req.apiKey
@@ -665,8 +734,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
let models = []
if (account) {
// 获取实际的模型列表
models = await getAvailableModels(account.accessToken, account.proxy)
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
try {
const oauthProvider = account.oauthProvider || 'gemini-cli'
models =
oauthProvider === 'antigravity'
? await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
account.proxy,
account.refreshToken
)
: await getAvailableModels(account.accessToken, account.proxy)
} catch (error) {
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
models = []
}
} else {
// 返回默认模型列表
models = [
@@ -679,6 +761,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
]
}
if (!models || models.length === 0) {
models = [
{
id: 'gemini-2.0-flash-exp',
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'google'
}
]
}
// 如果启用了模型限制,过滤模型列表
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
@@ -698,8 +791,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
}
})
}
return undefined
})
}
// OpenAI 兼容的模型列表端点 (带 v1 版)
router.get('/v1/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
router.get('/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型详情端点
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {

View File

@@ -9,9 +9,12 @@ const openaiAccountService = require('../services/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis')
const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const { IncrementalSSEParser } = require('../utils/sseParser')
const { getSafeMessage } = require('../utils/errorSanitizer')
// 创建代理 Agent使用统一的代理工具
function createProxyAgent(proxy) {
@@ -20,8 +23,7 @@ function createProxyAgent(proxy) {
// 检查 API Key 是否具备 OpenAI 权限
function checkOpenAIPermissions(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'openai'
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
}
function normalizeHeaders(headers = {}) {
@@ -68,7 +70,7 @@ function extractCodexUsageHeaders(headers) {
return hasData ? snapshot : null
}
async function applyRateLimitTracking(req, usageSummary, model, context = '') {
async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) {
if (!req.rateLimitInfo) {
return
}
@@ -79,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
const { totalTokens, totalCost } = await updateRateLimitCounters(
req.rateLimitInfo,
usageSummary,
model
model,
req.apiKey?.id,
accountType
)
if (totalTokens > 0) {
@@ -275,7 +279,9 @@ const handleResponses = async (req, res) => {
'text_formatting',
'truncation',
'text',
'service_tier'
'service_tier',
'prompt_cache_retention',
'safety_identifier'
]
fieldsToRemove.forEach((field) => {
delete req.body[field]
@@ -576,7 +582,6 @@ const handleResponses = async (req, res) => {
}
// 处理响应并捕获 usage 数据和真实的 model
let buffer = ''
let usageData = null
let actualModel = null
let usageReported = false
@@ -612,7 +617,8 @@ const handleResponses = async (req, res) => {
0, // OpenAI没有cache_creation_tokens
cacheReadTokens,
actualModel,
accountId
accountId,
'openai'
)
logger.info(
@@ -628,7 +634,8 @@ const handleResponses = async (req, res) => {
cacheReadTokens
},
actualModel,
'openai-non-stream'
'openai-non-stream',
'openai'
)
}
@@ -644,21 +651,11 @@ const handleResponses = async (req, res) => {
}
}
// 解析 SSE 事件以捕获 usage 数据和 model
const parseSSEForUsage = (data) => {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('event: response.completed')) {
// 下一行应该是数据
continue
}
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
const eventData = JSON.parse(jsonStr)
// 使用增量 SSE 解析器
const sseParser = new IncrementalSSEParser()
// 处理解析出的事件
const processSSEEvent = (eventData) => {
// 检查是否是 response.completed 事件
if (eventData.type === 'response.completed' && eventData.response) {
// 从响应中获取真实的 model
@@ -684,34 +681,20 @@ const handleResponses = async (req, res) => {
)
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
upstream.data.on('data', (chunk) => {
try {
const chunkStr = chunk.toString()
// 转发数据给客户端
if (!res.destroyed) {
res.write(chunk)
}
// 同时解析数据以捕获 usage 信息
buffer += chunkStr
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
// 使用增量解析器处理数据
const events = sseParser.feed(chunk.toString())
for (const event of events) {
if (event.trim()) {
parseSSEForUsage(event)
}
if (event.type === 'data' && event.data) {
processSSEEvent(event.data)
}
}
} catch (error) {
@@ -721,8 +704,14 @@ const handleResponses = async (req, res) => {
upstream.data.on('end', async () => {
// 处理剩余的 buffer
if (buffer.trim()) {
parseSSEForUsage(buffer)
const remaining = sseParser.getRemaining()
if (remaining.trim()) {
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
for (const event of events) {
if (event.type === 'data' && event.data) {
processSSEEvent(event.data)
}
}
}
// 记录使用统计
@@ -744,7 +733,8 @@ const handleResponses = async (req, res) => {
0, // OpenAI没有cache_creation_tokens
cacheReadTokens,
modelToRecord,
accountId
accountId,
'openai'
)
logger.info(
@@ -761,7 +751,8 @@ const handleResponses = async (req, res) => {
cacheReadTokens
},
modelToRecord,
'openai-stream'
'openai-stream',
'openai'
)
} catch (error) {
logger.error('Failed to record OpenAI usage:', error)
@@ -851,13 +842,15 @@ const handleResponses = async (req, res) => {
let responsePayload = error.response?.data
if (!responsePayload) {
responsePayload = { error: { message: error.message || 'Internal server error' } }
responsePayload = { error: { message: getSafeMessage(error) } }
} else if (typeof responsePayload === 'string') {
responsePayload = { error: { message: responsePayload } }
responsePayload = { error: { message: getSafeMessage(responsePayload) } }
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
responsePayload = {
error: { message: responsePayload.message || error.message || 'Internal server error' }
error: { message: getSafeMessage(responsePayload.message || error) }
}
} else if (responsePayload.error?.message) {
responsePayload.error.message = getSafeMessage(responsePayload.error.message)
}
if (!res.headersSent) {
@@ -875,16 +868,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
// 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => {
try {
const { usage } = req.apiKey
const keyData = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({
object: 'usage',
total_tokens: usage.total.tokens,
total_requests: usage.total.requests,
daily_tokens: usage.daily.tokens,
daily_requests: usage.daily.requests,
monthly_tokens: usage.monthly.tokens,
monthly_requests: usage.monthly.requests
total_tokens: usage?.total?.tokens || 0,
total_requests: usage?.total?.requests || 0,
daily_tokens: usage?.daily?.tokens || 0,
daily_requests: usage?.daily?.requests || 0,
monthly_tokens: usage?.monthly?.tokens || 0,
monthly_requests: usage?.monthly?.requests || 0
})
} catch (error) {
logger.error('Failed to get usage stats:', error)
@@ -901,25 +896,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
router.get('/key-info', authenticateApiKey, async (req, res) => {
try {
const keyData = req.apiKey
// 按需查询 usage 数据(仅 key-info 端点需要)
const usage = await redis.getUsageStats(keyData.id)
const tokensUsed = usage?.total?.tokens || 0
res.json({
id: keyData.id,
name: keyData.name,
description: keyData.description,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
tokens_used: tokensUsed,
tokens_remaining:
keyData.tokenLimit > 0
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
rate_limit: {
window: keyData.rateLimitWindow,
requests: keyData.rateLimitRequests
},
usage: {
total: keyData.usage.total,
daily: keyData.usage.daily,
monthly: keyData.usage.monthly
total: usage?.total || {},
daily: usage?.daily || {},
monthly: usage?.monthly || {}
}
})
} catch (error) {

View File

@@ -8,6 +8,7 @@ const {
handleStreamGenerateContent: geminiHandleStreamGenerateContent
} = require('../handlers/geminiHandlers')
const openaiRoutes = require('./openaiRoutes')
const apiKeyService = require('../services/apiKeyService')
const router = express.Router()
@@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限
const permissions = req.apiKey.permissions || 'all'
const { permissions } = req.apiKey
if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层
if (permissions !== 'all' && permissions !== 'claude') {
if (!apiKeyService.hasPermission(permissions, 'claude')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Claude',
@@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') {
// OpenAI 后端
if (permissions !== 'all' && permissions !== 'openai') {
if (!apiKeyService.hasPermission(permissions, 'openai')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access OpenAI',
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
return await openaiRoutes.handleResponses(req, res)
} else if (backend === 'gemini') {
// Gemini 后端
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Gemini',

View File

@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 额度卡核销相关路由
// ═══════════════════════════════════════════════════════════════════════════
const quotaCardService = require('../services/quotaCardService')
// 🎫 核销额度卡
router.post('/redeem-card', authenticateUser, async (req, res) => {
try {
const { code, apiKeyId } = req.body
if (!code) {
return res.status(400).json({
error: 'Missing card code',
message: 'Card code is required'
})
}
if (!apiKeyId) {
return res.status(400).json({
error: 'Missing API key ID',
message: 'API key ID is required'
})
}
// 验证 API Key 属于当前用户
const keyData = await redis.getApiKey(apiKeyId)
if (!keyData || Object.keys(keyData).length === 0) {
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
})
}
if (keyData.userId !== req.user.id) {
return res.status(403).json({
error: 'Forbidden',
message: 'You can only redeem cards to your own API keys'
})
}
// 执行核销
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Redeem card error:', error)
res.status(400).json({
error: 'Redeem failed',
message: error.message
})
}
})
// 📋 获取用户的核销历史
router.get('/redemption-history', authenticateUser, async (req, res) => {
try {
const { limit = 50, offset = 0 } = req.query
const result = await quotaCardService.getRedemptions({
userId: req.user.id,
limit: parseInt(limit),
offset: parseInt(offset)
})
res.json({
success: true,
data: result
})
} catch (error) {
logger.error('❌ Get redemption history error:', error)
res.status(500).json({
error: 'Failed to get redemption history',
message: error.message
})
}
})
// 📊 获取用户的额度信息
router.get('/quota-info', authenticateUser, async (req, res) => {
try {
const { apiKeyId } = req.query
if (!apiKeyId) {
return res.status(400).json({
error: 'Missing API key ID',
message: 'API key ID is required'
})
}
// 验证 API Key 属于当前用户
const keyData = await redis.getApiKey(apiKeyId)
if (!keyData || Object.keys(keyData).length === 0) {
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
})
}
if (keyData.userId !== req.user.id) {
return res.status(403).json({
error: 'Forbidden',
message: 'You can only view your own API key quota'
})
}
// 检查是否为聚合 Key
if (keyData.isAggregated !== 'true') {
return res.json({
success: true,
data: {
isAggregated: false,
message: 'This is a traditional API key, not using quota system'
}
})
}
// 解析聚合 Key 数据
let permissions = []
let serviceQuotaLimits = {}
let serviceQuotaUsed = {}
try {
permissions = JSON.parse(keyData.permissions || '[]')
} catch (e) {
permissions = [keyData.permissions]
}
try {
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
} catch (e) {
// 解析失败使用默认值
}
res.json({
success: true,
data: {
isAggregated: true,
quotaLimit: parseFloat(keyData.quotaLimit || 0),
quotaUsed: parseFloat(keyData.quotaUsed || 0),
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
permissions,
serviceQuotaLimits,
serviceQuotaUsed,
expiresAt: keyData.expiresAt
}
})
} catch (error) {
logger.error('❌ Get quota info error:', error)
res.status(500).json({
error: 'Failed to get quota info',
message: error.message
})
}
})
module.exports = router

View File

@@ -74,7 +74,7 @@ router.post('/auth/login', async (req, res) => {
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
if (!isValidUsername || !isValidPassword) {
logger.security(`🔒 Failed login attempt for username: ${username}`)
logger.security(`Failed login attempt for username: ${username}`)
return res.status(401).json({
error: 'Invalid credentials',
message: 'Invalid username or password'
@@ -96,7 +96,7 @@ router.post('/auth/login', async (req, res) => {
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
// init.json 是唯一真实数据源
logger.success(`🔐 Admin login successful: ${username}`)
logger.success(`Admin login successful: ${username}`)
return res.json({
success: true,
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
// 获取当前会话
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取当前管理员信息
const adminData = await redis.getSession('admin_credentials')
if (!adminData) {
@@ -183,7 +197,7 @@ router.post('/auth/change-password', async (req, res) => {
// 验证当前密码
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
if (!isValidPassword) {
logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`)
logger.security(`Invalid current password attempt for user: ${sessionData.username}`)
return res.status(401).json({
error: 'Invalid current password',
message: 'Current password is incorrect'
@@ -239,7 +253,7 @@ router.post('/auth/change-password', async (req, res) => {
// 清除当前会话(强制用户重新登录)
await redis.deleteSession(token)
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`)
logger.success(`Admin password changed successfully for user: ${updatedUsername}`)
return res.json({
success: true,
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
// 获取当前会话
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取管理员信息
const adminData = await redis.getSession('admin_credentials')
if (!adminData) {
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复检查空对象hgetall 对不存在的 key 返回 {}
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`Invalid session structure detected from ${req.ip || 'unknown'}`)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 更新最后活动时间
sessionData.lastActivity = new Date().toISOString()
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)

View File

@@ -0,0 +1,789 @@
const redis = require('../models/redis')
const balanceScriptService = require('./balanceScriptService')
const logger = require('../utils/logger')
const CostCalculator = require('../utils/costCalculator')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
class AccountBalanceService {
constructor(options = {}) {
this.redis = options.redis || redis
this.logger = options.logger || logger
this.providers = new Map()
this.CACHE_TTL_SECONDS = 3600
this.LOCAL_TTL_SECONDS = 300
this.LOW_BALANCE_THRESHOLD = 10
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
this.DEFAULT_CONCURRENCY = 10
}
getSupportedPlatforms() {
return [
'claude',
'claude-console',
'gemini',
'gemini-api',
'openai',
'openai-responses',
'azure_openai',
'bedrock',
'droid',
'ccr'
]
}
normalizePlatform(platform) {
if (!platform) {
return null
}
const value = String(platform).trim().toLowerCase()
// 兼容实施文档与历史命名
if (value === 'claude-official') {
return 'claude'
}
if (value === 'azure-openai') {
return 'azure_openai'
}
// 保持前端平台键一致
return value
}
registerProvider(platform, provider) {
const normalized = this.normalizePlatform(platform)
if (!normalized) {
throw new Error('registerProvider: 缺少 platform')
}
if (!provider || typeof provider.queryBalance !== 'function') {
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
}
this.providers.set(normalized, provider)
}
async getAccountBalance(accountId, platform, options = {}) {
const normalizedPlatform = this.normalizePlatform(platform)
const account = await this.getAccount(accountId, normalizedPlatform)
if (!account) {
return null
}
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
}
async refreshAccountBalance(accountId, platform) {
const normalizedPlatform = this.normalizePlatform(platform)
const account = await this.getAccount(accountId, normalizedPlatform)
if (!account) {
return null
}
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
queryApi: true,
useCache: false
})
}
async getAllAccountsBalance(platform, options = {}) {
const normalizedPlatform = this.normalizePlatform(platform)
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
const queryApi = this._parseBoolean(options.queryApi) || false
const useCache = options.useCache !== false
const results = await this._mapWithConcurrency(
accounts,
this.DEFAULT_CONCURRENCY,
async (acc) => {
try {
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
queryApi,
useCache
})
return { ...balance, name: acc.name || '' }
} catch (error) {
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
return {
success: true,
data: {
accountId: acc?.id,
platform: normalizedPlatform,
balance: null,
quota: null,
statistics: {},
source: 'local',
lastRefreshAt: new Date().toISOString(),
cacheExpiresAt: null,
status: 'error',
error: error.message || '批量查询失败'
},
name: acc?.name || ''
}
}
}
)
return results
}
async getBalanceSummary() {
const platforms = this.getSupportedPlatforms()
const summary = {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
for (const platform of platforms) {
const accounts = await this.getAllAccountsByPlatform(platform)
const platformData = {
count: accounts.length,
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
accounts: []
}
const balances = await this._mapWithConcurrency(
accounts,
this.DEFAULT_CONCURRENCY,
async (acc) => {
const balance = await this._getAccountBalanceForAccount(acc, platform, {
queryApi: false,
useCache: true
})
return { ...balance, name: acc.name || '' }
}
)
for (const item of balances) {
platformData.accounts.push(item)
const amount = item?.data?.balance?.amount
const percentage = item?.data?.quota?.percentage
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
const isHighUsage =
typeof percentage === 'number' &&
Number.isFinite(percentage) &&
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
if (hasAmount) {
platformData.totalBalance += amount
}
if (isLowBalance || isHighUsage) {
platformData.lowBalanceCount += 1
summary.lowBalanceCount += 1
}
platformData.totalCost += totalCost
}
summary.platforms[platform] = platformData
summary.totalBalance += platformData.totalBalance
summary.totalCost += platformData.totalCost
}
return summary
}
async clearCache(accountId, platform) {
const normalizedPlatform = this.normalizePlatform(platform)
if (!normalizedPlatform) {
throw new Error('缺少 platform 参数')
}
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
}
async getAccount(accountId, platform) {
if (!accountId || !platform) {
return null
}
const serviceMap = {
claude: require('./claudeAccountService'),
'claude-console': require('./claudeConsoleAccountService'),
gemini: require('./geminiAccountService'),
'gemini-api': require('./geminiApiAccountService'),
openai: require('./openaiAccountService'),
'openai-responses': require('./openaiResponsesAccountService'),
azure_openai: require('./azureOpenaiAccountService'),
bedrock: require('./bedrockAccountService'),
droid: require('./droidAccountService'),
ccr: require('./ccrAccountService')
}
const service = serviceMap[platform]
if (!service || typeof service.getAccount !== 'function') {
return null
}
const result = await service.getAccount(accountId)
// 处理不同服务返回格式的差异
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.success ? result.data : null
}
return result
}
async getAllAccountsByPlatform(platform) {
if (!platform) {
return []
}
const serviceMap = {
claude: require('./claudeAccountService'),
'claude-console': require('./claudeConsoleAccountService'),
gemini: require('./geminiAccountService'),
'gemini-api': require('./geminiApiAccountService'),
openai: require('./openaiAccountService'),
'openai-responses': require('./openaiResponsesAccountService'),
azure_openai: require('./azureOpenaiAccountService'),
bedrock: require('./bedrockAccountService'),
droid: require('./droidAccountService'),
ccr: require('./ccrAccountService')
}
const service = serviceMap[platform]
if (!service) {
return []
}
// Bedrock 特殊:返回 { success, data }
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
const result = await service.getAllAccounts()
return result?.success ? result.data || [] : []
}
if (platform === 'openai-responses') {
return await service.getAllAccounts(true)
}
if (typeof service.getAllAccounts !== 'function') {
return []
}
return await service.getAllAccounts()
}
async _getAccountBalanceForAccount(account, platform, options = {}) {
const queryMode = this._parseQueryMode(options.queryApi)
const useCache = options.useCache !== false
const accountId = account?.id
if (!accountId) {
// 如果账户缺少 id返回空响应而不是抛出错误避免接口报错和UI错误
this.logger.warn('账户缺少 id返回空余额数据', { account, platform })
return this._buildResponse(
{
status: 'error',
errorMessage: '账户数据异常',
balance: null,
currency: 'USD',
quota: null,
statistics: {},
lastRefreshAt: new Date().toISOString()
},
'unknown',
platform,
'local',
null,
{ scriptEnabled: false, scriptConfigured: false }
)
}
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
let scriptConfig = null
let scriptConfigured = false
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
scriptConfigured = !!(
scriptConfig &&
scriptConfig.scriptBody &&
String(scriptConfig.scriptBody).trim().length > 0
)
}
const scriptEnabled = isBalanceScriptEnabled()
const scriptMeta = { scriptEnabled, scriptConfigured }
const localBalance = await this._getBalanceFromLocal(accountId, platform)
const localStatistics = localBalance.statistics || {}
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
// 安全限制queryApi=auto 仅用于 Antigravitygemini + oauthProvider=antigravity账户
const effectiveQueryMode =
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
? 'local'
: queryMode
// local: 仅本地统计/缓存auto: 优先缓存,无缓存则尝试远程 Provider并缓存结果
if (effectiveQueryMode !== 'api') {
if (useCache) {
const cached = await this.redis.getAccountBalance(platform, accountId)
if (cached && cached.status === 'success') {
return this._buildResponse(
{
status: cached.status,
errorMessage: cached.errorMessage,
balance: quotaFromLocal.balance ?? cached.balance,
currency: quotaFromLocal.currency || cached.currency || 'USD',
quota: quotaFromLocal.quota || cached.quota || null,
statistics: localStatistics,
lastRefreshAt: cached.lastRefreshAt
},
accountId,
platform,
'cache',
cached.ttlSeconds,
scriptMeta
)
}
}
if (effectiveQueryMode === 'local') {
return this._buildResponse(
{
status: 'success',
errorMessage: null,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: localBalance.lastCalculated
},
accountId,
platform,
'local',
null,
scriptMeta
)
}
}
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider失败自动降级到本地统计
let providerResult
if (scriptEnabled && scriptConfigured) {
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
} else {
const provider = this.providers.get(platform)
if (!provider) {
return this._buildResponse(
{
status: 'error',
errorMessage: `不支持的平台: ${platform}`,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: new Date().toISOString()
},
accountId,
platform,
'local',
null,
scriptMeta
)
}
providerResult = await this._getBalanceFromProvider(provider, account)
}
const isRemoteSuccess =
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
if (isRemoteSuccess) {
await this.redis.setAccountBalance(
platform,
accountId,
providerResult,
this.CACHE_TTL_SECONDS
)
}
const source = isRemoteSuccess ? 'api' : 'local'
return this._buildResponse(
{
status: providerResult.status,
errorMessage: providerResult.errorMessage,
balance: quotaFromLocal.balance ?? providerResult.balance,
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
quota: quotaFromLocal.quota || providerResult.quota || null,
statistics: localStatistics,
lastRefreshAt: providerResult.lastRefreshAt
},
accountId,
platform,
source,
null,
scriptMeta
)
}
async _getBalanceFromScript(scriptConfig, accountId, platform) {
try {
const result = await balanceScriptService.execute({
scriptBody: scriptConfig.scriptBody,
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
variables: {
baseUrl: scriptConfig.baseUrl || '',
apiKey: scriptConfig.apiKey || '',
token: scriptConfig.token || '',
accountId,
platform,
extra: scriptConfig.extra || ''
}
})
const mapped = result?.mapped || {}
return {
status: mapped.status || 'error',
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
currency: mapped.currency || 'USD',
quota: mapped.quota || null,
queryMethod: 'api',
rawData: mapped.rawData || result?.response?.data || null,
lastRefreshAt: new Date().toISOString(),
errorMessage: mapped.errorMessage || ''
}
} catch (error) {
return {
status: 'error',
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: null,
lastRefreshAt: new Date().toISOString(),
errorMessage: error.message || '脚本执行失败'
}
}
}
async _getBalanceFromProvider(provider, account) {
try {
const result = await provider.queryBalance(account)
return {
status: 'success',
balance: typeof result?.balance === 'number' ? result.balance : null,
currency: result?.currency || 'USD',
quota: result?.quota || null,
queryMethod: result?.queryMethod || 'api',
rawData: result?.rawData || null,
lastRefreshAt: new Date().toISOString(),
errorMessage: ''
}
} catch (error) {
return {
status: 'error',
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: null,
lastRefreshAt: new Date().toISOString(),
errorMessage: error.message || '查询失败'
}
}
}
async _getBalanceFromLocal(accountId, platform) {
const cached = await this.redis.getLocalBalance(platform, accountId)
if (cached && cached.statistics) {
return cached
}
const statistics = await this._computeLocalStatistics(accountId)
const localBalance = {
status: 'success',
balance: null,
currency: 'USD',
statistics,
queryMethod: 'local',
lastCalculated: new Date().toISOString()
}
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
return localBalance
}
async _computeLocalStatistics(accountId) {
const safeNumber = (value) => {
const num = Number(value)
return Number.isFinite(num) ? num : 0
}
try {
const usageStats = await this.redis.getAccountUsageStats(accountId)
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
const monthlyCost = await this._computeMonthlyCost(accountId)
const totalCost = await this._computeTotalCost(accountId)
return {
totalCost,
dailyCost,
monthlyCost,
totalRequests: safeNumber(usageStats?.total?.requests || 0),
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
}
} catch (error) {
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
return {
totalCost: 0,
dailyCost: 0,
monthlyCost: 0,
totalRequests: 0,
dailyRequests: 0,
monthlyRequests: 0
}
}
}
async _computeMonthlyCost(accountId) {
const tzDate = this.redis.getDateInTimezone(new Date())
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
return await this._sumModelCostsByKeysPattern(pattern)
}
async _computeTotalCost(accountId) {
const pattern = `account_usage:model:monthly:${accountId}:*:*`
return await this._sumModelCostsByKeysPattern(pattern)
}
async _sumModelCostsByKeysPattern(pattern) {
try {
const client = this.redis.getClientSafe()
let totalCost = 0
let cursor = '0'
const scanCount = 200
let iterations = 0
const maxIterations = 2000
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
cursor = nextCursor
iterations += 1
if (!keys || keys.length === 0) {
continue
}
const pipeline = client.pipeline()
keys.forEach((key) => pipeline.hgetall(key))
const results = await pipeline.exec()
for (let i = 0; i < results.length; i += 1) {
const [, data] = results[i] || []
if (!data || Object.keys(data).length === 0) {
continue
}
const parts = String(keys[i]).split(':')
const model = parts[4] || 'unknown'
const usage = {
input_tokens: parseInt(data.inputTokens || 0),
output_tokens: parseInt(data.outputTokens || 0),
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
}
const costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total || 0
}
if (iterations >= maxIterations) {
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
break
}
} while (cursor !== '0')
return totalCost
} catch (error) {
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
return 0
}
}
_buildQuotaFromLocal(account, statistics) {
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return { balance: null, currency: null, quota: null }
}
const dailyQuota = Number(account.dailyQuota || 0)
const used = Number(statistics?.dailyCost || 0)
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
// 不限制
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
return {
balance: null,
currency: 'USD',
quota: {
daily: Infinity,
used,
remaining: Infinity,
percentage: 0,
unlimited: true,
resetAt
}
}
}
const remaining = Math.max(0, dailyQuota - used)
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
return {
balance: remaining,
currency: 'USD',
quota: {
daily: dailyQuota,
used,
remaining,
resetAt,
percentage: Math.round(percentage * 100) / 100
}
}
}
_computeNextResetAt(resetTime) {
const now = new Date()
const tzNow = this.redis.getDateInTimezone(now)
const offsetMs = tzNow.getTime() - now.getTime()
const [h, m] = String(resetTime || '00:00')
.split(':')
.map((n) => parseInt(n, 10))
const resetHour = Number.isFinite(h) ? h : 0
const resetMinute = Number.isFinite(m) ? m : 0
const year = tzNow.getUTCFullYear()
const month = tzNow.getUTCMonth()
const day = tzNow.getUTCDate()
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
if (resetAtMs <= now.getTime()) {
resetAtMs += 24 * 60 * 60 * 1000
}
return new Date(resetAtMs).toISOString()
}
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
const now = new Date()
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
const currency = balanceData.currency || 'USD'
let cacheExpiresAt = null
if (source === 'cache') {
const ttl =
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
}
return {
success: true,
data: {
accountId,
platform,
balance:
typeof amount === 'number'
? {
amount,
currency,
formattedAmount: this._formatCurrency(amount, currency)
}
: null,
quota: balanceData.quota || null,
statistics: balanceData.statistics || {},
source,
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
cacheExpiresAt,
status: balanceData.status || 'success',
error: balanceData.errorMessage || null,
...(extraData && typeof extraData === 'object' ? extraData : {})
}
}
}
_formatCurrency(amount, currency = 'USD') {
try {
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return 'N/A'
}
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
} catch (error) {
return `$${amount.toFixed(2)}`
}
}
_parseBoolean(value) {
if (typeof value === 'boolean') {
return value
}
if (typeof value !== 'string') {
return null
}
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false
}
return null
}
_parseQueryMode(value) {
if (value === 'auto') {
return 'auto'
}
const parsed = this._parseBoolean(value)
return parsed ? 'api' : 'local'
}
async _mapWithConcurrency(items, limit, mapper) {
const concurrency = Math.max(1, Number(limit) || 1)
const list = Array.isArray(items) ? items : []
const results = new Array(list.length)
let nextIndex = 0
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
while (nextIndex < list.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
}
})
await Promise.all(workers)
return results
}
}
const accountBalanceService = new AccountBalanceService()
module.exports = accountBalanceService
module.exports.AccountBalanceService = AccountBalanceService

View File

@@ -7,6 +7,62 @@ class AccountGroupService {
this.GROUPS_KEY = 'account_groups'
this.GROUP_PREFIX = 'account_group:'
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:'
this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated'
}
/**
* 确保反向索引存在(启动时自动调用)
* 检查是否已迁移,如果没有则自动回填
*/
async ensureReverseIndexes() {
try {
const client = redis.getClientSafe()
if (!client) {
return
}
// 检查是否已迁移
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
if (migrated === 'true') {
logger.debug('📁 账户分组反向索引已存在,跳过回填')
return
}
logger.info('📁 开始回填账户分组反向索引...')
const allGroupIds = await client.smembers(this.GROUPS_KEY)
if (allGroupIds.length === 0) {
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
return
}
let totalOperations = 0
for (const groupId of allGroupIds) {
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
if (!group || !group.platform) {
continue
}
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
if (members.length === 0) {
continue
}
const pipeline = client.pipeline()
for (const accountId of members) {
pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId)
}
await pipeline.exec()
totalOperations += members.length
}
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations}`)
} catch (error) {
logger.error('❌ 账户分组反向索引回填失败:', error)
}
}
/**
@@ -50,7 +106,7 @@ class AccountGroupService {
// 添加到分组集合
await client.sadd(this.GROUPS_KEY, groupId)
logger.success(`创建账户分组成功: ${name} (${platform})`)
logger.success(`创建账户分组成功: ${name} (${platform})`)
return group
} catch (error) {
@@ -101,7 +157,7 @@ class AccountGroupService {
// 返回更新后的完整数据
const updatedGroup = await client.hgetall(groupKey)
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
return updatedGroup
} catch (error) {
@@ -143,7 +199,7 @@ class AccountGroupService {
// 从分组集合中移除
await client.srem(this.GROUPS_KEY, groupId)
logger.success(`删除账户分组成功: ${group.name}`)
logger.success(`删除账户分组成功: ${group.name}`)
} catch (error) {
logger.error('❌ 删除账户分组失败:', error)
throw error
@@ -234,7 +290,10 @@ class AccountGroupService {
// 添加到分组成员集合
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`)
// 维护反向索引
await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId)
logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`)
} catch (error) {
logger.error('❌ 添加账户到分组失败:', error)
throw error
@@ -245,15 +304,26 @@ class AccountGroupService {
* 从分组移除账户
* @param {string} accountId - 账户ID
* @param {string} groupId - 分组ID
* @param {string} platform - 平台(可选,如果不传则从分组获取)
*/
async removeAccountFromGroup(accountId, groupId) {
async removeAccountFromGroup(accountId, groupId, platform = null) {
try {
const client = redis.getClientSafe()
// 从分组成员集合中移除
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
logger.success(`✅ 从分组移除账户成功: ${accountId}`)
// 维护反向索引
let groupPlatform = platform
if (!groupPlatform) {
const group = await this.getGroup(groupId)
groupPlatform = group?.platform
}
if (groupPlatform) {
await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId)
}
logger.success(`从分组移除账户成功: ${accountId}`)
} catch (error) {
logger.error('❌ 从分组移除账户失败:', error)
throw error
@@ -399,7 +469,7 @@ class AccountGroupService {
await this.addAccountToGroup(accountId, groupId, accountPlatform)
}
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
} catch (error) {
logger.error('❌ 批量设置账户分组失败:', error)
throw error
@@ -409,8 +479,9 @@ class AccountGroupService {
/**
* 从所有分组中移除账户
* @param {string} accountId - 账户ID
* @param {string} platform - 平台(可选,用于清理反向索引)
*/
async removeAccountFromAllGroups(accountId) {
async removeAccountFromAllGroups(accountId, platform = null) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
@@ -419,12 +490,155 @@ class AccountGroupService {
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
}
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
// 清理反向索引
if (platform) {
await client.del(`account_groups_reverse:${platform}:${accountId}`)
} else {
// 如果没有指定平台,清理所有可能的平台
const platforms = ['claude', 'gemini', 'openai', 'droid']
const pipeline = client.pipeline()
for (const p of platforms) {
pipeline.del(`account_groups_reverse:${p}:${accountId}`)
}
await pipeline.exec()
}
logger.success(`从所有分组移除账户成功: ${accountId}`)
} catch (error) {
logger.error('❌ 从所有分组移除账户失败:', error)
throw error
}
}
/**
* 批量获取多个账户的分组信息(性能优化版本,使用反向索引)
* @param {Array<string>} accountIds - 账户ID数组
* @param {string} platform - 平台类型
* @param {Object} options - 选项
* @param {boolean} options.skipMemberCount - 是否跳过 memberCount默认 true
* @returns {Map<string, Array>} accountId -> 分组信息数组的映射
*/
async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) {
const { skipMemberCount = true } = options
if (!accountIds || accountIds.length === 0) {
return new Map()
}
try {
const client = redis.getClientSafe()
// Pipeline 批量获取所有账户的分组ID
const pipeline = client.pipeline()
for (const accountId of accountIds) {
pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`)
}
const groupIdResults = await pipeline.exec()
// 收集所有需要的分组ID
const uniqueGroupIds = new Set()
const accountGroupIdsMap = new Map()
let hasAnyGroups = false
accountIds.forEach((accountId, i) => {
const [err, groupIds] = groupIdResults[i]
const ids = err ? [] : groupIds || []
accountGroupIdsMap.set(accountId, ids)
ids.forEach((id) => {
uniqueGroupIds.add(id)
hasAnyGroups = true
})
})
// 如果反向索引全空,回退到原方法(兼容未迁移的数据)
if (!hasAnyGroups) {
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
if (migrated !== 'true') {
logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups')
const result = new Map()
for (const accountId of accountIds) {
try {
const groups = await this.getAccountGroups(accountId)
result.set(accountId, groups)
} catch {
result.set(accountId, [])
}
}
return result
}
}
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
const emptyIndexAccountIds = []
for (const accountId of accountIds) {
const ids = accountGroupIdsMap.get(accountId) || []
if (ids.length === 0) {
emptyIndexAccountIds.push(accountId)
}
}
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
// 部分账户索引缺失,逐个查询并补建
for (const accountId of emptyIndexAccountIds) {
try {
const groups = await this.getAccountGroups(accountId)
if (groups.length > 0) {
const groupIds = groups.map((g) => g.id)
accountGroupIdsMap.set(accountId, groupIds)
groupIds.forEach((id) => uniqueGroupIds.add(id))
// 异步补建反向索引
client
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
.catch(() => {})
}
} catch {
// 忽略错误,保持空数组
}
}
}
// 批量获取分组详情
const groupDetailsMap = new Map()
if (uniqueGroupIds.size > 0) {
const detailPipeline = client.pipeline()
const groupIdArray = Array.from(uniqueGroupIds)
for (const groupId of groupIdArray) {
detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`)
if (!skipMemberCount) {
detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
}
}
const detailResults = await detailPipeline.exec()
const step = skipMemberCount ? 1 : 2
for (let i = 0; i < groupIdArray.length; i++) {
const groupId = groupIdArray[i]
const [err1, groupData] = detailResults[i * step]
if (!err1 && groupData && Object.keys(groupData).length > 0) {
const group = { ...groupData }
if (!skipMemberCount) {
const [err2, memberCount] = detailResults[i * step + 1]
group.memberCount = err2 ? 0 : memberCount || 0
}
groupDetailsMap.set(groupId, group)
}
}
}
// 构建最终结果
const result = new Map()
for (const [accountId, groupIds] of accountGroupIdsMap) {
const groups = groupIds
.map((gid) => groupDetailsMap.get(gid))
.filter(Boolean)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
result.set(accountId, groups)
}
return result
} catch (error) {
logger.error('❌ 批量获取账户分组失败:', error)
return new Map(accountIds.map((id) => [id, []]))
}
}
}
module.exports = new AccountGroupService()

View File

@@ -0,0 +1,420 @@
/**
* 账户定时测试调度服务
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
*/
const cron = require('node-cron')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class AccountTestSchedulerService {
constructor() {
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
this.scheduledTasks = new Map()
// 定期刷新配置的间隔 (毫秒)
this.refreshIntervalMs = 60 * 1000
this.refreshInterval = null
// 当前正在测试的账户
this.testingAccounts = new Set()
// 是否已启动
this.isStarted = false
}
/**
* 验证 cron 表达式是否有效
* @param {string} cronExpression - cron 表达式
* @returns {boolean}
*/
validateCronExpression(cronExpression) {
// 长度检查(防止 DoS
if (!cronExpression || cronExpression.length > 100) {
return false
}
return cron.validate(cronExpression)
}
/**
* 启动调度器
*/
async start() {
if (this.isStarted) {
logger.warn('⚠️ Account test scheduler is already running')
return
}
this.isStarted = true
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
// 初始化所有已配置账户的定时任务
await this._refreshAllTasks()
// 定期刷新配置,以便动态添加/修改的配置能生效
this.refreshInterval = setInterval(() => {
this._refreshAllTasks()
}, this.refreshIntervalMs)
logger.info(
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
)
}
/**
* 停止调度器
*/
stop() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
// 停止所有 cron 任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
taskInfo.task.stop()
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
}
this.scheduledTasks.clear()
this.isStarted = false
logger.info('🛑 Account test scheduler stopped')
}
/**
* 刷新所有账户的定时任务
* @private
*/
async _refreshAllTasks() {
try {
const platforms = ['claude', 'gemini', 'openai']
const activeAccountKeys = new Set()
// 并行加载所有平台的配置
const allEnabledAccounts = await Promise.all(
platforms.map((platform) =>
redis
.getEnabledTestAccounts(platform)
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
.catch((error) => {
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
return []
})
)
)
// 展平平台数据
const flatAccounts = allEnabledAccounts.flat()
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
if (!cronExpression) {
logger.warn(
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
)
continue
}
const accountKey = `${platform}:${accountId}`
activeAccountKeys.add(accountKey)
// 检查是否需要更新任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
// 如果 cron 表达式和模型都没变,不需要更新
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
continue
}
// 配置变了,停止旧任务
existingTask.task.stop()
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
} else {
logger.info(` Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
}
// 创建新的 cron 任务
this._createCronTask(accountId, platform, cronExpression, model)
}
// 清理已删除或禁用的账户任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
if (!activeAccountKeys.has(accountKey)) {
taskInfo.task.stop()
this.scheduledTasks.delete(accountKey)
logger.info(` Removed cron task for ${accountKey} (disabled or deleted)`)
}
}
} catch (error) {
logger.error('❌ Error refreshing account test tasks:', error)
}
}
/**
* 为单个账户创建 cron 任务
* @param {string} accountId
* @param {string} platform
* @param {string} cronExpression
* @param {string} model - 测试使用的模型
* @private
*/
_createCronTask(accountId, platform, cronExpression, model) {
const accountKey = `${platform}:${accountId}`
// 验证 cron 表达式
if (!this.validateCronExpression(cronExpression)) {
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
return
}
const task = cron.schedule(
cronExpression,
async () => {
await this._runAccountTest(accountId, platform, model)
},
{
scheduled: true,
timezone: process.env.TZ || 'Asia/Shanghai'
}
)
this.scheduledTasks.set(accountKey, {
task,
cronExpression,
model,
accountId,
platform
})
}
/**
* 执行单个账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @private
*/
async _runAccountTest(accountId, platform, model) {
const accountKey = `${platform}:${accountId}`
// 避免重复测试
if (this.testingAccounts.has(accountKey)) {
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
return
}
this.testingAccounts.add(accountKey)
try {
logger.info(
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
)
let testResult
// 根据平台调用对应的测试方法
switch (platform) {
case 'claude':
testResult = await this._testClaudeAccount(accountId, model)
break
case 'gemini':
testResult = await this._testGeminiAccount(accountId, model)
break
case 'openai':
testResult = await this._testOpenAIAccount(accountId, model)
break
default:
testResult = {
success: false,
error: `Unsupported platform: ${platform}`,
timestamp: new Date().toISOString()
}
}
// 保存测试结果
await redis.saveAccountTestResult(accountId, platform, testResult)
// 更新最后测试时间
await redis.setAccountLastTestTime(accountId, platform)
// 记录日志
if (testResult.success) {
logger.info(
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
)
} else {
logger.warn(
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
)
}
return testResult
} catch (error) {
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
const errorResult = {
success: false,
error: error.message,
timestamp: new Date().toISOString()
}
await redis.saveAccountTestResult(accountId, platform, errorResult)
await redis.setAccountLastTestTime(accountId, platform)
return errorResult
} finally {
this.testingAccounts.delete(accountKey)
}
}
/**
* 测试 Claude 账户
* @param {string} accountId
* @param {string} model - 测试使用的模型
* @private
*/
async _testClaudeAccount(accountId, model) {
const claudeRelayService = require('./claudeRelayService')
return await claudeRelayService.testAccountConnectionSync(accountId, model)
}
/**
* 测试 Gemini 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testGeminiAccount(_accountId, _model) {
// Gemini 测试暂时返回未实现
return {
success: false,
error: 'Gemini scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 测试 OpenAI 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testOpenAIAccount(_accountId, _model) {
// OpenAI 测试暂时返回未实现
return {
success: false,
error: 'OpenAI scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 手动触发账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @returns {Promise<Object>} 测试结果
*/
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
return await this._runAccountTest(accountId, platform, model)
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史
*/
async getTestHistory(accountId, platform) {
return await redis.getAccountTestHistory(accountId, platform)
}
/**
* 获取账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>}
*/
async getTestConfig(accountId, platform) {
return await redis.getAccountTestConfig(accountId, platform)
}
/**
* 设置账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
* @returns {Promise<void>}
*/
async setTestConfig(accountId, platform, testConfig) {
// 验证 cron 表达式
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
}
await redis.saveAccountTestConfig(accountId, platform, testConfig)
logger.info(
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
)
// 立即刷新任务,使配置立即生效
if (this.isStarted) {
await this._refreshAllTasks()
}
}
/**
* 更新单个账户的定时任务(配置变更时调用)
* @param {string} accountId
* @param {string} platform
*/
async refreshAccountTask(accountId, platform) {
if (!this.isStarted) {
return
}
const accountKey = `${platform}:${accountId}`
const testConfig = await redis.getAccountTestConfig(accountId, platform)
// 停止现有任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
existingTask.task.stop()
this.scheduledTasks.delete(accountKey)
}
// 如果启用且有有效的 cron 表达式,创建新任务
if (testConfig?.enabled && testConfig?.cronExpression) {
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
logger.info(
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
)
}
}
/**
* 获取调度器状态
* @returns {Object}
*/
getStatus() {
const tasks = []
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
tasks.push({
accountKey,
accountId: taskInfo.accountId,
platform: taskInfo.platform,
cronExpression: taskInfo.cronExpression,
model: taskInfo.model
})
}
return {
running: this.isStarted,
refreshIntervalMs: this.refreshIntervalMs,
scheduledTasksCount: this.scheduledTasks.size,
scheduledTasks: tasks,
currentlyTesting: Array.from(this.testingAccounts)
}
}
}
// 单例模式
const accountTestSchedulerService = new AccountTestSchedulerService()
module.exports = accountTestSchedulerService

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
const axios = require('axios')
const https = require('https')
const { v4: uuidv4 } = require('uuid')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const {
mapAntigravityUpstreamModel,
normalizeAntigravityModelInput,
getAntigravityModelMetadata
} = require('../utils/antigravityModel')
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
const keepAliveAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
timeout: 120000,
maxSockets: 100,
maxFreeSockets: 10
})
function getAntigravityApiUrl() {
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
}
function normalizeBaseUrl(url) {
const str = String(url || '').trim()
return str.endsWith('/') ? str.slice(0, -1) : str
}
function getAntigravityApiUrlCandidates() {
const configured = normalizeBaseUrl(getAntigravityApiUrl())
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
const prod = 'https://cloudcode-pa.googleapis.com'
// 若显式配置了自定义 base url则只使用该地址不做 fallback避免意外路由到别的环境
if (process.env.ANTIGRAVITY_API_URL) {
return [configured]
}
// 默认行为:优先 daily与旧逻辑一致失败时再尝试 prod对齐 CLIProxyAPI
if (configured === normalizeBaseUrl(daily)) {
return [configured, prod]
}
if (configured === normalizeBaseUrl(prod)) {
return [configured, daily]
}
return [configured, prod, daily].filter(Boolean)
}
function getAntigravityHeaders(accessToken, baseUrl) {
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
try {
host = new URL(resolvedBaseUrl).host || host
} catch (e) {
// ignore
}
return {
Host: host,
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip',
requestType: 'agent'
}
}
function generateAntigravityProjectId() {
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
}
function generateAntigravitySessionId() {
return `sess-${uuidv4()}`
}
function resolveAntigravityProjectId(projectId, requestData) {
const candidate = projectId || requestData?.project || requestData?.projectId || null
return candidate || generateAntigravityProjectId()
}
function resolveAntigravitySessionId(sessionId, requestData) {
const candidate =
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
return candidate || generateAntigravitySessionId()
}
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
const model = mapAntigravityUpstreamModel(requestData?.model)
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
const requestPayload = {
...(requestData?.request || {})
}
if (requestPayload.session_id !== undefined) {
delete requestPayload.session_id
}
requestPayload.sessionId = resolvedSessionId
const envelope = {
project: resolvedProjectId,
requestId: `req-${uuidv4()}`,
model,
userAgent: 'antigravity',
request: {
...requestPayload
}
}
if (userPromptId) {
envelope.user_prompt_id = userPromptId
envelope.userPromptId = userPromptId
}
normalizeAntigravityEnvelope(envelope)
return { model, envelope }
}
function normalizeAntigravityThinking(model, requestPayload) {
if (!requestPayload || typeof requestPayload !== 'object') {
return
}
const { generationConfig } = requestPayload
if (!generationConfig || typeof generationConfig !== 'object') {
return
}
const { thinkingConfig } = generationConfig
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
return
}
const normalizedModel = normalizeAntigravityModelInput(model)
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
delete thinkingConfig.thinkingLevel
}
const metadata = getAntigravityModelMetadata(normalizedModel)
if (metadata && !metadata.thinking) {
delete generationConfig.thinkingConfig
return
}
if (!metadata || !metadata.thinking) {
return
}
const budgetRaw = Number(thinkingConfig.thinkingBudget)
if (!Number.isFinite(budgetRaw)) {
return
}
let budget = Math.trunc(budgetRaw)
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
if (maxBudget !== null && budget > maxBudget) {
budget = maxBudget
}
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
? generationConfig.maxOutputTokens
: null
let setDefaultMax = false
if (!effectiveMax && metadata.maxCompletionTokens) {
effectiveMax = metadata.maxCompletionTokens
setDefaultMax = true
}
if (effectiveMax && budget >= effectiveMax) {
budget = Math.max(0, effectiveMax - 1)
}
if (minBudget !== null && budget >= 0 && budget < minBudget) {
delete generationConfig.thinkingConfig
return
}
thinkingConfig.thinkingBudget = budget
if (setDefaultMax) {
generationConfig.maxOutputTokens = effectiveMax
}
}
function normalizeAntigravityEnvelope(envelope) {
if (!envelope || typeof envelope !== 'object') {
return
}
const model = String(envelope.model || '')
const requestPayload = envelope.request
if (!requestPayload || typeof requestPayload !== 'object') {
return
}
if (requestPayload.safetySettings !== undefined) {
delete requestPayload.safetySettings
}
// 对齐 CLIProxyAPI有 tools 时默认启用 VALIDATED除非显式 NONE
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
if (existing?.mode !== 'NONE') {
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
}
}
// 对齐 CLIProxyAPI非 Claude 模型移除 maxOutputTokensAntigravity 环境不稳定)
normalizeAntigravityThinking(model, requestPayload)
if (!model.includes('claude')) {
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
delete requestPayload.generationConfig.maxOutputTokens
}
return
}
// Claude 模型parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400
if (!Array.isArray(requestPayload.tools)) {
return
}
for (const tool of requestPayload.tools) {
if (!tool || typeof tool !== 'object') {
continue
}
const decls = Array.isArray(tool.functionDeclarations)
? tool.functionDeclarations
: Array.isArray(tool.function_declarations)
? tool.function_declarations
: null
if (!decls) {
continue
}
for (const decl of decls) {
if (!decl || typeof decl !== 'object') {
continue
}
let schema =
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
if (typeof schema === 'string' && schema) {
try {
schema = JSON.parse(schema)
} catch (_) {
schema = null
}
}
decl.parameters = cleanJsonSchemaForGemini(schema)
delete decl.parametersJsonSchema
}
}
}
async function request({
accessToken,
proxyConfig = null,
requestData,
projectId = null,
sessionId = null,
userPromptId = null,
stream = false,
signal = null,
params = null,
timeoutMs = null
}) {
const { model, envelope } = buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
let endpoints = getAntigravityApiUrlCandidates()
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL则不改变顺序。
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
const prodHost = 'cloudcode-pa.googleapis.com'
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
const ordered = []
for (const u of endpoints) {
if (String(u).includes(prodHost)) {
ordered.push(u)
}
}
for (const u of endpoints) {
if (!String(u).includes(prodHost)) {
ordered.push(u)
}
}
// 去重并保持 prod -> daily 的稳定顺序
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
const av = String(a)
const bv = String(b)
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
return aScore - bScore
})
}
const isRetryable = (error) => {
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true
}
const status = error?.response?.status
if (status === 429) {
return true
}
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
if (status === 400 || status === 404) {
const data = error?.response?.data
const safeToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// axios responseType=stream 时data 可能是 stream存在循环引用不能 JSON.stringify
if (typeof value === 'object' && typeof value.pipe === 'function') {
return ''
}
if (Buffer.isBuffer(value)) {
try {
return value.toString('utf8')
} catch (_) {
return ''
}
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (_) {
return ''
}
}
return String(value)
}
const text = safeToString(data)
const msg = (text || '').toLowerCase()
return (
msg.includes('requested model is currently unavailable') ||
msg.includes('tool_use') ||
msg.includes('tool_result') ||
msg.includes('requested entity was not found') ||
msg.includes('not found')
)
}
return false
}
let lastError = null
let retriedAfterDelay = false
const attemptRequest = async () => {
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
const axiosConfig = {
url,
method: 'POST',
...(params ? { params } : {}),
headers: getAntigravityHeaders(accessToken, baseUrl),
data: envelope,
timeout: stream ? 0 : timeoutMs || 600000,
...(stream ? { responseType: 'stream' } : {})
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
if (signal) {
axiosConfig.signal = signal
}
try {
dumpAntigravityUpstreamRequest({
requestId: envelope.requestId,
model,
stream,
url,
baseUrl,
params: axiosConfig.params || null,
headers: axiosConfig.headers,
envelope
}).catch(() => {})
const response = await axios(axiosConfig)
return { model, response }
} catch (error) {
lastError = error
const status = error?.response?.status || null
const hasNext = index + 1 < endpoints.length
if (hasNext && isRetryable(error)) {
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
status,
from: baseUrl,
to: endpoints[index + 1],
model
})
continue
}
throw error
}
}
throw lastError || new Error('Antigravity request failed')
}
try {
return await attemptRequest()
} catch (error) {
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
const status = error?.response?.status
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
const data = error?.response?.data
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
const safeDataToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// stream 对象存在循环引用,不能 JSON.stringify
if (typeof value === 'object' && typeof value.pipe === 'function') {
return ''
}
if (Buffer.isBuffer(value)) {
try {
return value.toString('utf8')
} catch (_) {
return ''
}
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (_) {
return ''
}
}
return String(value)
}
const msg = safeDataToString(data)
if (
msg.toLowerCase().includes('resource_exhausted') ||
msg.toLowerCase().includes('no capacity')
) {
retriedAfterDelay = true
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
await new Promise((resolve) => setTimeout(resolve, 2000))
return await attemptRequest()
}
}
throw error
}
}
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
const endpoints = getAntigravityApiUrlCandidates()
let lastError = null
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:fetchAvailableModels`
const axiosConfig = {
url,
method: 'POST',
headers: getAntigravityHeaders(accessToken, baseUrl),
data: {},
timeout: timeoutMs
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
try {
const response = await axios(axiosConfig)
return response.data
} catch (error) {
lastError = error
const status = error?.response?.status
const hasNext = index + 1 < endpoints.length
if (hasNext && (status === 429 || status === 404)) {
continue
}
throw error
}
}
throw lastError || new Error('Antigravity fetchAvailableModels failed')
}
async function countTokens({
accessToken,
proxyConfig = null,
contents,
model,
timeoutMs = 30000
}) {
const upstreamModel = mapAntigravityUpstreamModel(model)
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
const endpoints = getAntigravityApiUrlCandidates()
let lastError = null
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:countTokens`
const axiosConfig = {
url,
method: 'POST',
headers: getAntigravityHeaders(accessToken, baseUrl),
data: {
request: {
model: `models/${upstreamModel}`,
contents
}
},
timeout: timeoutMs
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
try {
const response = await axios(axiosConfig)
return response.data
} catch (error) {
lastError = error
const status = error?.response?.status
const hasNext = index + 1 < endpoints.length
if (hasNext && (status === 429 || status === 404)) {
continue
}
throw error
}
}
throw lastError || new Error('Antigravity countTokens failed')
}
module.exports = {
getAntigravityApiUrl,
getAntigravityApiUrlCandidates,
getAntigravityHeaders,
buildAntigravityEnvelope,
request,
fetchAvailableModels,
countTokens
}

View File

@@ -0,0 +1,173 @@
const apiKeyService = require('./apiKeyService')
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
const antigravityClient = require('./antigravityClient')
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
const requestedModel = normalizeAntigravityModelInput(model)
const { contents, systemInstruction } = convertMessagesToGemini(messages)
const requestData = {
model: requestedModel,
request: {
contents,
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
candidateCount: 1,
topP: 0.95,
topK: 40
},
...(sessionId ? { sessionId } : {})
}
}
if (systemInstruction) {
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
}
return requestData
}
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
let buffer = ''
let totalUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0
}
let usageRecorded = false
try {
for await (const chunk of response.data) {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) {
continue
}
let jsonData = line
if (line.startsWith('data: ')) {
jsonData = line.substring(6).trim()
}
if (!jsonData || jsonData === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonData)
const payload = data?.response || data
if (payload?.usageMetadata) {
totalUsage = payload.usageMetadata
}
const openaiChunk = convertGeminiResponse(payload, model, true)
if (openaiChunk) {
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
const finishReason = openaiChunk.choices?.[0]?.finish_reason
if (finishReason === 'stop') {
yield 'data: [DONE]\n\n'
if (apiKeyId && totalUsage.totalTokenCount > 0) {
await apiKeyService.recordUsage(
apiKeyId,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0,
0,
model,
accountId,
'gemini'
)
usageRecorded = true
}
return
}
}
} catch (e) {
// ignore chunk parse errors
}
}
}
} finally {
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
await apiKeyService.recordUsage(
apiKeyId,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0,
0,
model,
accountId,
'gemini'
)
}
}
}
async function sendAntigravityRequest({
messages,
model,
temperature = 0.7,
maxTokens = 4096,
stream = false,
accessToken,
proxy,
apiKeyId,
signal,
projectId,
accountId = null
}) {
const requestedModel = normalizeAntigravityModelInput(model)
const requestData = buildRequestData({
messages,
model: requestedModel,
temperature,
maxTokens,
sessionId: apiKeyId
})
const { response } = await antigravityClient.request({
accessToken,
proxyConfig: proxy,
requestData,
projectId,
sessionId: apiKeyId,
stream,
signal,
params: { alt: 'sse' }
})
if (stream) {
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
}
const payload = response.data?.response || response.data
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
if (apiKeyId && openaiResponse?.usage) {
await apiKeyService.recordUsage(
apiKeyId,
openaiResponse.usage.prompt_tokens || 0,
openaiResponse.usage.completion_tokens || 0,
0,
0,
requestedModel,
accountId,
'gemini'
)
}
return openaiResponse
}
module.exports = {
sendAntigravityRequest
}

View File

@@ -0,0 +1,654 @@
/**
* API Key 索引服务
* 维护 Sorted Set 索引以支持高效分页查询
*/
const { randomUUID } = require('crypto')
const logger = require('../utils/logger')
class ApiKeyIndexService {
constructor() {
this.redis = null
this.INDEX_VERSION_KEY = 'apikey:index:version'
this.CURRENT_VERSION = 2 // 版本升级,触发重建
this.isBuilding = false
this.buildProgress = { current: 0, total: 0 }
// 索引键名
this.INDEX_KEYS = {
CREATED_AT: 'apikey:idx:createdAt',
LAST_USED_AT: 'apikey:idx:lastUsedAt',
NAME: 'apikey:idx:name',
ACTIVE_SET: 'apikey:set:active',
DELETED_SET: 'apikey:set:deleted',
ALL_SET: 'apikey:idx:all',
TAGS_ALL: 'apikey:tags:all' // 所有标签的集合
}
}
/**
* 初始化服务
*/
init(redis) {
this.redis = redis
return this
}
/**
* 启动时检查并重建索引
*/
async checkAndRebuild() {
if (!this.redis) {
logger.warn('⚠️ ApiKeyIndexService: Redis not initialized')
return
}
try {
const client = this.redis.getClientSafe()
const version = await client.get(this.INDEX_VERSION_KEY)
// 始终检查并回填 hash_map幂等操作确保升级兼容
this.rebuildHashMap().catch((err) => {
logger.error('❌ API Key hash_map 回填失败:', err)
})
if (parseInt(version) >= this.CURRENT_VERSION) {
logger.info('✅ API Key 索引已是最新版本')
return
}
// 后台异步重建,不阻塞启动
this.rebuildIndexes().catch((err) => {
logger.error('❌ API Key 索引重建失败:', err)
})
} catch (error) {
logger.error('❌ 检查 API Key 索引版本失败:', error)
}
}
/**
* 回填 apikey:hash_map升级兼容
* 扫描所有 API Key确保 hash -> keyId 映射存在
*/
async rebuildHashMap() {
if (!this.redis) {
return
}
try {
const client = this.redis.getClientSafe()
const keyIds = await this.redis.scanApiKeyIds()
let rebuilt = 0
const BATCH_SIZE = 100
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
const batch = keyIds.slice(i, i + BATCH_SIZE)
const pipeline = client.pipeline()
// 批量获取 API Key 数据
for (const keyId of batch) {
pipeline.hgetall(`apikey:${keyId}`)
}
const results = await pipeline.exec()
// 检查并回填缺失的映射
const fillPipeline = client.pipeline()
let needFill = false
for (let j = 0; j < batch.length; j++) {
const keyData = results[j]?.[1]
if (keyData && keyData.apiKey) {
// keyData.apiKey 存储的是哈希值
const exists = await client.hexists('apikey:hash_map', keyData.apiKey)
if (!exists) {
fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j])
rebuilt++
needFill = true
}
}
}
if (needFill) {
await fillPipeline.exec()
}
}
if (rebuilt > 0) {
logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`)
}
} catch (error) {
logger.error('❌ 回填 hash_map 失败:', error)
throw error
}
}
/**
* 检查索引是否可用
*/
async isIndexReady() {
if (!this.redis || this.isBuilding) {
return false
}
try {
const client = this.redis.getClientSafe()
const version = await client.get(this.INDEX_VERSION_KEY)
return parseInt(version) >= this.CURRENT_VERSION
} catch {
return false
}
}
/**
* 重建所有索引
*/
async rebuildIndexes() {
if (this.isBuilding) {
logger.warn('⚠️ API Key 索引正在重建中,跳过')
return
}
this.isBuilding = true
const startTime = Date.now()
try {
const client = this.redis.getClientSafe()
logger.info('🔨 开始重建 API Key 索引...')
// 0. 先删除版本号,让 _checkIndexReady 返回 false查询回退到 SCAN
await client.del(this.INDEX_VERSION_KEY)
// 1. 清除旧索引
const indexKeys = Object.values(this.INDEX_KEYS)
for (const key of indexKeys) {
await client.del(key)
}
// 清除标签索引(用 SCAN 避免阻塞)
let cursor = '0'
do {
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100)
cursor = newCursor
if (keys.length > 0) {
await client.del(...keys)
}
} while (cursor !== '0')
// 2. 扫描所有 API Key
const keyIds = await this.redis.scanApiKeyIds()
this.buildProgress = { current: 0, total: keyIds.length }
logger.info(`📊 发现 ${keyIds.length} 个 API Key开始建立索引...`)
// 3. 批量处理(每批 500 个)
const BATCH_SIZE = 500
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
const batch = keyIds.slice(i, i + BATCH_SIZE)
const apiKeys = await this.redis.batchGetApiKeys(batch)
const pipeline = client.pipeline()
for (const apiKey of apiKeys) {
if (!apiKey || !apiKey.id) {
continue
}
const keyId = apiKey.id
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
const name = (apiKey.name || '').toLowerCase()
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
// 创建时间索引
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
// 最后使用时间索引
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
// 名称索引用于排序存储格式name\0keyId
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
// 全部集合
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
// 状态集合
if (isDeleted) {
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
} else if (isActive) {
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
}
// 标签索引
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
for (const tag of tags) {
if (tag && typeof tag === 'string') {
pipeline.sadd(`apikey:tag:${tag}`, keyId)
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合
}
}
}
await pipeline.exec()
this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length)
// 每批次后短暂让出 CPU
await new Promise((resolve) => setTimeout(resolve, 10))
}
// 4. 更新版本号
await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION)
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`)
} catch (error) {
logger.error('❌ API Key 索引重建失败:', error)
throw error
} finally {
this.isBuilding = false
}
}
/**
* 添加单个 API Key 到索引
*/
async addToIndex(apiKey) {
if (!this.redis || !apiKey || !apiKey.id) {
return
}
try {
const client = this.redis.getClientSafe()
const keyId = apiKey.id
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now()
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
const name = (apiKey.name || '').toLowerCase()
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
const pipeline = client.pipeline()
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
if (isDeleted) {
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
} else if (isActive) {
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
} else {
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
}
// 标签索引
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
for (const tag of tags) {
if (tag && typeof tag === 'string') {
pipeline.sadd(`apikey:tag:${tag}`, keyId)
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
}
}
await pipeline.exec()
} catch (error) {
logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error)
}
}
/**
* 更新索引(状态、名称、标签变化时调用)
*/
async updateIndex(keyId, updates, oldData = {}) {
if (!this.redis || !keyId) {
return
}
try {
const client = this.redis.getClientSafe()
const pipeline = client.pipeline()
// 更新名称索引
if (updates.name !== undefined) {
const oldName = (oldData.name || '').toLowerCase()
const newName = (updates.name || '').toLowerCase()
if (oldName !== newName) {
pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`)
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`)
}
}
// 更新最后使用时间索引
if (updates.lastUsedAt !== undefined) {
const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
}
// 更新状态集合
if (updates.isActive !== undefined || updates.isDeleted !== undefined) {
const isActive = updates.isActive ?? oldData.isActive
const isDeleted = updates.isDeleted ?? oldData.isDeleted
if (isDeleted === true || isDeleted === 'true') {
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
} else if (isActive === true || isActive === 'true') {
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
} else {
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
}
}
// 更新标签索引
const removedTags = []
if (updates.tags !== undefined) {
const oldTags = Array.isArray(oldData.tags) ? oldData.tags : []
const newTags = Array.isArray(updates.tags) ? updates.tags : []
// 移除旧标签
for (const tag of oldTags) {
if (tag && !newTags.includes(tag)) {
pipeline.srem(`apikey:tag:${tag}`, keyId)
removedTags.push(tag)
}
}
// 添加新标签
for (const tag of newTags) {
if (tag && typeof tag === 'string') {
pipeline.sadd(`apikey:tag:${tag}`, keyId)
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
}
}
}
await pipeline.exec()
// 检查被移除的标签集合是否为空,为空则从 tags:all 移除
for (const tag of removedTags) {
const count = await client.scard(`apikey:tag:${tag}`)
if (count === 0) {
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
}
}
} catch (error) {
logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error)
}
}
/**
* 从索引中移除 API Key
*/
async removeFromIndex(keyId, oldData = {}) {
if (!this.redis || !keyId) {
return
}
try {
const client = this.redis.getClientSafe()
const pipeline = client.pipeline()
const name = (oldData.name || '').toLowerCase()
pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId)
pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId)
pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`)
pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId)
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
// 移除标签索引
const tags = Array.isArray(oldData.tags) ? oldData.tags : []
for (const tag of tags) {
if (tag) {
pipeline.srem(`apikey:tag:${tag}`, keyId)
}
}
await pipeline.exec()
// 检查标签集合是否为空,为空则从 tags:all 移除
for (const tag of tags) {
if (tag) {
const count = await client.scard(`apikey:tag:${tag}`)
if (count === 0) {
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
}
}
}
} catch (error) {
logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error)
}
}
/**
* 使用索引进行分页查询
* 使用 ZINTERSTORE 优化,避免全量拉回内存
*/
async queryWithIndex(options = {}) {
const {
page = 1,
pageSize = 20,
sortBy = 'createdAt',
sortOrder = 'desc',
isActive,
tag,
excludeDeleted = true
} = options
const client = this.redis.getClientSafe()
const tempSets = []
try {
// 1. 构建筛选集合
let filterSet = this.INDEX_KEYS.ALL_SET
// 状态筛选
if (isActive === true || isActive === 'true') {
// 筛选活跃的
filterSet = this.INDEX_KEYS.ACTIVE_SET
} else if (isActive === false || isActive === 'false') {
// 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted)
const tempKey = `apikey:tmp:inactive:${randomUUID()}`
if (excludeDeleted) {
await client.sdiffstore(
tempKey,
this.INDEX_KEYS.ALL_SET,
this.INDEX_KEYS.ACTIVE_SET,
this.INDEX_KEYS.DELETED_SET
)
} else {
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET)
}
await client.expire(tempKey, 60)
filterSet = tempKey
tempSets.push(tempKey)
} else if (excludeDeleted) {
// 排除已删除ALL - DELETED
const tempKey = `apikey:tmp:notdeleted:${randomUUID()}`
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET)
await client.expire(tempKey, 60)
filterSet = tempKey
tempSets.push(tempKey)
}
// 标签筛选
if (tag) {
const tagSet = `apikey:tag:${tag}`
const tempKey = `apikey:tmp:tag:${randomUUID()}`
await client.sinterstore(tempKey, filterSet, tagSet)
await client.expire(tempKey, 60)
filterSet = tempKey
tempSets.push(tempKey)
}
// 2. 获取筛选后的 keyId 集合
const filterMembers = await client.smembers(filterSet)
if (filterMembers.length === 0) {
// 没有匹配的数据
return {
items: [],
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
availableTags: await this._getAvailableTags(client)
}
}
// 3. 排序
let sortedKeyIds
if (sortBy === 'name') {
// 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引
const pipeline = client.pipeline()
for (const keyId of filterMembers) {
pipeline.hget(`apikey:${keyId}`, 'name')
}
const results = await pipeline.exec()
// 组装并排序
const items = filterMembers.map((keyId, i) => ({
keyId,
name: (results[i]?.[1] || '').toLowerCase()
}))
items.sort((a, b) =>
sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
)
sortedKeyIds = items.map((item) => item.keyId)
} else {
// createdAt / lastUsedAt 索引成员是 keyId可以用 ZINTERSTORE
const sortIndex = this._getSortIndex(sortBy)
const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}`
tempSets.push(tempSortedKey)
// 将 filterSet 转换为 Sorted Set所有分数为 0
const filterZsetKey = `apikey:tmp:filter:${randomUUID()}`
tempSets.push(filterZsetKey)
const zaddArgs = []
for (const member of filterMembers) {
zaddArgs.push(0, member)
}
await client.zadd(filterZsetKey, ...zaddArgs)
await client.expire(filterZsetKey, 60)
// ZINTERSTORE取交集使用排序索引的分数WEIGHTS 0 1
await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1)
await client.expire(tempSortedKey, 60)
// 获取排序后的 keyId
sortedKeyIds =
sortOrder === 'desc'
? await client.zrevrange(tempSortedKey, 0, -1)
: await client.zrange(tempSortedKey, 0, -1)
}
// 4. 分页
const total = sortedKeyIds.length
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
const validPage = Math.min(Math.max(1, page), totalPages)
const start = (validPage - 1) * pageSize
const pageKeyIds = sortedKeyIds.slice(start, start + pageSize)
// 5. 获取数据
const items = await this.redis.batchGetApiKeys(pageKeyIds)
// 6. 获取所有标签
const availableTags = await this._getAvailableTags(client)
return {
items,
pagination: {
page: validPage,
pageSize,
total,
totalPages
},
availableTags
}
} finally {
// 7. 清理临时集合
for (const tempKey of tempSets) {
client.del(tempKey).catch(() => {})
}
}
}
/**
* 获取排序索引键名
*/
_getSortIndex(sortBy) {
switch (sortBy) {
case 'createdAt':
return this.INDEX_KEYS.CREATED_AT
case 'lastUsedAt':
return this.INDEX_KEYS.LAST_USED_AT
case 'name':
return this.INDEX_KEYS.NAME
default:
return this.INDEX_KEYS.CREATED_AT
}
}
/**
* 获取所有可用标签(从 tags:all 集合)
*/
async _getAvailableTags(client) {
try {
const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL)
return tags.sort()
} catch {
return []
}
}
/**
* 更新 lastUsedAt 索引(供 recordUsage 调用)
*/
async updateLastUsedAt(keyId, lastUsedAt) {
if (!this.redis || !keyId) {
return
}
try {
const client = this.redis.getClientSafe()
const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now()
await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId)
} catch (error) {
logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error)
}
}
/**
* 获取索引状态
*/
async getStatus() {
if (!this.redis) {
return { ready: false, building: false }
}
try {
const client = this.redis.getClientSafe()
const version = await client.get(this.INDEX_VERSION_KEY)
const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET)
return {
ready: parseInt(version) >= this.CURRENT_VERSION,
building: this.isBuilding,
progress: this.buildProgress,
version: parseInt(version) || 0,
currentVersion: this.CURRENT_VERSION,
totalIndexed: totalCount
}
} catch {
return { ready: false, building: this.isBuilding }
}
}
}
// 单例
const apiKeyIndexService = new ApiKeyIndexService()
module.exports = apiKeyIndexService

File diff suppressed because it is too large Load Diff

View File

@@ -150,6 +150,7 @@ async function createAccount(accountData) {
const client = redisClient.getClientSafe()
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
await redisClient.addToIndex('azure_openai:account:index', accountId)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
// 从Redis中删除账户数据
await client.del(accountKey)
// 从索引中移除
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
// 从共享账户集合中移除
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
const accountIds = await redisClient.getAllIdsByIndex(
'azure_openai:account:index',
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
/^azure_openai:account:(.+)$/
)
if (!keys || keys.length === 0) {
if (!accountIds || accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
const accounts = []
for (const key of keys) {
const accountData = await client.hgetall(key)
const dataList = await redisClient.batchHgetallChunked(keys)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) {
// 不返回敏感数据给前端
delete accountData.apiKey

View File

@@ -0,0 +1,133 @@
const axios = require('axios')
const logger = require('../../utils/logger')
const ProxyHelper = require('../../utils/proxyHelper')
/**
* Provider 抽象基类
* 各平台 Provider 需继承并实现 queryBalance(account)
*/
class BaseBalanceProvider {
constructor(platform) {
this.platform = platform
this.logger = logger
}
/**
* 查询余额(抽象方法)
* @param {object} account - 账户对象
* @returns {Promise<object>}
* 形如:
* {
* balance: number|null,
* currency?: string,
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
* queryMethod?: 'api'|'field'|'local',
* rawData?: any
* }
*/
async queryBalance(_account) {
throw new Error('queryBalance 方法必须由子类实现')
}
/**
* 通用 HTTP 请求方法(支持代理)
* @param {string} url
* @param {object} options
* @param {object} account
*/
async makeRequest(url, options = {}, account = {}) {
const config = {
url,
method: options.method || 'GET',
headers: options.headers || {},
timeout: options.timeout || 15000,
data: options.data,
params: options.params,
responseType: options.responseType
}
const proxyConfig = account.proxyConfig || account.proxy
if (proxyConfig) {
const agent = ProxyHelper.createProxyAgent(proxyConfig)
if (agent) {
config.httpAgent = agent
config.httpsAgent = agent
config.proxy = false
}
}
try {
const response = await axios(config)
return {
success: true,
data: response.data,
status: response.status,
headers: response.headers
}
} catch (error) {
const status = error.response?.status
const message = error.response?.data?.message || error.message || '请求失败'
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
status,
message
})
return { success: false, status, error: message }
}
}
/**
* 从账户字段读取 dailyQuota / dailyUsage通用降级方案
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
*/
readQuotaFromFields(account) {
const dailyQuota = Number(account?.dailyQuota || 0)
const dailyUsage = Number(account?.dailyUsage || 0)
// 无限制
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
return {
balance: null,
currency: 'USD',
quota: {
daily: Infinity,
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
remaining: Infinity,
percentage: 0,
unlimited: true
},
queryMethod: 'field'
}
}
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
const remaining = Math.max(0, dailyQuota - used)
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
return {
balance: remaining,
currency: 'USD',
quota: {
daily: dailyQuota,
used,
remaining,
percentage: Math.round(percentage * 100) / 100
},
queryMethod: 'field'
}
}
parseCurrency(data) {
return data?.currency || data?.Currency || 'USD'
}
async safeExecute(fn, fallbackValue = null) {
try {
return await fn()
} catch (error) {
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
return fallbackValue
}
}
}
module.exports = BaseBalanceProvider

View File

@@ -0,0 +1,30 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
const claudeAccountService = require('../claudeAccountService')
class ClaudeBalanceProvider extends BaseBalanceProvider {
constructor() {
super('claude')
}
/**
* ClaudeOAuth优先尝试获取 OAuth usage用于配额/使用信息),不强行提供余额金额
*/
async queryBalance(account) {
this.logger.debug(`查询 Claude 余额OAuth usage: ${account?.id}`)
// 仅 OAuth 账户可用;失败时降级
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
if (!usageData) {
return { balance: null, currency: 'USD', queryMethod: 'local' }
}
return {
balance: null,
currency: 'USD',
queryMethod: 'api',
rawData: usageData
}
}
}
module.exports = ClaudeBalanceProvider

View File

@@ -0,0 +1,14 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
constructor() {
super('claude-console')
}
async queryBalance(account) {
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
return this.readQuotaFromFields(account)
}
}
module.exports = ClaudeConsoleBalanceProvider

View File

@@ -0,0 +1,250 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
const antigravityClient = require('../antigravityClient')
const geminiAccountService = require('../geminiAccountService')
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
function clamp01(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
if (value < 0) {
return 0
}
if (value > 1) {
return 1
}
return value
}
function round2(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
return Math.round(value * 100) / 100
}
function normalizeQuotaCategory(displayName, modelId) {
const name = String(displayName || '')
const id = String(modelId || '')
if (name.includes('Gemini') && name.includes('Pro')) {
return 'Gemini Pro'
}
if (name.includes('Gemini') && name.includes('Flash')) {
return 'Gemini Flash'
}
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
return 'Gemini Image'
}
if (name.includes('Claude') || name.includes('GPT-OSS')) {
return 'Claude'
}
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
return 'Gemini Pro'
}
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
return 'Gemini Flash'
}
if (id.includes('image')) {
return 'Gemini Image'
}
if (id.includes('claude') || id.includes('gpt-oss')) {
return 'Claude'
}
return name || id || 'Unknown'
}
function buildAntigravityQuota(modelsResponse) {
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
if (!models || typeof models !== 'object') {
return null
}
const parseRemainingFraction = (quotaInfo) => {
if (!quotaInfo || typeof quotaInfo !== 'object') {
return null
}
const raw =
quotaInfo.remainingFraction ??
quotaInfo.remaining_fraction ??
quotaInfo.remaining ??
undefined
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
if (!Number.isFinite(num)) {
return null
}
return clamp01(num)
}
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const categoryMap = new Map()
for (const [modelId, modelDataRaw] of Object.entries(models)) {
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
continue
}
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
const remainingFraction = parseRemainingFraction(quotaInfo)
if (remainingFraction === null) {
continue
}
const remainingPercent = round2(remainingFraction * 100)
const usedPercent = round2(100 - remainingPercent)
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
const category = normalizeQuotaCategory(displayName, modelId)
if (!allowedCategories.has(category)) {
continue
}
const entry = {
category,
modelId,
displayName: String(displayName || modelId || category),
remainingPercent,
usedPercent,
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
}
const existing = categoryMap.get(category)
if (!existing || entry.remainingPercent < existing.remainingPercent) {
categoryMap.set(category, entry)
}
}
const buckets = fixedOrder.map((category) => {
const existing = categoryMap.get(category) || null
if (existing) {
return existing
}
return {
category,
modelId: '',
displayName: category,
remainingPercent: null,
usedPercent: null,
resetAt: null
}
})
if (buckets.length === 0) {
return null
}
const critical = buckets
.filter((item) => item.remainingPercent !== null)
.reduce((min, item) => {
if (!min) {
return item
}
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
}, null)
if (!critical) {
return null
}
return {
balance: null,
currency: 'USD',
quota: {
type: 'antigravity',
total: 100,
used: critical.usedPercent,
remaining: critical.remainingPercent,
percentage: critical.usedPercent,
resetAt: critical.resetAt,
buckets: buckets.map((item) => ({
category: item.category,
remaining: item.remainingPercent,
used: item.usedPercent,
percentage: item.usedPercent,
resetAt: item.resetAt
}))
},
queryMethod: 'api',
rawData: {
modelsCount: Object.keys(models).length,
bucketCount: buckets.length
}
}
}
class GeminiBalanceProvider extends BaseBalanceProvider {
constructor() {
super('gemini')
}
async queryBalance(account) {
const oauthProvider = account?.oauthProvider
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return this.readQuotaFromFields(account)
}
return { balance: null, currency: 'USD', queryMethod: 'local' }
}
const accessToken = String(account?.accessToken || '').trim()
const refreshToken = String(account?.refreshToken || '').trim()
const proxyConfig = account?.proxyConfig || account?.proxy || null
if (!accessToken) {
throw new Error('Antigravity 账户缺少 accessToken')
}
const fetch = async (token) =>
await antigravityClient.fetchAvailableModels({
accessToken: token,
proxyConfig
})
let data
try {
data = await fetch(accessToken)
} catch (error) {
const status = error?.response?.status
if ((status === 401 || status === 403) && refreshToken) {
const refreshed = await geminiAccountService.refreshAccessToken(
refreshToken,
proxyConfig,
OAUTH_PROVIDER_ANTIGRAVITY
)
const nextToken = String(refreshed?.access_token || '').trim()
if (!nextToken) {
throw error
}
data = await fetch(nextToken)
} else {
throw error
}
}
const mapped = buildAntigravityQuota(data)
if (!mapped) {
return {
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: data || null
}
}
return mapped
}
}
module.exports = GeminiBalanceProvider

View File

@@ -0,0 +1,23 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class GenericBalanceProvider extends BaseBalanceProvider {
constructor(platform) {
super(platform)
}
async queryBalance(account) {
this.logger.debug(`${this.platform} 暂无专用余额 API实现降级策略`)
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return this.readQuotaFromFields(account)
}
return {
balance: null,
currency: 'USD',
queryMethod: 'local'
}
}
}
module.exports = GenericBalanceProvider

View File

@@ -0,0 +1,25 @@
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
const GenericBalanceProvider = require('./genericBalanceProvider')
const GeminiBalanceProvider = require('./geminiBalanceProvider')
function registerAllProviders(balanceService) {
// Claude
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
// OpenAI / Codex
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
// 其他平台(降级)
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
}
module.exports = { registerAllProviders }

View File

@@ -0,0 +1,54 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
constructor() {
super('openai-responses')
}
/**
* OpenAI-Responses
* - 优先使用 dailyQuota 字段(如果配置了额度)
* - 可选:尝试调用兼容 API不同服务商实现不一失败自动降级
*/
async queryBalance(account) {
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
// 配置了额度时直接返回(字段法)
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
return this.readQuotaFromFields(account)
}
// 尝试调用 usage 接口(兼容性不保证)
if (account?.apiKey && account?.baseApi) {
const baseApi = String(account.baseApi).replace(/\/$/, '')
const response = await this.makeRequest(
`${baseApi}/v1/usage`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${account.apiKey}`,
'Content-Type': 'application/json'
}
},
account
)
if (response.success) {
return {
balance: null,
currency: this.parseCurrency(response.data),
queryMethod: 'api',
rawData: response.data
}
}
}
return {
balance: null,
currency: 'USD',
queryMethod: 'local'
}
}
}
module.exports = OpenAIResponsesBalanceProvider

View File

@@ -0,0 +1,210 @@
const vm = require('vm')
const axios = require('axios')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
/**
* SSRF防护检查URL是否访问内网或敏感地址
* @param {string} url - 要检查的URL
* @returns {boolean} - true表示URL安全
*/
function isUrlSafe(url) {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
// 禁止的协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false
}
// 禁止访问localhost和私有IP
const privatePatterns = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./, // AWS metadata
/^0\./, // 0.0.0.0
/^::1$/,
/^fc00:/i,
/^fe80:/i,
/\.local$/i,
/\.internal$/i,
/\.localhost$/i
]
for (const pattern of privatePatterns) {
if (pattern.test(hostname)) {
return false
}
}
return true
} catch {
return false
}
}
/**
* 可配置脚本余额查询执行器
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
*/
class BalanceScriptService {
/**
* 执行脚本:返回标准余额结构 + 原始响应
* @param {object} options
* - scriptBody: string
* - variables: Record<string,string>
* - timeoutSeconds: number
*/
async execute(options = {}) {
if (!isBalanceScriptEnabled()) {
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
error.code = 'BALANCE_SCRIPT_DISABLED'
throw error
}
const scriptBody = options.scriptBody?.trim()
if (!scriptBody) {
throw new Error('脚本内容为空')
}
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
const sandbox = {
console,
Math,
Date
}
let scriptResult
try {
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
const script = new vm.Script(wrapped)
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
} catch (error) {
throw new Error(`脚本解析失败: ${error.message}`)
}
if (!scriptResult || typeof scriptResult !== 'object') {
throw new Error('脚本返回格式无效(需返回 { request, extractor }')
}
const variables = options.variables || {}
const request = this.applyTemplates(scriptResult.request || {}, variables)
const { extractor } = scriptResult
if (!request?.url || typeof request.url !== 'string') {
throw new Error('脚本 request.url 不能为空')
}
// SSRF防护验证URL安全性
if (!isUrlSafe(request.url)) {
throw new Error('脚本 request.url 不安全禁止访问内网地址、localhost或使用非HTTP(S)协议')
}
if (typeof extractor !== 'function') {
throw new Error('脚本 extractor 必须是函数')
}
const axiosConfig = {
url: request.url,
method: (request.method || 'GET').toUpperCase(),
headers: request.headers || {},
timeout: timeoutMs
}
if (request.params) {
axiosConfig.params = request.params
}
if (request.body || request.data) {
axiosConfig.data = request.body || request.data
}
let httpResponse
try {
httpResponse = await axios(axiosConfig)
} catch (error) {
const { response } = error || {}
const { status, data } = response || {}
throw new Error(
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
)
}
const responseData = httpResponse?.data
let extracted = {}
try {
extracted = extractor(responseData) || {}
} catch (error) {
throw new Error(`extractor 执行失败: ${error.message}`)
}
const mapped = this.mapExtractorResult(extracted, responseData)
return {
mapped,
extracted,
response: {
status: httpResponse?.status,
headers: httpResponse?.headers,
data: responseData
}
}
}
applyTemplates(value, variables) {
if (typeof value === 'string') {
return value.replace(/{{(\w+)}}/g, (_, key) => {
const trimmed = key.trim()
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
})
}
if (Array.isArray(value)) {
return value.map((item) => this.applyTemplates(item, variables))
}
if (value && typeof value === 'object') {
const result = {}
Object.keys(value).forEach((k) => {
result[k] = this.applyTemplates(value[k], variables)
})
return result
}
return value
}
mapExtractorResult(result = {}, responseData) {
const isValid = result.isValid !== false
const remaining = Number(result.remaining)
const total = Number(result.total)
const used = Number(result.used)
const currency = result.unit || 'USD'
const quota =
Number.isFinite(total) || Number.isFinite(used)
? {
total: Number.isFinite(total) ? total : null,
used: Number.isFinite(used) ? used : null,
remaining: Number.isFinite(remaining) ? remaining : null,
percentage:
Number.isFinite(total) && total > 0 && Number.isFinite(used)
? (used / total) * 100
: null
}
: null
return {
status: isValid ? 'success' : 'error',
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
balance: Number.isFinite(remaining) ? remaining : null,
currency,
quota,
planName: result.planName || null,
extra: result.extra || null,
rawData: responseData || result.raw
}
}
}
module.exports = new BalanceScriptService()

View File

@@ -35,12 +35,13 @@ class BedrockAccountService {
description = '',
region = process.env.AWS_REGION || 'us-east-1',
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key
} = options
const accountId = uuidv4()
@@ -71,8 +72,14 @@ class BedrockAccountService {
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
}
// 加密存储 Bearer Token
if (bearerToken) {
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
}
const client = redis.getClientSafe()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
await redis.addToIndex('bedrock_account:index', accountId)
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
@@ -106,9 +113,85 @@ class BedrockAccountService {
const account = JSON.parse(accountData)
// 解密AWS凭证用于内部使用
// 根据凭证类型解密对应的凭证
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
try {
let accessKeyDecrypted = false
let bearerTokenDecrypted = false
// 第一步:按照 credentialType 尝试解密对应的凭证
if (account.credentialType === 'access_key' && account.awsCredentials) {
// Access Key 模式:解密 AWS 凭证
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.debug(
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
// Bearer Token 模式:解密 Bearer Token
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.debug(
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (!account.credentialType || account.credentialType === 'default') {
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
}
logger.debug(
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
)
}
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.warn(
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
)
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.warn(
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
)
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.warn(
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
)
}
}
// 验证至少解密了一种凭证
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.error(
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
)
return {
success: false,
error: 'No valid credentials found in account data'
}
}
} catch (decryptError) {
logger.error(
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
decryptError
)
return {
success: false,
error: `Credentials decryption failed: ${decryptError.message}`
}
}
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
@@ -126,12 +209,18 @@ class BedrockAccountService {
// 📋 获取所有账户列表
async getAllAccounts() {
try {
const client = redis.getClientSafe()
const keys = await client.keys('bedrock_account:*')
const _client = redis.getClientSafe()
const accountIds = await redis.getAllIdsByIndex(
'bedrock_account:index',
'bedrock_account:*',
/^bedrock_account:(.+)$/
)
const keys = accountIds.map((id) => `bedrock_account:${id}`)
const accounts = []
const dataList = await redis.batchGetChunked(keys)
for (const key of keys) {
const accountData = await client.get(key)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData) {
const account = JSON.parse(accountData)
@@ -155,7 +244,11 @@ class BedrockAccountService {
updatedAt: account.updatedAt,
type: 'bedrock',
platform: 'bedrock',
hasCredentials: !!account.awsCredentials
// 根据凭证类型判断是否有凭证
hasCredentials:
account.credentialType === 'bearer_token'
? !!account.bearerToken
: !!account.awsCredentials
})
}
}
@@ -235,6 +328,15 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
}
// 更新 Bearer Token
if (updates.bearerToken !== undefined) {
if (updates.bearerToken) {
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
} else {
delete account.bearerToken
}
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
@@ -280,6 +382,7 @@ class BedrockAccountService {
const client = redis.getClientSafe()
await client.del(`bedrock_account:${accountId}`)
await redis.removeFromIndex('bedrock_account:index', accountId)
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
@@ -345,13 +448,45 @@ class BedrockAccountService {
const account = accountResult.data
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
logger.info(
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
)
// 尝试获取模型列表来测试连接
// 验证凭证是否已解密
const hasValidCredentials =
(account.credentialType === 'access_key' && account.awsCredentials) ||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
if (!hasValidCredentials) {
logger.error(
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
)
return {
success: false,
error: 'No valid credentials found after decryption'
}
}
// 尝试创建 Bedrock 客户端来验证凭证格式
try {
bedrockRelayService._getBedrockClient(account.region, account)
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
} catch (clientError) {
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
return {
success: false,
error: `Failed to create Bedrock client: ${clientError.message}`
}
}
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
const models = await bedrockRelayService.getAvailableModels(account)
if (models && models.length > 0) {
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
logger.info(
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
)
return {
success: true,
data: {
@@ -376,6 +511,135 @@ class BedrockAccountService {
}
}
/**
* 🧪 测试 Bedrock 账户连接SSE 流式返回,供前端测试页面使用)
* @param {string} accountId - 账户ID
* @param {Object} res - Express response 对象
* @param {string} model - 测试使用的模型
*/
async testAccountConnection(accountId, res, model = null) {
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
try {
// 获取账户信息
const accountResult = await this.getAccount(accountId)
if (!accountResult.success) {
throw new Error(accountResult.error || 'Account not found')
}
const account = accountResult.data
// 根据账户类型选择合适的测试模型
if (!model) {
// Access Key 模式使用 Haiku更快更便宜
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
logger.info(
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.status(200)
// 发送 test_start 事件
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
// 构造测试请求体Bedrock 格式)
const bedrockPayload = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 256,
messages: [
{
role: 'user',
content:
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
}
]
}
// 获取 Bedrock 客户端
const region = account.region || bedrockRelayService.defaultRegion
const client = bedrockRelayService._getBedrockClient(region, account)
// 创建流式调用命令
const command = new InvokeModelWithResponseStreamCommand({
modelId: model,
body: JSON.stringify(bedrockPayload),
contentType: 'application/json',
accept: 'application/json'
})
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
const startTime = Date.now()
const response = await client.send(command)
// 处理流式响应
// let responseText = ''
for await (const chunk of response.body) {
if (chunk.chunk) {
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
// 提取文本内容
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
const { text } = chunkData.delta
// responseText += text
// 发送 content 事件
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
// 检测错误
if (chunkData.type === 'error') {
throw new Error(chunkData.error?.message || 'Bedrock API error')
}
}
}
const duration = Date.now() - startTime
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
// 发送 message_stop 事件(前端兼容)
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
// 发送 test_complete 事件
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
// 结束响应
res.end()
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
} catch (error) {
logger.error(`❌ Test Bedrock account connection failed:`, error)
// 发送错误事件给前端
try {
// 检查响应流是否仍然可写
if (!res.writableEnded && !res.destroyed) {
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.status(200)
}
const errorMsg = error.message || '测试失败'
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
res.end()
}
} catch (writeError) {
logger.error('Failed to write error to response stream:', writeError)
}
// 不再重新抛出错误,避免路由层再次处理
// throw error
}
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象

View File

@@ -6,6 +6,7 @@ const {
const { fromEnv } = require('@aws-sdk/credential-providers')
const logger = require('../utils/logger')
const config = require('../../config/config')
const userMessageQueueService = require('./userMessageQueueService')
class BedrockRelayService {
constructor() {
@@ -47,13 +48,17 @@ class BedrockRelayService {
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
sessionToken: bedrockAccount.awsCredentials.sessionToken
}
} else if (bedrockAccount?.bearerToken) {
// Bearer Token 模式AWS SDK >= 3.400.0 会自动检测环境变量
clientConfig.token = { token: bedrockAccount.bearerToken }
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
} else {
// 检查是否有环境变量凭证
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
clientConfig.credentials = fromEnv()
} else {
throw new Error(
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
)
}
}
@@ -69,7 +74,68 @@ class BedrockRelayService {
// 处理非流式请求
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
success: false
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
}
}
const modelId = this._selectModel(requestBody, bedrockAccount)
const region = this._selectRegion(modelId, bedrockAccount)
const client = this._getBedrockClient(region, bedrockAccount)
@@ -90,6 +156,23 @@ class BedrockRelayService {
const response = await client.send(command)
const duration = Date.now() - startTime
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
releaseError.message
)
}
}
// 解析响应
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
const claudeResponse = this._convertFromBedrockFormat(responseBody)
@@ -106,12 +189,94 @@ class BedrockRelayService {
} catch (error) {
logger.error('❌ Bedrock非流式请求失败:', error)
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
releaseError.message
)
}
}
}
}
// 处理流式请求
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!res.headersSent) {
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
res.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
})}\n\n`
res.write(errorEvent)
res.write('data: [DONE]\n\n')
res.end()
return { success: false, error: errorType }
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
const modelId = this._selectModel(requestBody, bedrockAccount)
const region = this._selectRegion(modelId, bedrockAccount)
const client = this._getBedrockClient(region, bedrockAccount)
@@ -131,11 +296,35 @@ class BedrockRelayService {
const startTime = Date.now()
const response = await client.send(command)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
releaseError.message
)
}
}
// 设置SSE响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
if (existingConnection) {
logger.debug(
`🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}`
)
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
})
@@ -154,8 +343,8 @@ class BedrockRelayService {
res.write(`event: ${claudeEvent.type}\n`)
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
// 提取使用统计
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage
}
@@ -191,6 +380,21 @@ class BedrockRelayService {
res.end()
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -231,6 +435,18 @@ class BedrockRelayService {
_mapToBedrockModel(modelName) {
// 标准Claude模型名到Bedrock模型名的映射表
const modelMapping = {
// Claude 4.5 Opus
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
// Claude 4.5 Sonnet
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
// Claude 4.5 Haiku
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
// Claude Sonnet 4
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
@@ -360,8 +576,10 @@ class BedrockRelayService {
return {
type: 'message_start',
data: {
type: 'message',
type: 'message_start',
message: {
id: `msg_${Date.now()}_bedrock`,
type: 'message',
role: 'assistant',
content: [],
model: this.defaultModel,
@@ -371,21 +589,45 @@ class BedrockRelayService {
}
}
}
}
if (bedrockChunk.type === 'content_block_start') {
return {
type: 'content_block_start',
data: {
type: 'content_block_start',
index: bedrockChunk.index || 0,
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
}
}
}
if (bedrockChunk.type === 'content_block_delta') {
return {
type: 'content_block_delta',
data: {
type: 'content_block_delta',
index: bedrockChunk.index || 0,
delta: bedrockChunk.delta || {}
}
}
}
if (bedrockChunk.type === 'content_block_stop') {
return {
type: 'content_block_stop',
data: {
type: 'content_block_stop',
index: bedrockChunk.index || 0
}
}
}
if (bedrockChunk.type === 'message_delta') {
return {
type: 'message_delta',
data: {
type: 'message_delta',
delta: bedrockChunk.delta || {},
usage: bedrockChunk.usage || {}
}
@@ -396,7 +638,7 @@ class BedrockRelayService {
return {
type: 'message_stop',
data: {
usage: bedrockChunk.usage || {}
type: 'message_stop'
}
}
}

View File

@@ -208,7 +208,7 @@ class BillingEventPublisher {
// MKSTREAM: 如果 stream 不存在则创建
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
logger.success(`Created consumer group: ${groupName}`)
logger.success(`Created consumer group: ${groupName}`)
return true
} catch (error) {
if (error.message.includes('BUSYGROUP')) {

View File

@@ -1,33 +1,23 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
const { createEncryptor } = require('../utils/commonHelper')
class CcrAccountService {
constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'ccr-account-salt'
// Redis键前缀
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 使用 commonHelper 的加密器
this._encryptor = createEncryptor('ccr-account-salt')
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
this._encryptor.clearCache()
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
},
10 * 60 * 1000
)
@@ -106,6 +96,7 @@ class CcrAccountService {
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
await redis.addToIndex('ccr_account:index', accountId)
// 如果是共享账户,添加到共享账户集合
if (accountType === 'shared') {
@@ -139,12 +130,17 @@ class CcrAccountService {
// 📋 获取所有CCR账户
async getAllAccounts() {
try {
const client = redis.getClientSafe()
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
const accountIds = await redis.getAllIdsByIndex(
'ccr_account:index',
`${this.ACCOUNT_KEY_PREFIX}*`,
/^ccr_account:(.+)$/
)
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
const accounts = []
const dataList = await redis.batchHgetallChunked(keys)
for (const key of keys) {
const accountData = await client.hgetall(key)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData)
@@ -331,6 +327,9 @@ class CcrAccountService {
// 从共享账户集合中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 从索引中移除
await redis.removeFromIndex('ccr_account:index', accountId)
// 删除账户数据
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
@@ -403,7 +402,7 @@ class CcrAccountService {
` CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
)
} else {
logger.success(`Removed rate limit for CCR account: ${accountId}`)
logger.success(`Removed rate limit for CCR account: ${accountId}`)
}
await client.hmset(accountKey, {
@@ -488,7 +487,7 @@ class CcrAccountService {
errorMessage: ''
})
logger.success(`Removed overload status for CCR account: ${accountId}`)
logger.success(`Removed overload status for CCR account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
@@ -606,70 +605,12 @@ class CcrAccountService {
// 🔐 加密敏感数据
_encryptSensitiveData(data) {
if (!data) {
return ''
}
try {
const key = this._generateEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(data, 'utf8', 'hex')
encrypted += cipher.final('hex')
return `${iv.toString('hex')}:${encrypted}`
} catch (error) {
logger.error('❌ CCR encryption error:', error)
return data
}
return this._encryptor.encrypt(data)
}
// 🔓 解密敏感数据
_decryptSensitiveData(encryptedData) {
if (!encryptedData) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const parts = encryptedData.split(':')
if (parts.length === 2) {
const key = this._generateEncryptionKey()
const iv = Buffer.from(parts[0], 'hex')
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} else {
logger.error('❌ Invalid CCR encrypted data format')
return encryptedData
}
} catch (error) {
logger.error('❌ CCR decryption error:', error)
return encryptedData
}
}
// 🔑 生成加密密钥
_generateEncryptionKey() {
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
}
return this._encryptionKeyCache
return this._encryptor.decrypt(encryptedData)
}
// 🔍 获取限流状态信息
@@ -843,7 +784,7 @@ class CcrAccountService {
}
}
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
return { success: true, resetCount }
} catch (error) {
logger.error('❌ Failed to reset all CCR daily usage:', error)
@@ -915,7 +856,7 @@ class CcrAccountService {
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`Reset all error status for CCR account ${accountId}`)
logger.success(`Reset all error status for CCR account ${accountId}`)
// 异步发送 Webhook 通知(忽略错误)
try {

View File

@@ -3,6 +3,8 @@ const ccrAccountService = require('./ccrAccountService')
const logger = require('../utils/logger')
const config = require('../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
class CcrRelayService {
constructor() {
@@ -21,8 +23,67 @@ class CcrRelayService {
) {
let abortController = null
let account = null
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for CCR account ${accountId}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
accountId
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
@@ -162,6 +223,23 @@ class CcrRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -233,6 +311,21 @@ class CcrRelayService {
)
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -248,7 +341,77 @@ class CcrRelayService {
options = {}
) {
let account = null
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error(
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
)
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志用于后续<E5908E><E7BBAD>
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
})}\n\n`
responseStream.write(errorEvent)
responseStream.write('data: [DONE]\n\n')
responseStream.end()
return
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
@@ -296,14 +459,53 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
}
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -317,7 +519,8 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -380,8 +583,11 @@ class CcrRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 CCR stream response status: ${response.status}`)
// 错误响应处理
@@ -404,10 +610,13 @@ class CcrRelayService {
// 设置错误响应的状态码和响应头
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const errorHeaders = {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
}
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
delete errorHeaders['Transfer-Encoding']
@@ -417,13 +626,13 @@ class CcrRelayService {
// 直接透传错误数据,不进行包装
response.data.on('data', (chunk) => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(chunk)
}
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
resolve() // 不抛出异常,正常完成流处理
@@ -431,6 +640,19 @@ class CcrRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -444,11 +666,20 @@ class CcrRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
if (existingConnection) {
logger.debug(
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
)
}
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
}
@@ -487,12 +718,17 @@ class CcrRelayService {
}
// 写入到响应流
if (outputLine && !responseStream.destroyed) {
if (outputLine && isStreamWritable(responseStream)) {
responseStream.write(`${outputLine}\n`)
} else if (outputLine) {
// 客户端连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
)
}
} else {
// 空行也需要传递
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write('\n')
}
}
@@ -503,10 +739,6 @@ class CcrRelayService {
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.end()
}
// 如果收集到使用统计数据,调用回调
if (usageCallback && Object.keys(collectedUsage).length > 0) {
try {
@@ -518,12 +750,26 @@ class CcrRelayService {
}
}
if (isStreamWritable(responseStream)) {
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.debug(
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
)
resolve()
}
})
response.data.on('error', (err) => {
logger.error('❌ Stream data error:', err)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
reject(err)
@@ -555,7 +801,7 @@ class CcrRelayService {
}
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
responseStream.end()
}

View File

@@ -91,7 +91,9 @@ class ClaudeAccountService {
useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '', // 统一的客户端标识
expiresAt = null, // 账户订阅到期时间
extInfo = null // 额外扩展信息
extInfo = null, // 额外扩展信息
maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options
const accountId = uuidv4()
@@ -136,7 +138,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
} else {
// 兼容旧格式
@@ -168,7 +174,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
}
@@ -216,7 +226,8 @@ class ClaudeAccountService {
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId,
extInfo: normalizedExtInfo
extInfo: normalizedExtInfo,
interceptWarmup
}
}
@@ -574,7 +585,11 @@ class ClaudeAccountService {
// 添加停止原因
stoppedReason: account.stoppedReason || null,
// 扩展信息
extInfo: parsedExtInfo
extInfo: parsedExtInfo,
// 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
}
})
)
@@ -666,7 +681,9 @@ class ClaudeAccountService {
'useUnifiedClientId',
'unifiedClientId',
'subscriptionExpiresAt',
'extInfo'
'extInfo',
'maxConcurrency',
'interceptWarmup'
]
const updatedData = { ...accountData }
let shouldClearAutoStopFields = false
@@ -681,7 +698,7 @@ class ClaudeAccountService {
updatedData[field] = this._encryptSensitiveData(value)
} else if (field === 'proxy') {
updatedData[field] = value ? JSON.stringify(value) : ''
} else if (field === 'priority') {
} else if (field === 'priority' || field === 'maxConcurrency') {
updatedData[field] = value.toString()
} else if (field === 'subscriptionInfo') {
// 处理订阅信息更新
@@ -1553,7 +1570,7 @@ class ClaudeAccountService {
'rateLimitAutoStopped'
)
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
return { success: true }
} catch (error) {
@@ -2225,7 +2242,7 @@ class ClaudeAccountService {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
return {
totalAccounts: accounts.length,
@@ -2293,11 +2310,11 @@ class ClaudeAccountService {
}
}
logger.success('Session window initialization completed:')
logger.success(` 📊 Total accounts: ${accounts.length}`)
logger.success(` Valid windows: ${validWindowCount}`)
logger.success(` Expired windows: ${expiredWindowCount}`)
logger.success(` 📭 No windows: ${noWindowCount}`)
logger.success('Session window initialization completed:')
logger.success(` Total accounts: ${accounts.length}`)
logger.success(` Valid windows: ${validWindowCount}`)
logger.success(` Expired windows: ${expiredWindowCount}`)
logger.success(` No windows: ${noWindowCount}`)
return {
total: accounts.length,

View File

@@ -5,6 +5,11 @@
const redis = require('../models/redis')
const logger = require('../utils/logger')
const {
getCachedConfig,
setCachedConfig,
deleteCachedConfig
} = require('../utils/performanceOptimizer')
class ClaudeCodeHeadersService {
constructor() {
@@ -41,6 +46,9 @@ class ClaudeCodeHeadersService {
'sec-fetch-mode',
'accept-encoding'
]
// Headers 缓存 TTL60秒
this.headersCacheTtl = 60000
}
/**
@@ -147,6 +155,9 @@ class ClaudeCodeHeadersService {
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
// 更新内存缓存,避免延迟
setCachedConfig(key, extractedHeaders, this.headersCacheTtl)
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
} catch (error) {
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
@@ -154,18 +165,27 @@ class ClaudeCodeHeadersService {
}
/**
* 获取账号的 Claude Code headers
* 获取账号的 Claude Code headers(带内存缓存)
*/
async getAccountHeaders(accountId) {
const cacheKey = `claude_code_headers:${accountId}`
// 检查内存缓存
const cached = getCachedConfig(cacheKey)
if (cached) {
return cached
}
try {
const key = `claude_code_headers:${accountId}`
const data = await redis.getClient().get(key)
const data = await redis.getClient().get(cacheKey)
if (data) {
const parsed = JSON.parse(data)
logger.debug(
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
)
// 缓存到内存
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
return parsed.headers
}
@@ -183,8 +203,10 @@ class ClaudeCodeHeadersService {
*/
async clearAccountHeaders(accountId) {
try {
const key = `claude_code_headers:${accountId}`
await redis.getClient().del(key)
const cacheKey = `claude_code_headers:${accountId}`
await redis.getClient().del(cacheKey)
// 删除内存缓存
deleteCachedConfig(cacheKey)
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
} catch (error) {
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
@@ -192,12 +214,12 @@ class ClaudeCodeHeadersService {
}
/**
* 获取所有账号的 headers 信息
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys
*/
async getAllAccountHeaders() {
try {
const pattern = 'claude_code_headers:*'
const keys = await redis.getClient().keys(pattern)
const keys = await redis.scanKeys(pattern)
const results = {}
for (const key of keys) {

View File

@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
disableAutoProtection = false, // 是否关闭自动防护429/401/400/529 不自动禁用)
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options
// 验证必填字段
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
}
const client = redis.getClientSafe()
@@ -127,6 +129,7 @@ class ClaudeConsoleAccountService {
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
await redis.addToIndex('claude_console_account:index', accountId)
// 如果是共享账户,添加到共享账户集合
if (accountType === 'shared') {
@@ -156,6 +159,7 @@ class ClaudeConsoleAccountService {
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
interceptWarmup, // 新增:返回预热请求拦截开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -164,11 +168,18 @@ class ClaudeConsoleAccountService {
async getAllAccounts() {
try {
const client = redis.getClientSafe()
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
const accountIds = await redis.getAllIdsByIndex(
'claude_console_account:index',
`${this.ACCOUNT_KEY_PREFIX}*`,
/^claude_console_account:(.+)$/
)
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
const accounts = []
const dataList = await redis.batchHgetallChunked(keys)
for (const key of keys) {
const accountData = await client.hgetall(key)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) {
if (!accountData.id) {
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`)
@@ -217,7 +228,9 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true'
disableAutoProtection: accountData.disableAutoProtection === 'true',
// 拦截预热请求
interceptWarmup: accountData.interceptWarmup === 'true'
})
}
}
@@ -375,6 +388,9 @@ class ClaudeConsoleAccountService {
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
if (updates.interceptWarmup !== undefined) {
updatedData.interceptWarmup = updates.interceptWarmup.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
@@ -441,6 +457,7 @@ class ClaudeConsoleAccountService {
// 从Redis删除
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
await redis.removeFromIndex('claude_console_account:index', accountId)
// 从共享账户集合中移除
if (account.accountType === 'shared') {
@@ -569,7 +586,7 @@ class ClaudeConsoleAccountService {
}
await client.hset(accountKey, updateData)
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
}
} else {
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
@@ -577,7 +594,7 @@ class ClaudeConsoleAccountService {
` Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
)
}
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
}
return { success: true }
@@ -850,7 +867,7 @@ class ClaudeConsoleAccountService {
}
await client.hset(accountKey, updateData)
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
}
} else {
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
@@ -858,7 +875,7 @@ class ClaudeConsoleAccountService {
` Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
)
}
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
}
return { success: true }
@@ -959,7 +976,7 @@ class ClaudeConsoleAccountService {
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(
@@ -1408,7 +1425,7 @@ class ClaudeConsoleAccountService {
}
}
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
} catch (error) {
logger.error('Failed to reset all daily usage:', error)
}
@@ -1481,7 +1498,7 @@ class ClaudeConsoleAccountService {
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`Reset all error status for Claude Console account ${accountId}`)
logger.success(`Reset all error status for Claude Console account ${accountId}`)
// 发送 Webhook 通知
try {

View File

@@ -9,6 +9,9 @@ const {
sanitizeErrorMessage,
isAccountDisabledError
} = require('../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
const { filterForClaude } = require('../utils/headerFilter')
class ClaudeConsoleRelayService {
constructor() {
@@ -29,8 +32,68 @@ class ClaudeConsoleRelayService {
let account = null
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in console relayRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
apiKeyName: apiKeyData.name,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
accountId
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
@@ -203,6 +266,23 @@ class ClaudeConsoleRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -366,6 +446,21 @@ class ClaudeConsoleRelayService {
)
}
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -384,8 +479,71 @@ class ClaudeConsoleRelayService {
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
let leaseRefreshInterval = null // 租约刷新定时器
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error(
'❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture'
)
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
apiKeyName: apiKeyData.name,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n`
responseStream.write(errorEvent)
responseStream.write('data: [DONE]\n\n')
responseStream.end()
return
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
@@ -483,16 +641,40 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
}
throw error
} finally {
// 🛑 清理租约刷新定时器
@@ -517,6 +699,21 @@ class ClaudeConsoleRelayService {
)
}
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -530,7 +727,8 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -593,8 +791,11 @@ class ClaudeConsoleRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
// 错误响应处理
@@ -682,7 +883,7 @@ class ClaudeConsoleRelayService {
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(JSON.stringify(sanitizedError))
responseStream.end()
}
@@ -690,7 +891,7 @@ class ClaudeConsoleRelayService {
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(sanitizedText)
responseStream.end()
}
@@ -701,6 +902,19 @@ class ClaudeConsoleRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -714,11 +928,22 @@ class ClaudeConsoleRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const connectionHeader = existingConnection || 'keep-alive'
if (existingConnection) {
logger.debug(
`🔌 [Console Stream] Preserving existing Connection header: ${existingConnection}`
)
}
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: connectionHeader,
'X-Accel-Buffering': 'no'
})
}
@@ -744,20 +969,33 @@ class ClaudeConsoleRelayService {
buffer = lines.pop() || ''
// 转发数据并解析usage
if (lines.length > 0 && !responseStream.destroyed) {
if (lines.length > 0) {
// 检查流是否可写(客户端连接是否有效)
if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 应用流转换器如果有
let dataToWrite = linesToForward
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
responseStream.write(transformed)
}
dataToWrite = transformed
} else {
responseStream.write(linesToForward)
dataToWrite = null
}
}
// 解析SSE数据寻找usage信息
if (dataToWrite) {
responseStream.write(dataToWrite)
}
} else {
// 客户端连接已断开记录警告但仍继续解析usage
logger.warn(
`⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}`
)
}
// 解析SSE数据寻找usage信息无论连接状态如何
for (const line of lines) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
@@ -865,7 +1103,7 @@ class ClaudeConsoleRelayService {
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -888,7 +1126,7 @@ class ClaudeConsoleRelayService {
response.data.on('end', () => {
try {
// 处理缓冲区中剩余的数据
if (buffer.trim() && !responseStream.destroyed) {
if (buffer.trim() && isStreamWritable(responseStream)) {
if (streamTransformer) {
const transformed = streamTransformer(buffer)
if (transformed) {
@@ -937,12 +1175,33 @@ class ClaudeConsoleRelayService {
}
// 确保流正确结束
if (!responseStream.destroyed) {
responseStream.end()
if (isStreamWritable(responseStream)) {
// 📊 诊断日志:流结束前状态
logger.info(
`📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` +
`socketDestroyed: ${responseStream.socket?.destroyed}, ` +
`socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}`
)
// 禁用 Nagle 算法确保数据立即发送
if (responseStream.socket && !responseStream.socket.destroyed) {
responseStream.socket.setNoDelay(true)
}
logger.debug('🌊 Claude Console Claude stream response completed')
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.info(
`✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
)
resolve()
}
} catch (error) {
logger.error('❌ Error processing stream end:', error)
reject(error)
@@ -954,7 +1213,7 @@ class ClaudeConsoleRelayService {
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1002,14 +1261,17 @@ class ClaudeConsoleRelayService {
// 发送错误响应
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(error.response?.status || 500, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
})
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1041,30 +1303,9 @@ class ClaudeConsoleRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'authorization',
'x-api-key',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
'anthropic-version'
]
const filteredHeaders = {}
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!sensitiveHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类(白名单模式)
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
return filterForClaude(clientHeaders)
}
// 🕐 更新最后使用时间
@@ -1179,7 +1420,7 @@ class ClaudeConsoleRelayService {
'Cache-Control': 'no-cache'
})
}
if (!responseStream.destroyed && !responseStream.writableEnded) {
if (isStreamWritable(responseStream)) {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
)

View File

@@ -15,6 +15,20 @@ const DEFAULT_CONFIG = {
globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30, // 会话绑定 TTL默认30天
// 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
userMessageQueueLockTtlMs: 120000, // 锁TTL毫秒
// 并发请求排队配置
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3
concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数默认0仅使用固定值
concurrentRequestQueueTimeoutMs: 10000, // 排队超时毫秒默认10秒
concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值默认5次
// 排队健康检查配置
concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启)
concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值P90 >= 超时 × 阈值时拒绝新请求)
updatedAt: null,
updatedBy: null
}
@@ -100,7 +114,8 @@ class ClaudeRelayConfigService {
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
})
return updatedConfig
@@ -283,12 +298,13 @@ class ClaudeRelayConfigService {
const account = await accountService.getAccount(accountId)
if (!account || !account.success || !account.data) {
// getAccount() 直接返回账户数据对象或 null不是 { success, data } 格式
if (!account) {
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
return false
}
const accountData = account.data
const accountData = account
// 检查账户是否激活
if (accountData.isActive === false || accountData.isActive === 'false') {
@@ -315,11 +331,11 @@ class ClaudeRelayConfigService {
/**
* 验证新会话请求
* @param {Object} requestBody - 请求体
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
*/
async validateNewSession(requestBody, originalSessionId) {
async validateNewSession(_requestBody, originalSessionId) {
const cfg = await this.getConfig()
if (!cfg.globalSessionBindingEnabled) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,65 @@
const redis = require('../models/redis')
const apiKeyService = require('./apiKeyService')
const CostCalculator = require('../utils/costCalculator')
const logger = require('../utils/logger')
// HMGET 需要的字段
const USAGE_FIELDS = [
'totalInputTokens',
'inputTokens',
'totalOutputTokens',
'outputTokens',
'totalCacheCreateTokens',
'cacheCreateTokens',
'totalCacheReadTokens',
'cacheReadTokens'
]
class CostInitService {
/**
* 带并发限制的并行执行
*/
async parallelLimit(items, fn, concurrency = 20) {
let index = 0
const results = []
async function worker() {
while (index < items.length) {
const currentIndex = index++
try {
results[currentIndex] = await fn(items[currentIndex], currentIndex)
} catch (error) {
results[currentIndex] = { error }
}
}
}
await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker))
return results
}
/**
* 使用 SCAN 获取匹配的 keys带去重
*/
async scanKeysWithDedup(client, pattern, count = 500) {
const seen = new Set()
const allKeys = []
let cursor = '0'
do {
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
cursor = newCursor
for (const key of keys) {
if (!seen.has(key)) {
seen.add(key)
allKeys.push(key)
}
}
} while (cursor !== '0')
return allKeys
}
/**
* 初始化所有API Key的费用数据
* 扫描历史使用记录并计算费用
@@ -12,25 +68,57 @@ class CostInitService {
try {
logger.info('💰 Starting cost initialization for all API Keys...')
const apiKeys = await apiKeyService.getAllApiKeys()
// 用 scanApiKeyIds 获取 ID然后过滤已删除的
const allKeyIds = await redis.scanApiKeyIds()
const client = redis.getClientSafe()
// 批量检查 isDeleted 状态,过滤已删除的 key
const FILTER_BATCH = 100
const apiKeyIds = []
for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) {
const batch = allKeyIds.slice(i, i + FILTER_BATCH)
const pipeline = client.pipeline()
for (const keyId of batch) {
pipeline.hget(`apikey:${keyId}`, 'isDeleted')
}
const results = await pipeline.exec()
for (let j = 0; j < results.length; j++) {
const [err, isDeleted] = results[j]
if (!err && isDeleted !== 'true') {
apiKeyIds.push(batch[j])
}
}
}
logger.info(
`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`
)
let processedCount = 0
let errorCount = 0
for (const apiKey of apiKeys) {
// 优化6: 并行处理 + 并发限制
await this.parallelLimit(
apiKeyIds,
async (apiKeyId) => {
try {
await this.initializeApiKeyCosts(apiKey.id, client)
await this.initializeApiKeyCosts(apiKeyId, client)
processedCount++
if (processedCount % 10 === 0) {
logger.info(`💰 Processed ${processedCount} API Keys...`)
if (processedCount % 100 === 0) {
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
}
} catch (error) {
errorCount++
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
}
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
}
},
20 // 并发数
)
logger.success(
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
@@ -46,16 +134,55 @@ class CostInitService {
* 初始化单个API Key的费用数据
*/
async initializeApiKeyCosts(apiKeyId, client) {
// 获取所有时间的模型使用统计
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`)
// 优化4: 使用 SCAN 获取 keys带去重
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`)
if (modelKeys.length === 0) {
return
}
// 优化5: 使用 Pipeline + HMGET 批量获取数据
const BATCH_SIZE = 100
const allData = []
for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) {
const batch = modelKeys.slice(i, i + BATCH_SIZE)
const pipeline = client.pipeline()
for (const key of batch) {
pipeline.hmget(key, ...USAGE_FIELDS)
}
const results = await pipeline.exec()
for (let j = 0; j < results.length; j++) {
const [err, values] = results[j]
if (err) {
continue
}
// 将数组转换为对象
const data = {}
let hasData = false
for (let k = 0; k < USAGE_FIELDS.length; k++) {
if (values[k] !== null) {
data[USAGE_FIELDS[k]] = values[k]
hasData = true
}
}
if (hasData) {
allData.push({ key: batch[j], data })
}
}
}
// 按日期分组统计
const dailyCosts = new Map() // date -> cost
const monthlyCosts = new Map() // month -> cost
const hourlyCosts = new Map() // hour -> cost
const dailyCosts = new Map()
const monthlyCosts = new Map()
const hourlyCosts = new Map()
for (const key of modelKeys) {
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
for (const { key, data } of allData) {
const match = key.match(
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
)
@@ -65,13 +192,6 @@ class CostInitService {
const [, , period, model, dateStr] = match
// 获取使用数据
const data = await client.hgetall(key)
if (!data || Object.keys(data).length === 0) {
continue
}
// 计算费用
const usage = {
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
@@ -84,47 +204,34 @@ class CostInitService {
const costResult = CostCalculator.calculateCost(usage, model)
const cost = costResult.costs.total
// 根据period分组累加费用
if (period === 'daily') {
const currentCost = dailyCosts.get(dateStr) || 0
dailyCosts.set(dateStr, currentCost + cost)
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
} else if (period === 'monthly') {
const currentCost = monthlyCosts.get(dateStr) || 0
monthlyCosts.set(dateStr, currentCost + cost)
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
} else if (period === 'hourly') {
const currentCost = hourlyCosts.get(dateStr) || 0
hourlyCosts.set(dateStr, currentCost + cost)
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
}
}
// 将计算出的费用写入Redis
const promises = []
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的
const pipeline = client.pipeline()
// 写入每日费用
// 写入每日费用(只补缺失)
for (const [date, cost] of dailyCosts) {
const key = `usage:cost:daily:${apiKeyId}:${date}`
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 30) // 30天过期
)
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
}
// 写入每月费用
// 写入每月费用(只补缺失)
for (const [month, cost] of monthlyCosts) {
const key = `usage:cost:monthly:${apiKeyId}:${month}`
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 90) // 90天过期
)
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
}
// 写入每小时费用
// 写入每小时费用(只补缺失)
for (const [hour, cost] of hourlyCosts) {
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 7) // 7天过期
)
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
}
// 计算总费用
@@ -133,37 +240,25 @@ class CostInitService {
totalCost += cost
}
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
// 写入总费用(只补缺失)
if (totalCost > 0) {
const totalKey = `usage:cost:total:${apiKeyId}`
// 先检查总费用是否已存在
const existingTotal = await client.get(totalKey)
if (!existingTotal || parseFloat(existingTotal) === 0) {
// 仅在总费用不存在或为0时才初始化
promises.push(client.set(totalKey, totalCost.toString()))
pipeline.set(totalKey, totalCost.toString())
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
} else {
// 如果总费用已存在,保持不变,避免覆盖累计值
// 注意这个逻辑防止因每日费用键过期30天导致的错误覆盖
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
const existing = parseFloat(existingTotal)
const calculated = totalCost
if (calculated > existing * 1.1) {
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
if (totalCost > existing * 1.1) {
logger.warn(
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
)
} else {
logger.debug(
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
)
}
}
}
await Promise.all(promises)
await pipeline.exec()
logger.debug(
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
@@ -172,41 +267,70 @@ class CostInitService {
/**
* 检查是否需要初始化费用数据
* 使用 SCAN 代替 KEYS正确处理 cursor
*/
async needsInitialization() {
try {
const client = redis.getClientSafe()
// 检查是否有任何费用数据
const costKeys = await client.keys('usage:cost:*')
// 正确循环 SCAN 检查是否有任何费用数据
let cursor = '0'
let hasCostData = false
// 如果没有费用数据,需要初始化
if (costKeys.length === 0) {
do {
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100)
cursor = newCursor
if (keys.length > 0) {
hasCostData = true
break
}
} while (cursor !== '0')
if (!hasCostData) {
logger.info('💰 No cost data found, initialization needed')
return true
}
// 检查是否有使用数据但没有对应的费用数据
const sampleKeys = await client.keys('usage:*:model:daily:*:*')
if (sampleKeys.length > 10) {
// 抽样检查
const sampleSize = Math.min(10, sampleKeys.length)
for (let i = 0; i < sampleSize; i++) {
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
// 抽样检查使用数据是否有对应的费用数据
cursor = '0'
let samplesChecked = 0
const maxSamples = 10
do {
const [newCursor, usageKeys] = await client.scan(
cursor,
'MATCH',
'usage:*:model:daily:*:*',
'COUNT',
100
)
cursor = newCursor
for (const usageKey of usageKeys) {
if (samplesChecked >= maxSamples) {
break
}
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) {
const [, keyId, , date] = match
const costKey = `usage:cost:daily:${keyId}:${date}`
const hasCost = await client.exists(costKey)
if (!hasCost) {
logger.info(
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
)
return true
}
samplesChecked++
}
}
if (samplesChecked >= maxSamples) {
break
}
} while (cursor !== '0')
logger.info('💰 Cost data appears to be up to date')
return false

View File

@@ -103,7 +103,7 @@ class CostRankService {
}
this.isInitialized = true
logger.success('CostRankService initialized')
logger.success('CostRankService initialized')
} catch (error) {
logger.error('❌ Failed to initialize CostRankService:', error)
throw error
@@ -391,10 +391,24 @@ class CostRankService {
return {}
}
const status = {}
// 使用 Pipeline 批量获取
const pipeline = client.pipeline()
for (const timeRange of VALID_TIME_RANGES) {
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
pipeline.hgetall(RedisKeys.metaKey(timeRange))
}
const results = await pipeline.exec()
const status = {}
VALID_TIME_RANGES.forEach((timeRange, i) => {
const [err, meta] = results[i]
if (err || !meta) {
status[timeRange] = {
lastUpdate: null,
keyCount: 0,
status: 'unknown',
updateDuration: 0
}
} else {
status[timeRange] = {
lastUpdate: meta.lastUpdate || null,
keyCount: parseInt(meta.keyCount || 0),
@@ -402,6 +416,7 @@ class CostRankService {
updateDuration: parseInt(meta.updateDuration || 0)
}
}
})
return status
}

View File

@@ -2,11 +2,10 @@ const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const axios = require('axios')
const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger')
const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper')
const LRUCache = require('../utils/lruCache')
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
/**
* Droid 账户管理服务
@@ -26,21 +25,14 @@ class DroidAccountService {
this.refreshIntervalHours = 6 // 每6小时刷新一次
this.tokenValidHours = 8 // Token 有效期8小时
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'droid-account-salt'
// 🚀 性能优化:缓存派生的加密密钥
this._encryptionKeyCache = null
// 🔄 解密结果缓存
this._decryptCache = new LRUCache(500)
// 使用 commonHelper 的加密器
this._encryptor = createEncryptor('droid-account-salt')
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
this._encryptor.clearCache()
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
},
10 * 60 * 1000
)
@@ -69,92 +61,19 @@ class DroidAccountService {
return 'anthropic'
}
// 使用 commonHelper 的 isTruthy
_isTruthy(value) {
if (value === undefined || value === null) {
return false
}
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true') {
return true
}
if (normalized === 'false') {
return false
}
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
}
return Boolean(value)
return isTruthy(value)
}
/**
* 生成加密密钥(缓存优化)
*/
_generateEncryptionKey() {
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
logger.info('🔑 Droid encryption key derived and cached for performance optimization')
}
return this._encryptionKeyCache
}
/**
* 加密敏感数据
*/
// 加密敏感数据
_encryptSensitiveData(text) {
if (!text) {
return ''
return this._encryptor.encrypt(text)
}
const key = this._generateEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return `${iv.toString('hex')}:${encrypted}`
}
/**
* 解密敏感数据(带缓存)
*/
// 解密敏感数据(带缓存)
_decryptSensitiveData(encryptedText) {
if (!encryptedText) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = this._generateEncryptionKey()
const parts = encryptedText.split(':')
const iv = Buffer.from(parts[0], 'hex')
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} catch (error) {
logger.error('❌ Failed to decrypt Droid data:', error)
return ''
}
return this._encryptor.decrypt(encryptedText)
}
_parseApiKeyEntries(rawEntries) {
@@ -683,7 +602,7 @@ class DroidAccountService {
lastRefreshAt = new Date().toISOString()
status = 'active'
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
} catch (error) {
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
throw new Error(`Refresh Token 验证失败:${error.message}`)
@@ -1368,7 +1287,7 @@ class DroidAccountService {
}
}
logger.success(`Droid account token refreshed successfully: ${accountId}`)
logger.success(`Droid account token refreshed successfully: ${accountId}`)
return {
accessToken: refreshed.accessToken,

View File

@@ -90,7 +90,7 @@ class DroidRelayService {
return normalizedBody
}
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) {
if (!rateLimitInfo) {
return
}
@@ -99,7 +99,9 @@ class DroidRelayService {
const { totalTokens, totalCost } = await updateRateLimitCounters(
rateLimitInfo,
usageSummary,
model
model,
keyId,
'droid'
)
if (totalTokens > 0) {
@@ -336,7 +338,12 @@ class DroidRelayService {
)
}
} catch (error) {
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(`🔌 Droid relay ended: Client disconnected`)
} else {
logger.error(`❌ Droid relay error: ${error.message}`, error)
}
const status = error?.response?.status
if (status >= 400 && status < 500) {
@@ -398,6 +405,7 @@ class DroidRelayService {
) {
return new Promise((resolve, reject) => {
const url = new URL(apiUrl)
const keyId = apiKeyData?.id
const bodyString = JSON.stringify(processedBody)
const contentLength = Buffer.byteLength(bodyString)
const requestHeaders = {
@@ -601,10 +609,11 @@ class DroidRelayService {
clientRequest?.rateLimitInfo,
usageSummary,
model,
' [stream]'
' [stream]',
keyId
)
logger.success(`Droid stream completed - Account: ${account.name}`)
logger.success(`Droid stream completed - Account: ${account.name}`)
} else {
logger.success(
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
@@ -634,7 +643,7 @@ class DroidRelayService {
// 客户端断开连接时清理
clientResponse.on('close', () => {
if (req && !req.destroyed) {
req.destroy()
req.destroy(new Error('Client disconnected'))
}
})
@@ -1190,6 +1199,7 @@ class DroidRelayService {
skipUsageRecord = false
) {
const { data } = response
const keyId = apiKeyData?.id
// 从响应中提取 usage 数据
const usage = data.usage || {}
@@ -1220,7 +1230,8 @@ class DroidRelayService {
clientRequest?.rateLimitInfo,
usageSummary,
model,
endpointLabel
endpointLabel,
keyId
)
logger.success(

View File

@@ -2,103 +2,40 @@ const droidAccountService = require('./droidAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const {
isTruthy,
isAccountHealthy,
sortAccountsByPriority,
normalizeEndpointType
} = require('../utils/commonHelper')
class DroidScheduler {
constructor() {
this.STICKY_PREFIX = 'droid'
}
_normalizeEndpointType(endpointType) {
if (!endpointType) {
return 'anthropic'
}
const normalized = String(endpointType).toLowerCase()
if (normalized === 'openai') {
return 'openai'
}
if (normalized === 'comm') {
return 'comm'
}
if (normalized === 'anthropic') {
return 'anthropic'
}
return 'anthropic'
}
_isTruthy(value) {
if (value === undefined || value === null) {
return false
}
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
return value.toLowerCase() === 'true'
}
return Boolean(value)
}
_isAccountActive(account) {
if (!account) {
return false
}
const isActive = this._isTruthy(account.isActive)
if (!isActive) {
return false
}
const status = (account.status || 'active').toLowerCase()
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
return !unhealthyStatuses.has(status)
}
_isAccountSchedulable(account) {
return this._isTruthy(account?.schedulable ?? true)
return isTruthy(account?.schedulable ?? true)
}
_matchesEndpoint(account, endpointType) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
const normalizedEndpoint = normalizeEndpointType(endpointType)
const accountEndpoint = normalizeEndpointType(account?.endpointType)
if (normalizedEndpoint === accountEndpoint) {
return true
}
// comm 端点可以使用任何类型的账户
if (normalizedEndpoint === 'comm') {
return true
}
const sharedEndpoints = new Set(['anthropic', 'openai'])
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
}
_sortCandidates(candidates) {
return [...candidates].sort((a, b) => {
const priorityA = parseInt(a.priority, 10) || 50
const priorityB = parseInt(b.priority, 10) || 50
if (priorityA !== priorityB) {
return priorityA - priorityB
}
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
if (lastUsedA !== lastUsedB) {
return lastUsedA - lastUsedB
}
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return createdA - createdB
})
}
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
if (!sessionHash) {
return null
}
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const normalizedEndpoint = normalizeEndpointType(endpointType)
const apiKeyPart = apiKeyId || 'default'
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
}
@@ -121,7 +58,7 @@ class DroidScheduler {
)
return accounts.filter(
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
)
}
@@ -145,7 +82,7 @@ class DroidScheduler {
}
async selectAccount(apiKeyData, endpointType, sessionHash) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const normalizedEndpoint = normalizeEndpointType(endpointType)
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
let candidates = []
@@ -175,7 +112,7 @@ class DroidScheduler {
const filtered = candidates.filter(
(account) =>
account &&
this._isAccountActive(account) &&
isAccountHealthy(account) &&
this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint)
)
@@ -203,7 +140,7 @@ class DroidScheduler {
}
}
const sorted = this._sortCandidates(filtered)
const sorted = sortAccountsByPriority(filtered)
const selected = sorted[0]
if (!selected) {

View File

@@ -1,8 +1,6 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const https = require('https')
const config = require('../../config/config')
const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask')
@@ -15,12 +13,68 @@ const {
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
const { createEncryptor } = require('../utils/commonHelper')
const antigravityClient = require('./antigravityClient')
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
const OAUTH_CLIENT_SECRET = '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'
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
// Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
const OAUTH_PROVIDERS = {
[OAUTH_PROVIDER_GEMINI_CLI]: {
// Gemini CLI OAuth 配置(公开)
clientId:
process.env.GEMINI_OAUTH_CLIENT_ID ||
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
scopes: ['https://www.googleapis.com/auth/cloud-platform']
},
[OAUTH_PROVIDER_ANTIGRAVITY]: {
// Antigravity OAuth 配置(参考 gcli2api
clientId:
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
clientSecret:
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
scopes: [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
]
}
}
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
logger.warn(
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
)
}
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
logger.warn(
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
)
}
function normalizeOauthProvider(oauthProvider) {
if (!oauthProvider) {
return OAUTH_PROVIDER_GEMINI_CLI
}
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
? OAUTH_PROVIDER_ANTIGRAVITY
: OAUTH_PROVIDER_GEMINI_CLI
}
function getOauthProviderConfig(oauthProvider) {
const normalized = normalizeOauthProvider(oauthProvider)
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
}
// 🌐 TCP Keep-Alive Agent 配置
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
@@ -34,104 +88,140 @@ const keepAliveAgent = new https.Agent({
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'gemini-account-salt'
const IV_LENGTH = 16
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
let _encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
const decryptCache = new LRUCache(500)
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
if (!_encryptionKeyCache) {
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
}
return _encryptionKeyCache
}
// Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
// 加密函数
function encrypt(text) {
if (!text) {
return ''
}
const key = generateEncryptionKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密函数
function decrypt(text) {
if (!text) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
// 使用 commonHelper 的加密器
const encryptor = createEncryptor('gemini-account-salt')
const { encrypt, decrypt } = encryptor
async function fetchAvailableModelsAntigravity(
accessToken,
proxyConfig = null,
refreshToken = null
) {
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
const ivHex = text.substring(0, 32)
const encryptedHex = text.substring(33) // 跳过冒号
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
const result = decrypted.toString()
// 💾 存入缓存5分钟过期
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
decryptCache.printStats()
let effectiveToken = accessToken
if (refreshToken) {
try {
const client = await getOauthClient(
accessToken,
refreshToken,
proxyConfig,
OAUTH_PROVIDER_ANTIGRAVITY
)
if (client && client.getAccessToken) {
const latest = await client.getAccessToken()
if (latest?.token) {
effectiveToken = latest.token
}
}
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
logger.warn('Failed to refresh Antigravity access token for models list:', {
message: error.message
})
}
}
const data = await antigravityClient.fetchAvailableModels({
accessToken: effectiveToken,
proxyConfig
})
const modelsDict = data?.models
const created = Math.floor(Date.now() / 1000)
const models = []
const seen = new Set()
const {
getAntigravityModelAlias,
getAntigravityModelMetadata,
normalizeAntigravityModelInput
} = require('../utils/antigravityModel')
const pushModel = (modelId) => {
if (!modelId || seen.has(modelId)) {
return
}
seen.add(modelId)
const metadata = getAntigravityModelMetadata(modelId)
const entry = {
id: modelId,
object: 'model',
created,
owned_by: 'antigravity'
}
if (metadata?.name) {
entry.name = metadata.name
}
if (metadata?.maxCompletionTokens) {
entry.max_completion_tokens = metadata.maxCompletionTokens
}
if (metadata?.thinking) {
entry.thinking = metadata.thinking
}
models.push(entry)
}
if (modelsDict && typeof modelsDict === 'object') {
for (const modelId of Object.keys(modelsDict)) {
const normalized = normalizeAntigravityModelInput(modelId)
const alias = getAntigravityModelAlias(normalized)
if (!alias) {
continue
}
pushModel(alias)
if (alias.endsWith('-thinking')) {
pushModel(alias.replace(/-thinking$/, ''))
}
if (alias.startsWith('gemini-claude-')) {
pushModel(alias.replace(/^gemini-/, ''))
}
}
}
return models
} catch (error) {
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
return [
{
id: 'gemini-2.5-flash',
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'antigravity'
}
]
}
}
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
const { token } = await client.getAccessToken()
const response = await antigravityClient.countTokens({
accessToken: token,
proxyConfig,
contents,
model
})
return response
}
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
decryptCache.cleanup()
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
encryptor.clearCache()
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
},
10 * 60 * 1000
)
// 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
// 如果没有提供 redirectUri使用默认值
const uri = redirectUri || 'http://localhost:45462'
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 准备客户端选项
const clientOptions = {
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
redirectUri: uri
}
@@ -152,10 +242,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
}
// 生成授权 URL (支持 PKCE 和代理)
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
async function generateAuthUrl(
state = null,
redirectUri = null,
proxyConfig = null,
oauthProvider = null
) {
// 使用新的 redirect URI
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
if (proxyConfig) {
logger.info(
@@ -172,7 +269,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
const authUrl = oAuth2Client.generateAuthUrl({
redirect_uri: finalRedirectUri,
access_type: 'offline',
scope: OAUTH_SCOPES,
scope: oauthConfig.scopes,
code_challenge_method: 'S256',
code_challenge: codeVerifier.codeChallenge,
state: stateValue,
@@ -183,7 +280,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
authUrl,
state: stateValue,
codeVerifier: codeVerifier.codeVerifier,
redirectUri: finalRedirectUri
redirectUri: finalRedirectUri,
oauthProvider: normalizedProvider
}
}
@@ -244,11 +342,14 @@ async function exchangeCodeForTokens(
code,
redirectUri = null,
codeVerifier = null,
proxyConfig = null
proxyConfig = null,
oauthProvider = null
) {
try {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
if (proxyConfig) {
logger.info(
@@ -274,7 +375,7 @@ async function exchangeCodeForTokens(
return {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scope: tokens.scope || OAUTH_SCOPES.join(' '),
scope: tokens.scope || oauthConfig.scopes.join(' '),
token_type: tokens.token_type || 'Bearer',
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
}
@@ -285,9 +386,11 @@ async function exchangeCodeForTokens(
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken, proxyConfig = null) {
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig)
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
try {
// 设置 refresh_token
@@ -319,7 +422,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
return {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
scope: credentials.scope || OAUTH_SCOPES.join(' '),
scope: credentials.scope || oauthConfig.scopes.join(' '),
token_type: credentials.token_type || 'Bearer',
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
}
@@ -339,6 +442,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
async function createAccount(accountData) {
const id = uuidv4()
const now = new Date().toISOString()
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 处理凭证数据
let geminiOauth = null
@@ -371,7 +476,7 @@ async function createAccount(accountData) {
geminiOauth = JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken,
scope: accountData.scope || OAUTH_SCOPES.join(' '),
scope: accountData.scope || oauthConfig.scopes.join(' '),
token_type: accountData.tokenType || 'Bearer',
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
})
@@ -399,7 +504,8 @@ async function createAccount(accountData) {
refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
// 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
oauthProvider,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
@@ -426,6 +532,7 @@ async function createAccount(accountData) {
// 保存到 Redis
const client = redisClient.getClientSafe()
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
await redisClient.addToIndex('gemini_account:index', id)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
@@ -508,6 +615,10 @@ async function updateAccount(accountId, updates) {
updates.schedulable = updates.schedulable.toString()
}
if (updates.oauthProvider !== undefined) {
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
}
// 加密敏感字段
if (updates.geminiOauth) {
updates.geminiOauth = encrypt(
@@ -623,19 +734,20 @@ async function deleteAccount(accountId) {
// 从 Redis 删除
const client = redisClient.getClientSafe()
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
await redisClient.removeFromIndex('gemini_account:index', accountId)
// 从共享账户集合中移除
if (account.accountType === 'shared') {
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
}
// 清理会话映射
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
for (const key of sessionMappings) {
const mappedAccountId = await client.get(key)
if (mappedAccountId === accountId) {
await client.del(key)
}
// 清理会话映射(使用反向索引)
const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
if (sessionHashes.length > 0) {
const pipeline = client.pipeline()
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
pipeline.del(`gemini_account_sessions:${accountId}`)
await pipeline.exec()
}
logger.info(`Deleted Gemini account: ${accountId}`)
@@ -644,12 +756,18 @@ async function deleteAccount(accountId) {
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
const _client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex(
'gemini_account:index',
`${GEMINI_ACCOUNT_KEY_PREFIX}*`,
/^gemini_account:(.+)$/
)
const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`)
const accounts = []
const dataList = await redisClient.batchHgetallChunked(keys)
for (const key of keys) {
const accountData = await client.hgetall(key)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
@@ -752,6 +870,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期
account.id
)
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
}
return account
@@ -811,6 +931,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
// 创建粘性会话映射
if (sessionHash) {
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash)
await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600)
}
return selectedAccount
@@ -885,12 +1007,13 @@ async function refreshAccountToken(accountId) {
// 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId)
if (updatedAccount && updatedAccount.accessToken) {
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
const accessToken = decrypt(updatedAccount.accessToken)
return {
access_token: accessToken,
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
token_type: 'Bearer'
}
}
@@ -904,7 +1027,11 @@ async function refreshAccountToken(accountId) {
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
// 传入账户的代理配置
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
const newTokens = await refreshAccessToken(
account.refreshToken,
account.proxy,
account.oauthProvider
)
// 更新账户信息
const updates = {
@@ -1036,14 +1163,15 @@ async function getAccountRateLimitInfo(accountId) {
}
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法支持代理
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
const client = createOAuth2Client(null, proxyConfig)
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
const creds = {
access_token: accessToken,
refresh_token: refreshToken,
scope:
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
scope: oauthConfig.scopes.join(' '),
token_type: 'Bearer',
expiry_date: 1754269905646
}
@@ -1509,6 +1637,43 @@ async function generateContent(
return response.data
}
// 调用 Antigravity 上游生成内容(非流式)
async function generateContentAntigravity(
client,
requestData,
userPromptId,
projectId = null,
sessionId = null,
proxyConfig = null
) {
const { token } = await client.getAccessToken()
const { model } = antigravityClient.buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
logger.info('🪐 Antigravity generateContent API调用开始', {
model,
userPromptId,
projectId,
sessionId
})
const { response } = await antigravityClient.request({
accessToken: token,
proxyConfig,
requestData,
projectId,
sessionId,
userPromptId,
stream: false
})
logger.info('✅ Antigravity generateContent API调用成功')
return response.data
}
// 调用 Code Assist API 生成内容(流式)
async function generateContentStream(
client,
@@ -1593,6 +1758,46 @@ async function generateContentStream(
return response.data // 返回流对象
}
// 调用 Antigravity 上游生成内容(流式)
async function generateContentStreamAntigravity(
client,
requestData,
userPromptId,
projectId = null,
sessionId = null,
signal = null,
proxyConfig = null
) {
const { token } = await client.getAccessToken()
const { model } = antigravityClient.buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
model,
userPromptId,
projectId,
sessionId
})
const { response } = await antigravityClient.request({
accessToken: token,
proxyConfig,
requestData,
projectId,
sessionId,
userPromptId,
stream: true,
signal,
params: { alt: 'sse' }
})
logger.info('✅ Antigravity streamGenerateContent API调用成功开始流式传输')
return response.data
}
// 更新账户的临时项目 ID
async function updateTempProjectId(accountId, tempProjectId) {
if (!tempProjectId) {
@@ -1684,13 +1889,14 @@ module.exports = {
setupUser,
encrypt,
decrypt,
generateEncryptionKey,
decryptCache, // 暴露缓存对象以便测试和监控
encryptor, // 暴露加密器以便测试和监控
countTokens,
countTokensAntigravity,
generateContent,
generateContentStream,
generateContentAntigravity,
generateContentStreamAntigravity,
fetchAvailableModelsAntigravity,
updateTempProjectId,
resetAccountStatus,
OAUTH_CLIENT_ID,
OAUTH_SCOPES
resetAccountStatus
}

View File

@@ -85,7 +85,7 @@ class GeminiApiAccountService {
// 保存到 Redis
await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
logger.success(`Created Gemini-API account: ${name} (${accountId})`)
return {
...accountData,
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
// 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 从索引中移除
await redis.removeFromIndex('gemini_api_account:index', accountId)
// 删除账户数据
await client.del(key)
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
}
// 直接从 Redis 获取所有账户(包括非共享账户)
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
for (const key of keys) {
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
const allAccountIds = await redis.getAllIdsByIndex(
'gemini_api_account:index',
`${this.ACCOUNT_KEY_PREFIX}*`,
/^gemini_api_account:(.+)$/
)
const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
const dataList = await redis.batchHgetallChunked(keys)
for (let i = 0; i < allAccountIds.length; i++) {
const accountId = allAccountIds[i]
if (!accountIds.includes(accountId)) {
const accountData = await client.hgetall(key)
const accountData = dataList[i]
if (accountData && accountData.id) {
// 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') {
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
// 保存账户数据
await client.hset(key, accountData)
// 添加到索引
await redis.addToIndex('gemini_api_account:index', accountId)
// 添加到共享账户列表
if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)

View File

@@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null
0, // cacheCreateTokens (Gemini 没有这个概念)
0, // cacheReadTokens (Gemini 没有这个概念)
model,
accountId
accountId,
'gemini'
)
.catch((error) => {
logger.error('❌ Failed to record Gemini usage:', error)
@@ -317,7 +318,8 @@ async function sendGeminiRequest({
0, // cacheCreateTokens
0, // cacheReadTokens
model,
accountId
accountId,
'gemini'
)
.catch((error) => {
logger.error('❌ Failed to record Gemini usage:', error)

View File

@@ -18,7 +18,7 @@ class ModelService {
(sum, config) => sum + config.models.length,
0
)
logger.success(`Model service initialized with ${totalModels} models`)
logger.success(`Model service initialized with ${totalModels} models`)
}
/**

View File

@@ -1,6 +1,5 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const config = require('../../config/config')
@@ -13,104 +12,23 @@ const {
logTokenUsage,
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache')
const tokenRefreshService = require('./tokenRefreshService')
const { createEncryptor } = require('../utils/commonHelper')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'openai-account-salt'
const IV_LENGTH = 16
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
let _encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
const decryptCache = new LRUCache(500)
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
if (!_encryptionKeyCache) {
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
}
return _encryptionKeyCache
}
// 使用 commonHelper 的加密器
const encryptor = createEncryptor('openai-account-salt')
const { encrypt, decrypt } = encryptor
// OpenAI 账户键前缀
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
// 加密函数
function encrypt(text) {
if (!text) {
return ''
}
const key = generateEncryptionKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密函数
function decrypt(text) {
if (!text || text === '') {
return ''
}
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
if (text.length < 33 || text.charAt(32) !== ':') {
logger.warn('Invalid encrypted text format, returning empty string', {
textLength: text ? text.length : 0,
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
first50: text ? text.substring(0, 50) : 'N/A'
})
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
const ivHex = text.substring(0, 32)
const encryptedHex = text.substring(33) // 跳过冒号
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
const result = decrypted.toString()
// 💾 存入缓存5分钟过期
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
decryptCache.printStats()
}
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
decryptCache.cleanup()
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
encryptor.clearCache()
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
},
10 * 60 * 1000
)
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
const client = redisClient.getClientSafe()
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
await redisClient.addToIndex('openai:account:index', accountId)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
// 从 Redis 删除
const client = redisClient.getClientSafe()
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
await redisClient.removeFromIndex('openai:account:index', accountId)
// 从共享账户集合中移除
if (account.accountType === 'shared') {
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
}
// 清理会话映射
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
for (const key of sessionMappings) {
const mappedAccountId = await client.get(key)
if (mappedAccountId === accountId) {
await client.del(key)
}
// 清理会话映射(使用反向索引)
const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
if (sessionHashes.length > 0) {
const pipeline = client.pipeline()
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
pipeline.del(`openai_account_sessions:${accountId}`)
await pipeline.exec()
}
logger.info(`Deleted OpenAI account: ${accountId}`)
@@ -746,12 +666,18 @@ async function deleteAccount(accountId) {
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
const _client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex(
'openai:account:index',
`${OPENAI_ACCOUNT_KEY_PREFIX}*`,
/^openai:account:(.+)$/
)
const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
const accounts = []
const dataList = await redisClient.batchHgetallChunked(keys)
for (const key of keys) {
const accountData = await client.hgetall(key)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) {
const codexUsage = buildCodexUsageSnapshot(accountData)
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期
account.id
)
// 反向索引accountId -> sessionHash用于删除账户时快速清理
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
await client.expire(`openai_account_sessions:${account.id}`, 3600)
}
return account
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期
selectedAccount.id
)
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
}
return selectedAccount
@@ -1278,6 +1209,5 @@ module.exports = {
updateCodexUsageSnapshot,
encrypt,
decrypt,
generateEncryptionKey,
decryptCache // 暴露缓存对象以便测试和监控
encryptor // 暴露加密器以便测试和监控
}

View File

@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
// 保存到 Redis
await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
return {
...accountData,
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
// 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 从索引中移除
await redis.removeFromIndex('openai_responses_account:index', accountId)
// 删除账户数据
await client.del(key)
@@ -191,71 +194,48 @@ class OpenAIResponsesAccountService {
// 获取所有账户
async getAllAccounts(includeInactive = false) {
const client = redis.getClientSafe()
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
// 使用索引获取所有账户ID
const accountIds = await redis.getAllIdsByIndex(
'openai_responses_account:index',
`${this.ACCOUNT_KEY_PREFIX}*`,
/^openai_responses_account:(.+)$/
)
if (accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
// Pipeline 批量查询所有账户数据
const pipeline = client.pipeline()
keys.forEach((key) => pipeline.hgetall(key))
const results = await pipeline.exec()
const accounts = []
results.forEach(([err, accountData]) => {
if (err || !accountData || !accountData.id) {
return
}
for (const accountId of accountIds) {
const account = await this.getAccount(accountId)
if (account) {
// 过滤非活跃账户
if (includeInactive || account.isActive === 'true') {
// 隐藏敏感信息
account.apiKey = '***'
// 获取限流状态信息与普通OpenAI账号保持一致的格式
const rateLimitInfo = this._getRateLimitInfo(account)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
account.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
rateLimitedAt: account.rateLimitedAt || null,
minutesRemaining: rateLimitInfo.remainingMinutes || 0
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
if (!includeInactive && accountData.isActive !== 'true') {
return
}
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
account.schedulable = account.schedulable !== 'false'
// 转换 isActive 字段为布尔值
account.isActive = account.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
account.expiresAt = account.subscriptionExpiresAt || null
account.platform = account.platform || 'openai-responses'
accounts.push(account)
}
}
}
// 直接从 Redis 获取所有账户(包括非共享账户)
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
for (const key of keys) {
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
if (!accountIds.includes(accountId)) {
const accountData = await client.hgetall(key)
if (accountData && accountData.id) {
// 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') {
// 隐藏敏感信息
accountData.apiKey = '***'
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
} catch {
accountData.proxy = null
}
}
// 获取限流状态信息与普通OpenAI账号保持一致的格式
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
@@ -268,20 +248,14 @@ class OpenAIResponsesAccountService {
minutesRemaining: 0
}
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
// 转换字段类型
accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
accountData.expiresAt = accountData.subscriptionExpiresAt || null
accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData)
}
}
}
}
})
return accounts
}
@@ -644,6 +618,9 @@ class OpenAIResponsesAccountService {
// 保存账户数据
await client.hset(key, accountData)
// 添加到索引
await redis.addToIndex('openai_responses_account:index', accountId)
// 添加到共享账户列表
if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)

View File

@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const config = require('../../config/config')
const crypto = require('crypto')
const LRUCache = require('../utils/lruCache')
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
const LAST_USED_AT_THROTTLE_MS = 60000
// 抽取缓存写入 token兼容多种字段命名
function extractCacheCreationTokens(usageData) {
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
this.defaultTimeout = config.requestTimeout || 600000
}
// 节流更新 lastUsedAt
async _throttledUpdateLastUsedAt(accountId) {
const now = Date.now()
const lastUpdate = lastUsedAtThrottle.get(accountId)
if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) {
return // 跳过更新
}
lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS)
await openaiResponsesAccountService.updateAccount(accountId, {
lastUsedAt: new Date().toISOString()
})
}
// 处理请求转发
async handleRequest(req, res, account, apiKeyData) {
let abortController = null
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
return res.status(response.status).json(errorData)
}
// 更新最后使用时间
await openaiResponsesAccountService.updateAccount(account.id, {
lastUsedAt: new Date().toISOString()
})
// 更新最后使用时间(节流)
await this._throttledUpdateLastUsedAt(account.id)
// 处理流式响应
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
@@ -428,7 +446,7 @@ class OpenAIResponsesRelayService {
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const jsonStr = line.slice(6)
const jsonStr = line.slice(5).trim()
if (jsonStr === '[DONE]') {
continue
}
@@ -539,7 +557,8 @@ class OpenAIResponsesRelayService {
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
account.id
account.id,
'openai-responses'
)
logger.info(
@@ -667,7 +686,8 @@ class OpenAIResponsesRelayService {
cacheCreateTokens,
cacheReadTokens,
actualModel,
account.id
account.id,
'openai-responses'
)
logger.info(

View File

@@ -105,7 +105,7 @@ class PricingService {
// 设置文件监听器
this.setupFileWatcher()
logger.success('💰 Pricing service initialized successfully')
logger.success('Pricing service initialized successfully')
} catch (error) {
logger.error('❌ Failed to initialize pricing service:', error)
}
@@ -298,7 +298,7 @@ class PricingService {
this.pricingData = jsonData
this.lastUpdated = new Date()
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`)
logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`)
// 设置或重新设置文件监听器
this.setupFileWatcher()
@@ -762,7 +762,7 @@ class PricingService {
this.lastUpdated = new Date()
const modelCount = Object.keys(jsonData).length
logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`)
logger.success(`Reloaded pricing data for ${modelCount} models from file`)
// 显示一些统计信息
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length

View File

@@ -0,0 +1,698 @@
/**
* 额度卡/时间卡服务
* 管理员生成卡,用户核销,管理员可撤销
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
class QuotaCardService {
constructor() {
this.CARD_PREFIX = 'quota_card:'
this.REDEMPTION_PREFIX = 'redemption:'
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
this.LIMITS_CONFIG_KEY = 'system:quota_card_limits'
}
/**
* 获取额度卡上限配置
*/
async getLimitsConfig() {
try {
const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY)
if (configStr) {
return JSON.parse(configStr)
}
// 没有 Redis 配置时,使用 config.js 默认值
const config = require('../../config/config')
return (
config.quotaCardLimits || {
enabled: true,
maxExpiryDays: 90,
maxTotalCostLimit: 1000
}
)
} catch (error) {
logger.error('❌ Failed to get limits config:', error)
return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 }
}
}
/**
* 保存额度卡上限配置
*/
async saveLimitsConfig(config) {
try {
const parsedDays = parseInt(config.maxExpiryDays)
const parsedCost = parseFloat(config.maxTotalCostLimit)
const newConfig = {
enabled: config.enabled !== false,
maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays,
maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost,
updatedAt: new Date().toISOString()
}
await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig))
logger.info('✅ Quota card limits config saved')
return newConfig
} catch (error) {
logger.error('❌ Failed to save limits config:', error)
throw error
}
}
/**
* 生成卡号16位格式CC_XXXX_XXXX_XXXX
*/
_generateCardCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
let code = ''
for (let i = 0; i < 12; i++) {
code += chars.charAt(crypto.randomInt(chars.length))
}
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
}
/**
* 创建额度卡/时间卡
* @param {Object} options - 卡配置
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
* @param {number} options.quotaAmount - CC 额度数量quota/combo 类型必填)
* @param {number} options.timeAmount - 时间数量time/combo 类型必填)
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
* @param {string} options.expiresAt - 卡本身的有效期(可选)
* @param {string} options.note - 备注
* @param {string} options.createdBy - 创建者 ID
* @returns {Object} 创建的卡信息
*/
async createCard(options = {}) {
try {
const {
type = 'quota',
quotaAmount = 0,
timeAmount = 0,
timeUnit = 'days',
expiresAt = null,
note = '',
createdBy = 'admin'
} = options
// 验证
if (!['quota', 'time', 'combo'].includes(type)) {
throw new Error('Invalid card type')
}
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
throw new Error('quotaAmount is required for quota/combo cards')
}
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
throw new Error('timeAmount is required for time/combo cards')
}
const cardId = uuidv4()
const cardCode = this._generateCardCode()
const cardData = {
id: cardId,
code: cardCode,
type,
quotaAmount: String(quotaAmount || 0),
timeAmount: String(timeAmount || 0),
timeUnit: timeUnit || 'days',
status: 'unused', // unused | redeemed | revoked | expired
createdBy,
createdAt: new Date().toISOString(),
expiresAt: expiresAt || '',
note: note || '',
// 核销信息
redeemedBy: '',
redeemedByUsername: '',
redeemedApiKeyId: '',
redeemedApiKeyName: '',
redeemedAt: '',
// 撤销信息
revokedAt: '',
revokedBy: '',
revokeReason: ''
}
// 保存卡数据
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
// 建立卡号到 ID 的映射(用于快速查找)
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
// 添加到卡列表索引
await redis.client.sadd('quota_cards:all', cardId)
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
return {
id: cardId,
code: cardCode,
type,
quotaAmount: parseFloat(quotaAmount || 0),
timeAmount: parseInt(timeAmount || 0),
timeUnit,
status: 'unused',
createdBy,
createdAt: cardData.createdAt,
expiresAt: cardData.expiresAt,
note
}
} catch (error) {
logger.error('❌ Failed to create card:', error)
throw error
}
}
/**
* 批量创建卡
* @param {Object} options - 卡配置
* @param {number} count - 创建数量
* @returns {Array} 创建的卡列表
*/
async createCardsBatch(options = {}, count = 1) {
const cards = []
for (let i = 0; i < count; i++) {
const card = await this.createCard(options)
cards.push(card)
}
logger.success(`🎫 Batch created ${count} cards`)
return cards
}
/**
* 通过卡号获取卡信息
*/
async getCardByCode(code) {
try {
const cardId = await redis.client.get(`quota_card_code:${code}`)
if (!cardId) {
return null
}
return await this.getCardById(cardId)
} catch (error) {
logger.error('❌ Failed to get card by code:', error)
return null
}
}
/**
* 通过 ID 获取卡信息
*/
async getCardById(cardId) {
try {
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
if (!cardData || Object.keys(cardData).length === 0) {
return null
}
return {
id: cardData.id,
code: cardData.code,
type: cardData.type,
quotaAmount: parseFloat(cardData.quotaAmount || 0),
timeAmount: parseInt(cardData.timeAmount || 0),
timeUnit: cardData.timeUnit,
status: cardData.status,
createdBy: cardData.createdBy,
createdAt: cardData.createdAt,
expiresAt: cardData.expiresAt,
note: cardData.note,
redeemedBy: cardData.redeemedBy,
redeemedByUsername: cardData.redeemedByUsername,
redeemedApiKeyId: cardData.redeemedApiKeyId,
redeemedApiKeyName: cardData.redeemedApiKeyName,
redeemedAt: cardData.redeemedAt,
revokedAt: cardData.revokedAt,
revokedBy: cardData.revokedBy,
revokeReason: cardData.revokeReason
}
} catch (error) {
logger.error('❌ Failed to get card:', error)
return null
}
}
/**
* 获取所有卡列表
* @param {Object} options - 查询选项
* @param {string} options.status - 按状态筛选
* @param {number} options.limit - 限制数量
* @param {number} options.offset - 偏移量
*/
async getAllCards(options = {}) {
try {
const { status, limit = 100, offset = 0 } = options
let cardIds
if (status) {
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
} else {
cardIds = await redis.client.smembers('quota_cards:all')
}
// 排序(按创建时间倒序)
const cards = []
for (const cardId of cardIds) {
const card = await this.getCardById(cardId)
if (card) {
cards.push(card)
}
}
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
// 分页
const total = cards.length
const paginatedCards = cards.slice(offset, offset + limit)
return {
cards: paginatedCards,
total,
limit,
offset
}
} catch (error) {
logger.error('❌ Failed to get all cards:', error)
return { cards: [], total: 0, limit: 100, offset: 0 }
}
}
/**
* 核销卡
* @param {string} code - 卡号
* @param {string} apiKeyId - 目标 API Key ID
* @param {string} userId - 核销用户 ID
* @param {string} username - 核销用户名
* @returns {Object} 核销结果
*/
async redeemCard(code, apiKeyId, userId, username = '') {
try {
// 获取卡信息
const card = await this.getCardByCode(code)
if (!card) {
throw new Error('卡号不存在')
}
// 检查卡状态
if (card.status !== 'unused') {
const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' }
throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`)
}
// 检查卡是否过期
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
// 更新卡状态为过期
await this._updateCardStatus(card.id, 'expired')
throw new Error('卡片已过期')
}
// 获取 API Key 信息
const apiKeyService = require('./apiKeyService')
const keyData = await redis.getApiKey(apiKeyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API Key 不存在')
}
// 获取上限配置
const limits = await this.getLimitsConfig()
// 执行核销
const redemptionId = uuidv4()
const now = new Date().toISOString()
// 记录核销前状态
const beforeLimit = parseFloat(keyData.totalCostLimit || 0)
const beforeExpiry = keyData.expiresAt || ''
// 应用卡效果
let afterLimit = beforeLimit
let afterExpiry = beforeExpiry
let quotaAdded = 0
let timeAdded = 0
let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days
const warnings = [] // 截断警告信息
if (card.type === 'quota' || card.type === 'combo') {
let amountToAdd = card.quotaAmount
// 上限保护:检查是否超过最大额度限制
if (limits.enabled && limits.maxTotalCostLimit > 0) {
const maxAllowed = limits.maxTotalCostLimit - beforeLimit
if (amountToAdd > maxAllowed) {
amountToAdd = Math.max(0, maxAllowed)
warnings.push(
`额度已达上限,本次仅增加 ${amountToAdd} CC原卡面 ${card.quotaAmount} CC`
)
logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`)
}
}
if (amountToAdd > 0) {
const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd)
afterLimit = result.newTotalCostLimit
quotaAdded = amountToAdd
}
}
if (card.type === 'time' || card.type === 'combo') {
// 计算新的过期时间
let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date()
if (baseDate < new Date()) {
baseDate = new Date()
}
let newExpiry = new Date(baseDate)
switch (card.timeUnit) {
case 'hours':
newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000)
break
case 'days':
newExpiry.setDate(newExpiry.getDate() + card.timeAmount)
break
case 'months':
newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount)
break
}
// 上限保护:检查是否超过最大有效期
if (limits.enabled && limits.maxExpiryDays > 0) {
const maxExpiry = new Date()
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
if (newExpiry > maxExpiry) {
newExpiry = maxExpiry
warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`)
logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`)
}
}
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
// 如果有上限保护,使用截断后的时间
if (limits.enabled && limits.maxExpiryDays > 0) {
const maxExpiry = new Date()
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
if (new Date(result.newExpiresAt) > maxExpiry) {
await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString())
afterExpiry = maxExpiry.toISOString()
// 计算实际增加的天数,截断时统一用天
const actualDays = Math.max(
0,
Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))
)
timeAdded = actualDays
actualTimeUnit = 'days'
} else {
afterExpiry = result.newExpiresAt
timeAdded = card.timeAmount
}
} else {
afterExpiry = result.newExpiresAt
timeAdded = card.timeAmount
}
}
// 更新卡状态
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
status: 'redeemed',
redeemedBy: userId,
redeemedByUsername: username,
redeemedApiKeyId: apiKeyId,
redeemedApiKeyName: keyData.name || '',
redeemedAt: now
})
// 更新状态索引
await redis.client.srem(`quota_cards:status:unused`, card.id)
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
// 创建核销记录
const redemptionData = {
id: redemptionId,
cardId: card.id,
cardCode: card.code,
cardType: card.type,
userId,
username,
apiKeyId,
apiKeyName: keyData.name || '',
quotaAdded: String(quotaAdded),
timeAdded: String(timeAdded),
timeUnit: actualTimeUnit,
beforeLimit: String(beforeLimit),
afterLimit: String(afterLimit),
beforeExpiry,
afterExpiry,
timestamp: now,
status: 'active' // active | revoked
}
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
// 添加到核销记录索引
await redis.client.sadd('redemptions:all', redemptionId)
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
return {
success: true,
warnings,
redemptionId,
cardCode: card.code,
cardType: card.type,
quotaAdded,
timeAdded,
timeUnit: actualTimeUnit,
beforeLimit,
afterLimit,
beforeExpiry,
afterExpiry
}
} catch (error) {
logger.error('❌ Failed to redeem card:', error)
throw error
}
}
/**
* 撤销核销
* @param {string} redemptionId - 核销记录 ID
* @param {string} revokedBy - 撤销者 ID
* @param {string} reason - 撤销原因
* @returns {Object} 撤销结果
*/
async revokeRedemption(redemptionId, revokedBy, reason = '') {
try {
// 获取核销记录
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
if (!redemptionData || Object.keys(redemptionData).length === 0) {
throw new Error('Redemption record not found')
}
if (redemptionData.status !== 'active') {
throw new Error('Redemption is already revoked')
}
const apiKeyService = require('./apiKeyService')
const now = new Date().toISOString()
// 撤销效果
let actualDeducted = 0
if (parseFloat(redemptionData.quotaAdded) > 0) {
const result = await apiKeyService.deductTotalCostLimit(
redemptionData.apiKeyId,
parseFloat(redemptionData.quotaAdded)
)
;({ actualDeducted } = result)
}
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
// 如果需要回退时间,可以在这里添加逻辑
// 更新核销记录状态
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
status: 'revoked',
revokedAt: now,
revokedBy,
revokeReason: reason,
actualDeducted: String(actualDeducted)
})
// 更新卡状态
const { cardId } = redemptionData
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
status: 'revoked',
revokedAt: now,
revokedBy,
revokeReason: reason
})
// 更新状态索引
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
return {
success: true,
redemptionId,
cardCode: redemptionData.cardCode,
actualDeducted,
reason
}
} catch (error) {
logger.error('❌ Failed to revoke redemption:', error)
throw error
}
}
/**
* 获取核销记录
* @param {Object} options - 查询选项
* @param {string} options.userId - 按用户筛选
* @param {string} options.apiKeyId - 按 API Key 筛选
* @param {number} options.limit - 限制数量
* @param {number} options.offset - 偏移量
*/
async getRedemptions(options = {}) {
try {
const { userId, apiKeyId, limit = 100, offset = 0 } = options
let redemptionIds
if (userId) {
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
} else if (apiKeyId) {
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
} else {
redemptionIds = await redis.client.smembers('redemptions:all')
}
const redemptions = []
for (const id of redemptionIds) {
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
if (data && Object.keys(data).length > 0) {
redemptions.push({
id: data.id,
cardId: data.cardId,
cardCode: data.cardCode,
cardType: data.cardType,
userId: data.userId,
username: data.username,
apiKeyId: data.apiKeyId,
apiKeyName: data.apiKeyName,
quotaAdded: parseFloat(data.quotaAdded || 0),
timeAdded: parseInt(data.timeAdded || 0),
timeUnit: data.timeUnit,
beforeLimit: parseFloat(data.beforeLimit || 0),
afterLimit: parseFloat(data.afterLimit || 0),
beforeExpiry: data.beforeExpiry,
afterExpiry: data.afterExpiry,
timestamp: data.timestamp,
status: data.status,
revokedAt: data.revokedAt,
revokedBy: data.revokedBy,
revokeReason: data.revokeReason,
actualDeducted: parseFloat(data.actualDeducted || 0)
})
}
}
// 排序(按时间倒序)
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
// 分页
const total = redemptions.length
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
return {
redemptions: paginatedRedemptions,
total,
limit,
offset
}
} catch (error) {
logger.error('❌ Failed to get redemptions:', error)
return { redemptions: [], total: 0, limit: 100, offset: 0 }
}
}
/**
* 删除未使用的卡
*/
async deleteCard(cardId) {
try {
const card = await this.getCardById(cardId)
if (!card) {
throw new Error('Card not found')
}
if (card.status !== 'unused') {
throw new Error('Only unused cards can be deleted')
}
// 删除卡数据
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
await redis.client.del(`quota_card_code:${card.code}`)
// 从索引中移除
await redis.client.srem('quota_cards:all', cardId)
await redis.client.srem(`quota_cards:status:unused`, cardId)
logger.success(`🗑️ Deleted card ${card.code}`)
return { success: true, cardCode: card.code }
} catch (error) {
logger.error('❌ Failed to delete card:', error)
throw error
}
}
/**
* 更新卡状态(内部方法)
*/
async _updateCardStatus(cardId, newStatus) {
const card = await this.getCardById(cardId)
if (!card) {
return
}
const oldStatus = card.status
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
// 更新状态索引
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
}
/**
* 获取卡统计信息
*/
async getCardStats() {
try {
const [unused, redeemed, revoked, expired] = await Promise.all([
redis.client.scard('quota_cards:status:unused'),
redis.client.scard('quota_cards:status:redeemed'),
redis.client.scard('quota_cards:status:revoked'),
redis.client.scard('quota_cards:status:expired')
])
return {
total: unused + redeemed + revoked + expired,
unused,
redeemed,
revoked,
expired
}
} catch (error) {
logger.error('❌ Failed to get card stats:', error)
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
}
}
}
module.exports = new QuotaCardService()

View File

@@ -72,7 +72,8 @@ class RateLimitCleanupService {
const results = {
openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }
claudeConsole: { checked: 0, cleared: 0, errors: [] },
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
}
// 清理 OpenAI 账号
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
// 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
// 主动刷新等待重置的 Claude 账户 Token防止 5小时/7天 等待期间 Token 过期)
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked
const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
const duration = Date.now() - startTime
if (totalCleared > 0) {
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
logger.info(
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
)
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
)
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
logger.info(
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
)
}
// 发送 webhook 恢复通知
if (this.clearedAccounts.length > 0) {
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
const allErrors = [
...results.openai.errors,
...results.claude.errors,
...results.claudeConsole.errors
...results.claudeConsole.errors,
...results.tokenRefresh.errors
]
if (allErrors.length > 0) {
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
}
}
/**
* 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期)
* 仅对因限流/配额限制而等待重置的账户执行刷新:
* - 429 限流账户rateLimitAutoStopped=true
* - 5小时限制自动停止账户fiveHourAutoStopped=true
* 不处理错误状态账户error/temp_error
*/
async proactiveRefreshClaudeTokens(result) {
try {
const redis = require('../models/redis')
const accounts = await redis.getAllClaudeAccounts()
const now = Date.now()
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
for (const account of accounts) {
// 1. 必须激活
if (account.isActive !== 'true') {
continue
}
// 2. 必须有 refreshToken
if (!account.refreshToken) {
continue
}
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
// 正常调度的账户会在请求时自动刷新,无需主动刷新
// 错误状态账户的 Token 可能已失效,刷新也会失败
const isWaitingForReset =
account.rateLimitAutoStopped === 'true' || // 429 限流
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
if (!isWaitingForReset) {
continue
}
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
if (now - lastRefreshAt < recentRefreshMs) {
continue
}
// 5. 检查 Token 是否即将过期30分钟内
const expiresAt = parseInt(account.expiresAt)
if (expiresAt && now < expiresAt - refreshAheadMs) {
continue
}
// 符合条件,执行刷新
result.checked++
try {
await claudeAccountService.refreshAccountToken(account.id)
result.refreshed++
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
}
}
} catch (error) {
logger.error('Failed to proactively refresh Claude tokens:', error)
result.errors.push({ error: error.message })
}
}
/**
* 手动触发一次清理(供 API 或 CLI 调用)
*/

View File

@@ -0,0 +1,259 @@
/**
* 服务倍率配置服务
* 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0
* 用于聚合 Key 的虚拟额度计算
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
class ServiceRatesService {
constructor() {
this.CONFIG_KEY = 'system:service_rates'
this.cachedRates = null
this.cacheExpiry = 0
this.CACHE_TTL = 60 * 1000 // 1分钟缓存
}
/**
* 获取默认倍率配置
*/
getDefaultRates() {
return {
baseService: 'claude',
rates: {
claude: 1.0, // 基准1 USD = 1 CC额度
codex: 1.0,
gemini: 1.0,
droid: 1.0,
bedrock: 1.0,
azure: 1.0,
ccr: 1.0
},
updatedAt: null,
updatedBy: null
}
}
/**
* 获取倍率配置(带缓存)
*/
async getRates() {
try {
// 检查缓存
if (this.cachedRates && Date.now() < this.cacheExpiry) {
return this.cachedRates
}
const configStr = await redis.client.get(this.CONFIG_KEY)
if (!configStr) {
const defaultRates = this.getDefaultRates()
this.cachedRates = defaultRates
this.cacheExpiry = Date.now() + this.CACHE_TTL
return defaultRates
}
const storedConfig = JSON.parse(configStr)
// 合并默认值,确保新增服务有默认倍率
const defaultRates = this.getDefaultRates()
storedConfig.rates = {
...defaultRates.rates,
...storedConfig.rates
}
this.cachedRates = storedConfig
this.cacheExpiry = Date.now() + this.CACHE_TTL
return storedConfig
} catch (error) {
logger.error('获取服务倍率配置失败:', error)
return this.getDefaultRates()
}
}
/**
* 保存倍率配置
*/
async saveRates(config, updatedBy = 'admin') {
try {
const defaultRates = this.getDefaultRates()
// 验证配置
this.validateRates(config)
const newConfig = {
baseService: config.baseService || defaultRates.baseService,
rates: {
...defaultRates.rates,
...config.rates
},
updatedAt: new Date().toISOString(),
updatedBy
}
await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig))
// 清除缓存
this.cachedRates = null
this.cacheExpiry = 0
logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`)
return newConfig
} catch (error) {
logger.error('保存服务倍率配置失败:', error)
throw error
}
}
/**
* 验证倍率配置
*/
validateRates(config) {
if (!config || typeof config !== 'object') {
throw new Error('无效的配置格式')
}
if (config.rates) {
for (const [service, rate] of Object.entries(config.rates)) {
if (typeof rate !== 'number' || rate <= 0) {
throw new Error(`服务 ${service} 的倍率必须是正数`)
}
}
}
}
/**
* 获取单个服务的倍率
*/
async getServiceRate(service) {
const config = await this.getRates()
return config.rates[service] || 1.0
}
/**
* 计算消费的 CC 额度
* @param {number} costUSD - 真实成本USD
* @param {string} service - 服务类型
* @returns {number} CC 额度消耗
*/
async calculateQuotaConsumption(costUSD, service) {
const rate = await this.getServiceRate(service)
return costUSD * rate
}
/**
* 根据模型名称获取服务类型
*/
getServiceFromModel(model) {
if (!model) {
return 'claude'
}
const modelLower = model.toLowerCase()
// Claude 系列
if (
modelLower.includes('claude') ||
modelLower.includes('anthropic') ||
modelLower.includes('opus') ||
modelLower.includes('sonnet') ||
modelLower.includes('haiku')
) {
return 'claude'
}
// OpenAI / Codex 系列
if (
modelLower.includes('gpt') ||
modelLower.includes('o1') ||
modelLower.includes('o3') ||
modelLower.includes('o4') ||
modelLower.includes('codex') ||
modelLower.includes('davinci') ||
modelLower.includes('curie') ||
modelLower.includes('babbage') ||
modelLower.includes('ada')
) {
return 'codex'
}
// Gemini 系列
if (
modelLower.includes('gemini') ||
modelLower.includes('palm') ||
modelLower.includes('bard')
) {
return 'gemini'
}
// Droid 系列
if (modelLower.includes('droid') || modelLower.includes('factory')) {
return 'droid'
}
// Bedrock 系列(通常带有 aws 或特定前缀)
if (
modelLower.includes('bedrock') ||
modelLower.includes('amazon') ||
modelLower.includes('titan')
) {
return 'bedrock'
}
// Azure 系列
if (modelLower.includes('azure')) {
return 'azure'
}
// 默认返回 claude
return 'claude'
}
/**
* 根据账户类型获取服务类型(优先级高于模型推断)
*/
getServiceFromAccountType(accountType) {
if (!accountType) {
return null
}
const mapping = {
claude: 'claude',
'claude-official': 'claude',
'claude-console': 'claude',
ccr: 'ccr',
bedrock: 'bedrock',
gemini: 'gemini',
'openai-responses': 'codex',
openai: 'codex',
azure: 'azure',
'azure-openai': 'azure',
droid: 'droid'
}
return mapping[accountType] || null
}
/**
* 获取服务类型(优先 accountType后备 model
*/
getService(accountType, model) {
return this.getServiceFromAccountType(accountType) || this.getServiceFromModel(model)
}
/**
* 获取所有支持的服务列表
*/
async getAvailableServices() {
const config = await this.getRates()
return Object.keys(config.rates)
}
/**
* 清除缓存(用于测试或强制刷新)
*/
clearCache() {
this.cachedRates = null
this.cacheExpiry = 0
}
}
module.exports = new ServiceRatesService()

Some files were not shown because too many files have changed in this diff Show More