Compare commits

...

112 Commits

Author SHA1 Message Date
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
github-actions[bot]
698f3d7daa chore: sync VERSION file with release v1.1.227 [skip ci] 2025-12-08 08:10:44 +00:00
shaw
5af5e55d80 chore: trigger release [force release]
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 16:10:09 +08:00
shaw
5a18f54abd Merge branch 'dev' 2025-12-08 16:08:10 +08:00
Wesley Liddick
6d3b51510a Merge pull request #779 from sususu98/fix/explore-agent-prompt-template [skip ci]
fix: 添加 Explore agent 系统提示词模板并优化日志级别
2025-12-08 03:06:55 -05:00
shaw
c79fdc4d71 feat: 增加Claude会话强制绑定 2025-12-08 16:06:23 +08:00
shaw
659072075d fix: 统一格式化claude参数传递 2025-12-08 14:23:13 +08:00
sususu
cf93128a96 fix: format 2025-12-08 11:01:10 +08:00
sususu
909b5ad37f fix: 添加 Explore agent 系统提示词模板并优化日志级别
- 添加 exploreAgentSystemPrompt 模板用于匹配 Claude Code Explore 子代理
- 将详细的 prompt 内容从 error 日志移至 warn 级别,减少日志噪音

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:45:46 +08:00
shaw
bab7073822 fix: 修复api-keys页面窗口费率显示问题 2025-12-08 09:58:54 +08:00
github-actions[bot]
0035f8cb4f chore: sync VERSION file with release v1.1.226 [skip ci] 2025-12-08 01:45:46 +00:00
shaw
d49cc0cec8 fix: 修复api-keys页面窗口费率显示问题 2025-12-08 09:45:19 +08:00
github-actions[bot]
c4d6ab97f2 chore: sync VERSION file with release v1.1.225 [skip ci] 2025-12-08 00:26:14 +00:00
Wesley Liddick
7053d5f1ac Merge pull request #778 from miraserver/fix/droid-user-agent-and-provider
[fix] Droid: dynamic x-api-provider and custom User-Agent support
2025-12-07 19:25:56 -05:00
John Doe
24796fc889 fix: format droidAccountService.js with Prettier 2025-12-07 21:14:42 +03:00
John Doe
201d95c84e [fix] Droid: dynamic x-api-provider and custom User-Agent support
- Dynamic x-api-provider selection for OpenAI endpoint based on model
  - Models with '-max' suffix use 'openai' provider
  - Other models use 'azure_openai' provider
  - Fixes gpt-5.1-codex-max model compatibility issue

- Update default User-Agent to factory-cli/0.32.1

- Add custom User-Agent field for Droid accounts
  - Backend: userAgent field in createAccount and updateAccount
  - Frontend: User-Agent input in account creation/edit UI
  - Supports all Droid auth modes: OAuth, Manual, API Key

This resolves the issue where gpt-5.1-codex-max failed with 'Azure OpenAI only supports...' error due to incorrect provider header.
2025-12-07 21:08:48 +03:00
Wesley Liddick
b978d864e3 Merge pull request #776 from miraserver/fix/droid-openai-cache-tokens [skip ci]
[fix] Add cache token capture for Droid OpenAI endpoint
2025-12-06 22:46:54 -05:00
Wesley Liddick
175c041e5a Merge pull request #774 from mrlitong/main [skip ci]
chore(docker): optimize build cache and install flow
2025-12-06 22:45:12 -05:00
Wesley Liddick
b441506199 Merge branch 'main' into main 2025-12-06 22:44:44 -05:00
Wesley Liddick
eb2341fb16 Merge pull request #771 from DaydreamCoding/patch-2 [skip ci]
Update model filtering to use blacklist approach
2025-12-06 22:43:52 -05:00
Wesley Liddick
e89e2964e7 Merge pull request #773 from DaydreamCoding/feature/concurrency [skip ci]
feat(concurrencyManagement): implement concurrency status management …
2025-12-06 22:43:29 -05:00
John Doe
b3e27e9f15 [fix] Add cache token capture for Droid OpenAI endpoint
The _parseOpenAIUsageFromSSE method was not capturing cache-related
tokens (cache_read_input_tokens, cache_creation_input_tokens) from
OpenAI format responses, while the Anthropic endpoint correctly
captured them.

This fix adds extraction of:
- cached_tokens from input_tokens_details
- cache_creation_input_tokens from both input_tokens_details and
  top-level usage object

This ensures proper cache statistics tracking and cost calculation
for OpenAI models (like GPT-5/Codex) when using the Droid provider.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 23:00:54 +03:00
github-actions[bot]
d0b397b45a chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-06 11:46:10 +00:00
litongtongxue@gmail.com
195e42e0a5 chore(docker): optimize build cache and install flow 2025-12-06 03:45:43 -08:00
github-actions[bot]
ebecee4c6f chore: sync VERSION file with release v1.1.224 [skip ci] 2025-12-06 11:00:48 +00:00
Wesley Liddick
0607322cc7 Merge pull request #765 from SunSeekerX/feature_key_model_filter
feat(api-keys): 添加模型筛选功能
2025-12-06 06:00:32 -05:00
SunSeekerX
0828746281 fix: 修复 ESLint 错误 - if 语句花括号和箭头函数简写 2025-12-06 18:30:44 +08:00
SunSeekerX
e1df90684a fix: 合并冲突 - 保留多选支持并添加暗黑模式样式 2025-12-06 18:28:03 +08:00
DaydreamCoding
f74f77ef65 feat(concurrencyManagement): implement concurrency status management API and enhance concurrency handling in middleware 2025-12-06 17:23:42 +08:00
QTom
b63c3217bc Update model filtering to use blacklist approach
Change model filtering logic to blacklist restricted models.
2025-12-06 14:20:06 +08:00
github-actions[bot]
d81a16b98d chore: sync VERSION file with release v1.1.223 [skip ci] 2025-12-06 03:01:17 +00:00
shaw
30727be92f chore: trigger release [force release] 2025-12-06 11:00:59 +08:00
shaw
b8a6cc627a Merge branch 'lusipad/main' 2025-12-06 10:56:57 +08:00
Wesley Liddick
01c63bf5df Merge pull request #760 from IanShaw027/upstream-pr-account-full [skip ci]
feat: 增强账户管理功能
2025-12-05 21:45:37 -05:00
Wesley Liddick
4317962955 Merge pull request #758 from IanShaw027/upstream-pr-temp-unavailable [skip ci]
feat: 添加上游不稳定错误检测与账户临时不可用机制
2025-12-05 21:44:39 -05:00
Wesley Liddick
b66fd7f655 Merge pull request #766 from atoz03/feature/account-detail-timeline [skip ci]
feat(account): enhance detail timeline & remove redundant entries
2025-12-05 21:43:14 -05:00
Wesley Liddick
ac280ef563 Merge pull request #767 from DaydreamCoding/patch-1 [skip ci]
Refactor model restriction checks to use blacklist
2025-12-05 21:39:56 -05:00
github-actions[bot]
c70070d912 chore: sync VERSION file with release v1.1.227 [skip ci] 2025-12-05 20:54:53 +00:00
lusipad
849d8e047b docs: translate isProAccount function comments to English
- Change function description from Chinese to English
- Translate inline comments (API priority, local config)
- Keep function logic unchanged

This completes the full English comment translation for all modified files.
2025-12-06 04:54:41 +08:00
github-actions[bot]
065aa6d35e chore: sync VERSION file with release v1.1.226 [skip ci] 2025-12-05 20:45:07 +00:00
lusipad
10a1d61427 docs: translate remaining Chinese comments in claudeAccountService.js
- Filter Opus models based on account type and model version
- Free account: does not support any Opus model
- Pro account: only supports Opus 4.5+
- Max account: supports all Opus versions
- Account without subscription info defaults to supported

All logic unchanged, only comment translation.
2025-12-06 04:44:52 +08:00
github-actions[bot]
cfdcc97cc7 chore: sync VERSION file with release v1.1.225 [skip ci] 2025-12-05 20:43:32 +00:00
lusipad
ea053c6a16 docs: convert Chinese comments to English
- Change VERSION判断逻辑 to VERSION LOGIC
- Change ACCOUNT TYPE判断逻辑 to ACCOUNT TYPE LOGIC
- Translate remaining Chinese phrases to English
- Keep all logic unchanged, only translation
2025-12-06 04:43:11 +08:00
github-actions[bot]
84a8fdeaba chore: sync VERSION file with release v1.1.224 [skip ci] 2025-12-05 20:26:31 +00:00
lusipad
c1c941aa4c fix(opus): fix PR#762 review issues and add maintenance comments
- Fix regex to support 2-digit minor versions (e.g., opus-4-10)
- Prevent matching 8-digit dates as minor version numbers
- Unify English comments for consistency across codebase
- Extract isProAccount() helper to eliminate code duplication
- Add detailed version logic comments for future maintenance

Changes:
- VERSION LOGIC: Opus 4.5+ returns true (Pro eligible), <4.5 returns false (Max only)
- ACCOUNT RESTRICTIONS: Free=no Opus, Pro=Opus 4.5+, Max=all Opus versions
- REGEX FIX: (\d{1,2}) limits minor version to 1-2 digits, avoiding date confusion

Test: All 21 tests pass
Format: Prettier validated
2025-12-06 04:26:11 +08:00
atoz03
f78e376dea fix:该文件的代码格式不符合 Prettier 规范 2025-12-05 22:29:45 +08:00
github-actions[bot]
530d38e4a4 chore: sync VERSION file with release v1.1.223 [skip ci] 2025-12-05 14:16:07 +00:00
lusipad
0bf7bfae04 Merge branch 'Wei-Shaw:main' into main 2025-12-05 22:15:54 +08:00
atoz03
fbb660138c fix:调整去重策略
- 调整去重策略(src/routes/admin/usageStats.js):账户筛选改为按 accountId 聚合记录所有出现的 accountType,构建 options 时依次按历史类型解析账号,失败再全量回退,无法解析也保留为筛选项并带 rawTypes,避免渠道改名/删除导致选项被“去
    重”丢失。
  - 解析兜底(src/routes/admin/usageStats.js):resolveAccountInfo 在传入未知类型或过滤后为空时回退尝试全部服务,减轻渠道改名解析不到的问题。
2025-12-05 19:19:52 +08:00
QTom
9c970fda3b Refactor model restriction checks to use blacklist 2025-12-05 17:06:21 +08:00
atoz03
bfa3f528a2 fix:优化了dropdown的弹窗
- ActionDropdown.vue:位置计算调整,优先向右展开并增加 8px 间距,减少遮挡左侧内容;下拉全局互斥仍保留。
  - 账户页面:列表下拉/卡片已无“请求时间线”入口,只保留详情弹窗顶部按钮。
2025-12-05 15:11:12 +08:00
atoz03
9b0d0bee96 fix: 账户时间线入口与路由修复
- 移除账户列表下拉/卡片的时间线入口,仅保留详情弹窗顶部按钮
  - ActionDropdown 全局互斥,避免多菜单堆叠
  - 账户筛选去重,避免“未知渠道”重复泄露
2025-12-05 14:57:34 +08:00
atoz03
ff30bfab82 feat: 账户时间线详情页与接口完善
- 后端新增 /admin/accounts/:accountId/usage-records 接口,支持按账户聚合多 Key 记录并分页筛选、汇总统计
  - 修复 API Key 时间线账户筛选跳过已删除账号,补充账户/Key 辅助解析
  - 前端新增 AccountUsageRecordsView、路由及账户列表“时间线”入口,支持模型/API Key 筛选与 CSV 导出
  - 补装 prettier-plugin-tailwindcss 并完成相关文件格式化
2025-12-05 14:23:25 +08:00
SunSeekerX
93497cc13c fix: 修复 ESLint vue/attributes-order 属性顺序问题 2025-12-05 13:49:19 +08:00
SunSeekerX
2429bad2b7 feat(api-keys): 添加模型筛选功能 2025-12-05 13:44:09 +08:00
IanShaw027
a03753030c fix: CustomDropdown组件支持层级结构显示
- 添加动态padding支持indent属性(每级缩进16px)
- 添加isGroup属性支持,分组项显示为粗体带背景
- 修复暗黑模式下选中图标颜色
- 支持二级平台分类的视觉层级展示
2025-12-05 12:47:20 +08:00
github-actions[bot]
94aca4dc22 chore: sync VERSION file with release v1.1.222 [skip ci] 2025-12-05 01:06:39 +00:00
shaw
6bfef2525a Merge PR #753: feat: 新增 API Key
请求时间线接口与管理端详情页面
2025-12-05 09:03:53 +08:00
github-actions[bot]
5a636a36f6 chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-05 00:38:43 +00:00
shaw
b61e1062bf fix: 修复create_proxyagent调用方式 2025-12-05 08:37:43 +08:00
lusipad
6ab91c0c75 chore: revert version 2025-12-05 08:25:42 +08:00
github-actions[bot]
675e7b9111 chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-05 00:15:42 +00:00
lusipad
f82db11e7d Merge pull request #1 from lusipad/feature/opus-45-pro-support
feat(account): 支持 Pro 账号使用 Opus 4.5+ 模型
2025-12-05 08:15:32 +08:00
lusipad
06b18b7186 refactor: extract isProAccount helper for Pro account detection
Extract duplicate Pro account detection logic into a reusable helper
function that handles both API-returned (hasClaudePro) and locally
configured (accountType) data sources.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 08:12:51 +08:00
lusipad
12cb841a64 refactor: address Copilot review feedback
- Import isOpus45OrNewer from modelHelper instead of duplicating code
- Remove invalid 'claude_free' check (only 'free' is used in practice)
2025-12-05 07:56:53 +08:00
lusipad
dc868522cf fix: apply ESLint curly rule and remove useless escape chars 2025-12-05 07:49:55 +08:00
lusipad
b1dc27b5d7 style: format test-official-models.js with Prettier 2025-12-05 07:43:15 +08:00
lusipad
b94bd2b822 feat(account): 支持 Pro 账号使用 Opus 4.5+ 模型
Opus 4.5 已对 Claude Pro 用户开放,调整账户模型限制逻辑:

- Pro 账号:支持 Opus 4.5+,不支持历史版本 (3.x/4.0/4.1)
- Free 账号:不支持任何 Opus 模型
- Max 账号:支持所有 Opus 版本

修改内容:
- 新增 isOpus45OrNewer() 函数用于精确识别模型版本
- 更新 claudeAccountService.js 中的账户选择逻辑
- 更新 unifiedClaudeScheduler.js 中的模型支持检查
- 新增测试脚本验证官方模型名称识别

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 07:38:55 +08:00
IanShaw027
827c0f6207 feat: 添加两级平台筛选功能(支持平台分组)
- 添加 platformHierarchy 定义平台层级结构(Claude全部、OpenAI全部、Gemini全部、Droid)
- 添加 platformGroupMap 映射平台组到具体平台
- 添加 platformRequestHandlers 动态处理平台请求
- 将 platformOptions 从 ref 改为 computed 支持缩进显示
- 优化 loadAccounts 使用动态平台加载替代大型 switch 语句
- 新增 getPlatformsForFilter 辅助函数

功能说明:
- 支持选择"Claude(全部)"同时筛选 claude + claude-console + bedrock + ccr
- 支持选择"OpenAI(全部)"同时筛选 openai + openai-responses + azure_openai
- 支持选择"Gemini(全部)"同时筛选 gemini + gemini-api
- 保持向后兼容,仍支持单独选择具体平台
2025-12-05 03:31:13 +08:00
IanShaw027
0b3cf5112b refactor: 移除仪表盘使用记录功能以避免与PR #753重叠
移除了仪表盘中的使用记录展示功能,避免与PR #753的API Key详细使用记录功能重叠:
- 移除DashboardView.vue中的使用记录表格UI及相关函数
- 移除dashboard.js中的/dashboard/usage-records接口
- 保留核心账户管理功能(账户过滤、限流状态、统计模态框等)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
3db268fff7 feat: 完善账户管理和仪表盘功能
- 修改使用记录API路由路径为 /dashboard/usage-records
- 增加对更多账户类型的支持(Bedrock、Azure、Droid、CCR等)
- 修复Codex模型识别逻辑,避免 gpt-5-codex 系列被错误归一化
- 在账户管理页面添加状态过滤器(正常/异常)
- 在账户管理页面添加限流时间过滤器(≤1h/5h/12h/1d)
- 增加账户统计汇总弹窗,按平台分类展示
- 完善仪表盘使用记录展示功能,支持分页加载
- 将 logs1/ 目录添加到 .gitignore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
81971436e6 feat: 在仪表盘添加使用记录展示功能
- 新增后端API端点 /admin/dashboard/usage-records
  - 支持分页查询所有API Key的使用记录
  - 自动关联API Key名称和账户名称
  - 按时间倒序排列(最新的在前)

- 新增仪表盘使用记录表格
  - 显示时间、API Key、账户、模型、输入/输出/缓存创建/缓存读取tokens、成本
  - 智能时间格式化(今天显示时分秒,昨天显示时间)
  - 支持加载更多记录,分页展示
  - 响应式设计,支持暗黑模式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
69a1006f4c feat: 增强账户管理页面的过滤和统计功能
- 新增状态过滤器:支持按正常/异常/全部筛选账户
- 新增限流时间过滤器:支持按1h/5h/12h/1d筛选限流账户
- 新增账户统计弹窗:按平台类型和状态汇总账户数量
- 优化账户列表过滤逻辑,支持组合过滤条件
- 默认状态过滤为'正常',提升用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
4cf1762467 fix: 修复 ESLint curly 规则问题
- 在 if 语句后添加必需的大括号
- 修复 unifiedClaudeScheduler.js (1处)
- 修复 unstableUpstreamHelper.js (2处)
2025-12-05 02:28:30 +08:00
IanShaw027
0d64d40654 feat: 添加上游不稳定错误检测与账户临时不可用机制
## 背景
当上游 API(如 Anthropic、AWS Bedrock 等)出现临时故障时,服务会持续向故障
账户发送请求,导致用户体验下降。需要自动检测上游不稳定状态并临时排除故障账户。

## 改动内容

### 新增 unstableUpstreamHelper.js
- 检测多种上游不稳定错误模式
- 支持环境变量扩展检测规则

### 修改 unifiedClaudeScheduler.js
- 新增 markAccountTemporarilyUnavailable() 方法:标记账户临时不可用
- 新增 isAccountTemporarilyUnavailable() 方法:检查账户是否临时不可用
- 专属账户检查:claude-official、claude-console、bedrock 临时不可用时自动回退到池
- 池账户选择:跳过临时不可用的账户

### 修改 claudeRelayService.js
- _handleServerError() 方法增加临时不可用标记逻辑
- 5xx 错误时自动标记账户临时不可用(5分钟 TTL)

## 检测的状态码

| 分类 | 状态码 | 说明 |
|------|--------|------|
| 服务器错误 | 500-599 | 内部错误、服务不可用等 |
| 超时类 | 408 | 请求超时 |
| 连接类 | 499 | 客户端关闭请求 (Nginx) |
| 网关类 | 502, 503, 504 | 网关错误、服务不可用、网关超时 |
| CDN类 | 522 | Cloudflare 连接超时 |
| 语义类 | error.type = "server_error" | API 级别服务器错误 |

## 环境变量配置

- UNSTABLE_ERROR_TYPES: 额外的错误类型(逗号分隔)
- UNSTABLE_ERROR_KEYWORDS: 错误消息关键词(逗号分隔)

## Redis 键

- temp_unavailable:{accountType}:{accountId} - TTL 300秒
2025-12-05 02:28:30 +08:00
github-actions[bot]
1b18a1226d chore: sync VERSION file with release v1.1.220 [skip ci] 2025-12-04 13:01:54 +00:00
Wesley Liddick
0b2372abab Merge pull request #756 from SunSeekerX/feature_api_disable_switch
feat(account): 新增账户自动防护禁用开关
2025-12-04 08:01:35 -05:00
SunSeekerX
8aca1f9dd1 feat(account): 新增账户自动防护禁用开关
支持 disableAutoProtection 配置项,启用后上游 401/400/429/529 错误不再自动禁用账户
2025-12-04 20:47:12 +08:00
atoz03
95ef04c1a3 fix: 保持仪表盘趋势图非负并纠正小时区间
- 小时粒度请求使用用户选择的起止时间,避免近24小时被截成整天
  - 修正日期展示格式化逻辑,减少时区偏移导致的窗口错位
  - 趋势图 Y 轴(Token/请求数/费用等)强制最小值为 0,防止出现负刻度
2025-12-04 17:05:36 +08:00
atoz03
4919e392a5 feat: 仪表盘日期筛选默认今日并记忆用户偏好 2025-12-04 16:48:11 +08:00
atoz03
354d8da13f feat:已修复详情弹窗位置问题:RecordDetailModal 现在 append-to-body、destroy-on-close,并设定 top="10vh",点击列表底部的“详情”不会被滚动容器截断或浮在页面顶部看不到。 2025-12-04 15:17:48 +08:00
atoz03
3df0c7c650 feat:已修复 ESLint no-shadow 问题:geminiApiAccountService 不再重复声明,改用顶部引入的实例。后端/前端 lint 均通过(npm run lint:check、cd web/admin-spa && npm run lint) 2025-12-04 15:05:09 +08:00
atoz03
6a3dce523b chore: format usage stats route 2025-12-04 15:02:07 +08:00
atoz03
9fe2918a54 feat: keep API key stats modal and add timeline entry point 2025-12-04 14:56:27 +08:00
atoz03
92b30e1924 feat: add API key usage timeline API and admin UI 2025-12-04 14:41:38 +08:00
github-actions[bot]
b63f2f78fc chore: sync VERSION file with release v1.1.219 [skip ci] 2025-12-04 01:48:56 +00:00
Wesley Liddick
c971d239ff Merge pull request #752 from IanShaw027/fix/filter-cloudflare-cdn-headers
fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
2025-12-03 20:48:41 -05:00
Wesley Liddick
01d6e30e82 Merge pull request #751 from atoz03/feature/account-sort-toggle [skip ci]
feat(accounts): 支持账户排序正序/倒序切换
2025-12-03 20:48:24 -05:00
IanShaw027
5fd78b6411 fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
使用 Cloudflare 橙色云(CDN 代理模式)时,Cloudflare 会自动添加 CDN 相关的 headers
(cf-*, x-forwarded-*, cdn-loop 等),这会触发上游 API 提供商的安全检查:

1. 已确认问题:88code API 检测到 CDN headers 后返回 403 Forbidden,
   导致 Codex CLI 无法使用
2. 潜在风险:其他 API 提供商(OpenAI、Anthropic)可能也会因检测到
   代理/CDN 特征而采取限制措施

创建统一的 headerFilter 工具类,在所有转发服务中过滤 Cloudflare CDN headers,
使转发请求伪装成正常的直接客户端请求。

1. 新增 src/utils/headerFilter.js
   - 统一的 CDN headers 过滤列表(13 个 Cloudflare headers)
   - 提供 filterForOpenAI() 和 filterForClaude() 方法
   - 在现有过滤逻辑基础上添加 CDN header 过滤

2. 更新 src/services/openaiResponsesRelayService.js
   - 使用 filterForOpenAI() 替代内联的 _filterRequestHeaders()
   - 保持向后兼容性

3. 更新 src/services/claudeRelayService.js
   - 使用 filterForClaude() 替代 _filterClientHeaders() 实现
   - 简化代码,移除重复的 header 列表定义

4. 修复 src/routes/openaiRoutes.js
   - 添加对 input 字段的类型检查(可以是数组或字符串)
   - 防止 "startsWith is not a function" 错误

x-real-ip, x-forwarded-for, x-forwarded-proto, x-forwarded-host,
x-forwarded-port, x-accel-buffering, cf-ray, cf-connecting-ip,
cf-ipcountry, cf-visitor, cf-request-id, cdn-loop, true-client-ip

-  Codex CLI 通过中转服务成功调用 88code API(之前返回 403)
-  保留所有业务必需的 headers(conversation_id、session_id 等)
-  移除所有 Cloudflare CDN 痕迹
-  保持橙色云的 DDoS 防护和 CDN 加速优势
-  Docker 构建成功

1. 解决 88code 403 问题,Codex CLI 可正常使用
2. 降低因 CDN/代理特征被上游 API 识别的风险
3. 提升与各种 API 提供商的兼容性
4. 统一管理 CDN headers 过滤逻辑,便于维护
2025-12-03 07:07:12 -08:00
atoz03
9ad5c85c2c feat(accounts): 支持排序切换正序/倒序
- 统一下拉选择器和表头的排序变量
  - 再次点击同一排序选项/列头时切换排序方向
  - 动态更新排序图标指示当前方向
2025-12-03 20:25:26 +08:00
github-actions[bot]
279cd72f23 chore: sync VERSION file with release v1.1.218 [skip ci] 2025-12-02 12:52:01 +00:00
shaw
81e89d2dc4 feat: 支持sessionKey完成oauth授权 2025-12-02 20:43:47 +08:00
62 changed files with 15754 additions and 1108 deletions

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ redis_data/
# Logs directory
logs/
logs1/
*.log
startup.log
app.log

View File

@@ -60,6 +60,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,6 +186,10 @@ 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
@@ -337,6 +342,7 @@ 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`
### 调试工具
@@ -510,6 +516,9 @@ npm run setup # 自动生成密钥并创建管理员账户
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **用户消息队列**:
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁当前持有者requestId
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
- **系统信息**:
- `system_info` - 系统状态缓存
- `model_pricing` - 模型价格数据pricingService

View File

@@ -1,4 +1,17 @@
# 🎯 前端构建阶段
# 🎯 后端依赖阶段 (与前端构建并行)
FROM node:18-alpine AS backend-deps
# 📁 设置工作目录
WORKDIR /app
# 📦 复制 package 文件
COPY package*.json ./
# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# 🎯 前端构建阶段 (与后端依赖并行)
FROM node:18-alpine AS frontend-builder
# 📁 设置工作目录
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
# 📦 复制前端依赖文件
COPY web/admin-spa/package*.json ./
# 🔽 安装前端依赖
RUN npm ci
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
RUN --mount=type=cache,target=/root/.npm \
npm ci
# 📋 复制前端源代码
COPY web/admin-spa/ ./
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
# 📁 设置工作目录
WORKDIR /app
# 📦 复制 package 文件
# 📦 复制 package 文件 (用于版本信息等)
COPY package*.json ./
# 🔽 安装依赖 (生产环境)
RUN npm ci --only=production && \
npm cache clean --force
# 📦 从后端依赖阶段复制 node_modules (已预装好)
COPY --from=backend-deps /app/node_modules ./node_modules
# 📋 复制应用代码
COPY . .
# 📦 从构建阶段复制前端产物
# 📦 从前端构建阶段复制前端产物
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
# 🔧 复制并设置启动脚本权限

View File

@@ -1 +1 @@
1.1.217
1.1.232

View File

@@ -203,6 +203,15 @@ const config = {
development: {
debug: process.env.DEBUG === 'true',
hotReload: process.env.HOT_RELOAD === 'true'
},
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 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秒足以覆盖请求发送
}
}

88
package-lock.json generated
View File

@@ -44,6 +44,7 @@
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3"
},
"engines": {
@@ -890,6 +891,7 @@
"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",
@@ -2998,6 +3000,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3079,6 +3082,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3534,6 +3538,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4421,6 +4426,7 @@
"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",
@@ -4477,6 +4483,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7575,6 +7582,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7598,6 +7606,85 @@
"node": ">=6.0.0"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
"dev": true,
"license": "MIT",
"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
}
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -9014,6 +9101,7 @@
"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

@@ -83,6 +83,7 @@
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3"
},
"engines": {

6357
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* 官方模型版本识别测试 - 最终版 v2
*/
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
// 官方模型
const officialModels = [
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
]
// 非 Opus 模型
const nonOpusModels = [
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
]
// 其他格式测试
const otherFormats = [
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
{ name: 'opus', expected: false, desc: '仅 opus' },
{ name: null, expected: false, desc: 'null' },
{ name: '', expected: false, desc: '空字符串' }
]
console.log('='.repeat(90))
console.log('官方模型版本识别测试 - 最终版 v2')
console.log('='.repeat(90))
console.log()
let passed = 0
let failed = 0
// 测试官方 Opus 模型
console.log('📌 官方 Opus 模型:')
for (const m of officialModels) {
const result = isOpus45OrNewer(m.name)
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
if (result === m.expectPro) {
passed++
} else {
failed++
}
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
}
console.log()
console.log('📌 非 Opus 模型 (不受此函数影响):')
for (const m of nonOpusModels) {
const result = isOpus45OrNewer(m.name)
console.log(
` | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
)
if (result) {
failed++ // 非 Opus 模型不应返回 true
}
}
console.log()
console.log('📌 其他格式测试:')
for (const m of otherFormats) {
const result = isOpus45OrNewer(m.name)
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
if (result === m.expected) {
passed++
} else {
failed++
}
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
console.log(
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
)
}
console.log()
console.log('='.repeat(90))
console.log('测试结果:', passed, '通过,', failed, '失败')
console.log('='.repeat(90))
if (failed > 0) {
console.log('\n❌ 有测试失败,请检查函数逻辑')
process.exit(1)
} else {
console.log('\n✅ 所有测试通过!函数可以安全使用')
process.exit(0)
}

View File

@@ -625,6 +625,14 @@ class Application {
}, 60000) // 每分钟执行一次
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
// 📬 启动用户消息队列服务
const userMessageQueueService = require('./services/userMessageQueueService')
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
userMessageQueueService.cleanupStaleLocks().then(() => {
// 然后启动定时清理任务
userMessageQueueService.startCleanupTask()
})
}
setupGracefulShutdown() {
@@ -661,6 +669,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')

View File

@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
// 添加代理配置
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -732,9 +731,8 @@ async function handleModels(req, res) {
headers: { 'Content-Type': 'application/json' }
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
const response = await axios(axiosConfig)
models = (response.data.models || []).map((m) => ({
@@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {

View File

@@ -6,6 +6,8 @@ const logger = require('../utils/logger')
const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
@@ -201,6 +203,53 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
if (!skipKeyRestrictions) {
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
const isClaudeMessagesEndpoint =
normalizedPath.includes('/v1/messages') &&
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
if (isClaudeMessagesEndpoint) {
try {
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
// API Key 级别的 Claude Code 限制
const keyClaudeCodeOnly =
validation.keyData.enableClientRestriction &&
Array.isArray(validation.keyData.allowedClients) &&
validation.keyData.allowedClients.length === 1 &&
validation.keyData.allowedClients.includes('claude_code')
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
const isClaudeCode = ClaudeCodeValidator.validate(req)
if (!isClaudeCode) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
logger.api(
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
)
return res.status(403).json({
error: {
type: 'client_validation_error',
message: 'This endpoint only accepts requests from Claude Code CLI'
}
})
}
logger.api(
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
)
}
} catch (error) {
logger.error('❌ Error checking Claude Code restriction:', error)
// 配置服务出错时不阻断请求
}
}
}
// 检查并发限制
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
if (!skipKeyRestrictions && concurrencyLimit > 0) {
@@ -226,8 +275,18 @@ const authenticateApiKey = async (req, res, next) => {
)
if (currentConcurrency > concurrencyLimit) {
// 如果超过限制,立即减少计数
await redis.decrConcurrency(validation.keyData.id, requestId)
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
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}`
)
} catch (error) {
logger.error(
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
error
)
}
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
@@ -249,7 +308,38 @@ const authenticateApiKey = async (req, res, next) => {
let leaseRenewInterval = null
if (renewIntervalMs > 0) {
// 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期
// 默认最大生存时间为 10 分钟,可通过环境变量配置
const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10
const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs)
let refreshCount = 0
leaseRenewInterval = setInterval(() => {
refreshCount++
// 超过最大刷新次数,强制停止并清理
if (refreshCount > maxRefreshCount) {
logger.warn(
`⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes`
)
// 清理定时器
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
}
// 强制减少并发计数(如果还没减少)
if (!concurrencyDecremented) {
concurrencyDecremented = true
redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => {
logger.error(
`Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`,
error
)
})
}
return
}
redis
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
.catch((error) => {

View File

@@ -284,7 +284,8 @@ class RedisClient {
isActive = '',
sortBy = 'createdAt',
sortOrder = 'desc',
excludeDeleted = true // 默认排除已删除的 API Keys
excludeDeleted = true, // 默认排除已删除的 API Keys
modelFilter = []
} = options
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
@@ -332,6 +333,15 @@ class RedisClient {
}
}
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await this.getKeyIdsWithModels(
filteredKeys.map((k) => k.id),
modelFilter
)
filteredKeys = filteredKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 4. 排序
filteredKeys.sort((a, b) => {
// status 排序实际上使用 isActive 字段API Key 没有 status 字段)
@@ -781,6 +791,58 @@ class RedisClient {
await Promise.all(operations)
}
/**
* 获取使用了指定模型的 Key IDsOR 逻辑)
*/
async getKeyIdsWithModels(keyIds, models) {
if (!keyIds.length || !models.length) {
return new Set()
}
const client = this.getClientSafe()
const result = new Set()
// 批量检查每个 keyId 是否使用过任意一个指定模型
for (const keyId of keyIds) {
for (const model of models) {
// 检查是否有该模型的使用记录daily 或 monthly
const pattern = `usage:${keyId}:model:*:${model}:*`
const keys = await client.keys(pattern)
if (keys.length > 0) {
result.add(keyId)
break // 找到一个就够了OR 逻辑)
}
}
}
return result
}
/**
* 获取所有被使用过的模型列表
*/
async getAllUsedModels() {
const client = this.getClientSafe()
const models = new Set()
// 扫描所有模型使用记录
const pattern = 'usage:*:model:daily:*'
let cursor = '0'
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
cursor = nextCursor
for (const key of keys) {
// 从 key 中提取模型名: usage:{keyId}:model:daily:{model}:{date}
const match = key.match(/usage:[^:]+:model:daily:([^:]+):/)
if (match) {
models.add(match[1])
}
}
} while (cursor !== '0')
return [...models].sort()
}
async getUsageStats(keyId) {
const totalKey = `usage:${keyId}`
const today = getDateStringInTimezone()
@@ -2034,6 +2096,246 @@ class RedisClient {
return await this.getConcurrency(compositeKey)
}
// 🔧 并发管理方法(用于管理员手动清理)
/**
* 获取所有并发状态
* @returns {Promise<Array>} 并发状态列表
*/
async getAllConcurrencyStatus() {
try {
const client = this.getClientSafe()
const keys = await client.keys('concurrency:*')
const now = Date.now()
const results = []
for (const key of keys) {
// 提取 apiKeyId去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '')
// 获取所有成员和分数(过期时间)
const members = await client.zrangebyscore(key, now, '+inf', 'WITHSCORES')
// 解析成员和过期时间
const activeRequests = []
for (let i = 0; i < members.length; i += 2) {
const requestId = members[i]
const expireAt = parseInt(members[i + 1])
const remainingSeconds = Math.max(0, Math.round((expireAt - now) / 1000))
activeRequests.push({
requestId,
expireAt: new Date(expireAt).toISOString(),
remainingSeconds
})
}
// 获取过期的成员数量
const expiredCount = await client.zcount(key, '-inf', now)
results.push({
apiKeyId,
key,
activeCount: activeRequests.length,
expiredCount,
activeRequests
})
}
return results
} catch (error) {
logger.error('❌ Failed to get all concurrency status:', error)
throw error
}
}
/**
* 获取特定 API Key 的并发状态详情
* @param {string} apiKeyId - API Key ID
* @returns {Promise<Object>} 并发状态详情
*/
async getConcurrencyStatus(apiKeyId) {
try {
const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}`
const now = Date.now()
// 检查 key 是否存在
const exists = await client.exists(key)
if (!exists) {
return {
apiKeyId,
key,
activeCount: 0,
expiredCount: 0,
activeRequests: [],
exists: false
}
}
// 获取所有成员和分数
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
const activeRequests = []
const expiredRequests = []
for (let i = 0; i < allMembers.length; i += 2) {
const requestId = allMembers[i]
const expireAt = parseInt(allMembers[i + 1])
const remainingSeconds = Math.round((expireAt - now) / 1000)
const requestInfo = {
requestId,
expireAt: new Date(expireAt).toISOString(),
remainingSeconds
}
if (expireAt > now) {
activeRequests.push(requestInfo)
} else {
expiredRequests.push(requestInfo)
}
}
return {
apiKeyId,
key,
activeCount: activeRequests.length,
expiredCount: expiredRequests.length,
activeRequests,
expiredRequests,
exists: true
}
} catch (error) {
logger.error(`❌ Failed to get concurrency status for ${apiKeyId}:`, error)
throw error
}
}
/**
* 强制清理特定 API Key 的并发计数(忽略租约)
* @param {string} apiKeyId - API Key ID
* @returns {Promise<Object>} 清理结果
*/
async forceClearConcurrency(apiKeyId) {
try {
const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}`
// 获取清理前的状态
const beforeCount = await client.zcard(key)
// 删除整个 key
await client.del(key)
logger.warn(
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
)
return {
apiKeyId,
key,
clearedCount: beforeCount,
success: true
}
} catch (error) {
logger.error(`❌ Failed to force clear concurrency for ${apiKeyId}:`, error)
throw error
}
}
/**
* 强制清理所有并发计数
* @returns {Promise<Object>} 清理结果
*/
async forceClearAllConcurrency() {
try {
const client = this.getClientSafe()
const keys = await client.keys('concurrency:*')
let totalCleared = 0
const clearedKeys = []
for (const key of keys) {
const count = await client.zcard(key)
await client.del(key)
totalCleared += count
clearedKeys.push({
key,
clearedCount: count
})
}
logger.warn(
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
)
return {
keysCleared: keys.length,
totalEntriesCleared: totalCleared,
clearedKeys,
success: true
}
} catch (error) {
logger.error('❌ Failed to force clear all concurrency:', error)
throw error
}
}
/**
* 清理过期的并发条目(不影响活跃请求)
* @param {string} apiKeyId - API Key ID可选不传则清理所有
* @returns {Promise<Object>} 清理结果
*/
async cleanupExpiredConcurrency(apiKeyId = null) {
try {
const client = this.getClientSafe()
const now = Date.now()
let keys
if (apiKeyId) {
keys = [`concurrency:${apiKeyId}`]
} else {
keys = await client.keys('concurrency:*')
}
let totalCleaned = 0
const cleanedKeys = []
for (const key of keys) {
// 只清理过期的条目
const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) {
totalCleaned += cleaned
cleanedKeys.push({
key,
cleanedCount: cleaned
})
}
// 如果 key 为空,删除它
const remaining = await client.zcard(key)
if (remaining === 0) {
await client.del(key)
}
}
logger.info(
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
)
return {
keysProcessed: keys.length,
keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned,
cleanedKeys,
success: true
}
} catch (error) {
logger.error('❌ Failed to cleanup expired concurrency:', error)
throw error
}
}
// 🔧 Basic Redis operations wrapper methods for convenience
async get(key) {
const client = this.getClientSafe()
@@ -2254,4 +2556,217 @@ redisClient.getDateStringInTimezone = getDateStringInTimezone
redisClient.getHourInTimezone = getHourInTimezone
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
// ============== 用户消息队列相关方法 ==============
/**
* 尝试获取用户消息队列锁
* 使用 Lua 脚本保证原子性
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @param {number} lockTtlMs - 锁 TTL毫秒
* @param {number} delayMs - 请求间隔(毫秒)
* @returns {Promise<{acquired: boolean, waitMs: number}>}
* - acquired: 是否成功获取锁
* - waitMs: 需要等待的毫秒数(-1表示被占用需等待>=0表示需要延迟的毫秒数
*/
redisClient.acquireUserMessageLock = async function (accountId, requestId, lockTtlMs, delayMs) {
const lockKey = `user_msg_queue_lock:${accountId}`
const lastTimeKey = `user_msg_queue_last:${accountId}`
const script = `
local lockKey = KEYS[1]
local lastTimeKey = KEYS[2]
local requestId = ARGV[1]
local lockTtl = tonumber(ARGV[2])
local delayMs = tonumber(ARGV[3])
-- 检查锁是否空闲
local currentLock = redis.call('GET', lockKey)
if currentLock == false then
-- 检查是否需要延迟
local lastTime = redis.call('GET', lastTimeKey)
local now = redis.call('TIME')
local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
if lastTime then
local elapsed = nowMs - tonumber(lastTime)
if elapsed < delayMs then
-- 需要等待的毫秒数
return {0, delayMs - elapsed}
end
end
-- 获取锁
redis.call('SET', lockKey, requestId, 'PX', lockTtl)
return {1, 0}
end
-- 锁被占用,返回等待
return {0, -1}
`
try {
const result = await this.client.eval(
script,
2,
lockKey,
lastTimeKey,
requestId,
lockTtlMs,
delayMs
)
return {
acquired: result[0] === 1,
waitMs: result[1]
}
} catch (error) {
logger.error(`Failed to acquire user message lock for account ${accountId}:`, error)
// 返回 redisError 标记,让上层能区分 Redis 故障和正常锁占用
return { acquired: false, waitMs: -1, redisError: true, errorMessage: error.message }
}
}
/**
* 释放用户消息队列锁并记录完成时间
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @returns {Promise<boolean>} 是否成功释放
*/
redisClient.releaseUserMessageLock = async function (accountId, requestId) {
const lockKey = `user_msg_queue_lock:${accountId}`
const lastTimeKey = `user_msg_queue_last:${accountId}`
const script = `
local lockKey = KEYS[1]
local lastTimeKey = KEYS[2]
local requestId = ARGV[1]
-- 验证锁持有者
local currentLock = redis.call('GET', lockKey)
if currentLock == requestId then
-- 记录完成时间
local now = redis.call('TIME')
local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
redis.call('SET', lastTimeKey, nowMs, 'EX', 60) -- 60秒后过期
-- 删除锁
redis.call('DEL', lockKey)
return 1
end
return 0
`
try {
const result = await this.client.eval(script, 2, lockKey, lastTimeKey, requestId)
return result === 1
} catch (error) {
logger.error(`Failed to release user message lock for account ${accountId}:`, error)
return false
}
}
/**
* 强制释放用户消息队列锁(用于清理孤儿锁)
* @param {string} accountId - 账户ID
* @returns {Promise<boolean>} 是否成功释放
*/
redisClient.forceReleaseUserMessageLock = async function (accountId) {
const lockKey = `user_msg_queue_lock:${accountId}`
try {
await this.client.del(lockKey)
return true
} catch (error) {
logger.error(`Failed to force release user message lock for account ${accountId}:`, error)
return false
}
}
/**
* 获取用户消息队列统计信息(用于调试)
* @param {string} accountId - 账户ID
* @returns {Promise<Object>} 队列统计
*/
redisClient.getUserMessageQueueStats = async function (accountId) {
const lockKey = `user_msg_queue_lock:${accountId}`
const lastTimeKey = `user_msg_queue_last:${accountId}`
try {
const [lockHolder, lastTime, lockTtl] = await Promise.all([
this.client.get(lockKey),
this.client.get(lastTimeKey),
this.client.pttl(lockKey)
])
return {
accountId,
isLocked: !!lockHolder,
lockHolder,
lockTtlMs: lockTtl > 0 ? lockTtl : 0,
lockTtlRaw: lockTtl, // 原始 PTTL 值:>0 有TTL-1 无过期时间,-2 键不存在
lastCompletedAt: lastTime ? new Date(parseInt(lastTime)).toISOString() : null
}
} catch (error) {
logger.error(`Failed to get user message queue stats for account ${accountId}:`, error)
return {
accountId,
isLocked: false,
lockHolder: null,
lockTtlMs: 0,
lockTtlRaw: -2,
lastCompletedAt: null
}
}
}
/**
* 扫描所有用户消息队列锁(用于清理任务)
* @returns {Promise<string[]>} 账户ID列表
*/
redisClient.scanUserMessageQueueLocks = async function () {
const accountIds = []
let cursor = '0'
let iterations = 0
const MAX_ITERATIONS = 1000 // 防止无限循环
try {
do {
const [newCursor, keys] = await this.client.scan(
cursor,
'MATCH',
'user_msg_queue_lock:*',
'COUNT',
100
)
cursor = newCursor
iterations++
for (const key of keys) {
const accountId = key.replace('user_msg_queue_lock:', '')
accountIds.push(accountId)
}
// 防止无限循环
if (iterations >= MAX_ITERATIONS) {
logger.warn(
`📬 User message queue: SCAN reached max iterations (${MAX_ITERATIONS}), stopping early`,
{ foundLocks: accountIds.length }
)
break
}
} while (cursor !== '0')
if (accountIds.length > 0) {
logger.debug(
`📬 User message queue: scanned ${accountIds.length} lock(s) in ${iterations} iteration(s)`
)
}
return accountIds
} catch (error) {
logger.error('Failed to scan user message queue locks:', error)
return []
}
}
module.exports = redisClient

View File

@@ -103,6 +103,17 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
}
})
// 获取所有被使用过的模型列表
router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => {
try {
const models = await redis.getAllUsedModels()
return res.json({ success: true, data: models })
} catch (error) {
logger.error('❌ Failed to get used models:', error)
return res.status(500).json({ error: 'Failed to get used models', message: error.message })
}
})
// 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
@@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 筛选参数
tag = '',
isActive = '',
models = '', // 模型筛选(逗号分隔)
// 排序参数
sortBy = 'createdAt',
sortOrder = 'desc',
@@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
timeRange = 'all'
} = req.query
// 解析模型筛选参数
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
// 验证分页参数
const pageNum = Math.max(1, parseInt(page) || 1)
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
@@ -217,7 +232,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search,
searchMode,
tag,
isActive
isActive,
modelFilter
})
costSortStatus = {
@@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search,
searchMode,
tag,
isActive
isActive,
modelFilter
})
costSortStatus.isRealTimeCalculation = false
@@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
sortOrder: validSortOrder,
modelFilter
})
}
@@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
* 使用预计算索引进行费用排序的分页查询
*/
async function getApiKeysSortedByCostPrecomputed(options) {
const { page, pageSize, sortOrder, costTimeRange, search, searchMode, tag, isActive } = options
const {
page,
pageSize,
sortOrder,
costTimeRange,
search,
searchMode,
tag,
isActive,
modelFilter = []
} = options
const costRankService = require('../../services/costRankService')
// 1. 获取排序后的全量 keyId 列表
@@ -369,6 +397,15 @@ async function getApiKeysSortedByCostPrecomputed(options) {
}
}
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await redis.getKeyIdsWithModels(
orderedKeys.map((k) => k.id),
modelFilter
)
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 5. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
@@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) {
* 使用实时计算进行 custom 时间范围的费用排序
*/
async function getApiKeysSortedByCostCustom(options) {
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } =
options
const {
page,
pageSize,
sortOrder,
startDate,
endDate,
search,
searchMode,
tag,
isActive,
modelFilter = []
} = options
const costRankService = require('../../services/costRankService')
// 1. 实时计算所有 Keys 的费用
@@ -427,9 +474,9 @@ async function getApiKeysSortedByCostCustom(options) {
}
// 2. 转换为数组并排序
const sortedEntries = [...costs.entries()].sort((a, b) => {
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
})
const sortedEntries = [...costs.entries()].sort((a, b) =>
sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
)
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
// 3. 批量获取 API Key 基础数据
@@ -465,6 +512,15 @@ async function getApiKeysSortedByCostCustom(options) {
}
}
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await redis.getKeyIdsWithModels(
orderedKeys.map((k) => k.id),
modelFilter
)
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 6. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
@@ -863,6 +919,62 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
// 去重(避免日数据和月数据重复计算)
const uniqueKeys = [...new Set(allKeys)]
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
let dailyCost = 0
let currentWindowCost = 0
let windowRemainingSeconds = null
let windowStartTime = null
let windowEndTime = null
let allTimeCost = 0
try {
// 先获取 API Key 配置,判断是否需要查询限制相关数据
const apiKey = await redis.getApiKey(keyId)
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
// 只在启用了每日费用限制时查询
if (dailyCostLimit > 0) {
dailyCost = await redis.getDailyCost(keyId)
}
// 只在启用了总费用限制时查询
if (totalCostLimit > 0) {
const totalCostKey = `usage:cost:total:${keyId}`
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
}
// 只在启用了窗口限制时查询窗口数据
if (rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
windowStartTime = parseInt(windowStart)
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期
windowRemainingSeconds = 0
currentWindowCost = 0
}
}
}
} catch (error) {
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
}
// 如果没有使用数据,返回零值但包含窗口数据
if (uniqueKeys.length === 0) {
return {
requests: 0,
@@ -872,7 +984,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: 0,
formattedCost: '$0.00'
formattedCost: '$0.00',
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
@@ -973,52 +1092,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 获取实时限制数据
let dailyCost = 0
let currentWindowCost = 0
let windowRemainingSeconds = null
let windowStartTime = null
let windowEndTime = null
let allTimeCost = 0
try {
// 获取当日费用
dailyCost = await redis.getDailyCost(keyId)
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
const totalCostKey = `usage:cost:total:${keyId}`
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
// 获取 API Key 配置信息以判断是否需要窗口数据
const apiKey = await redis.getApiKey(keyId)
if (apiKey && apiKey.rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
windowStartTime = parseInt(windowStart)
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期
windowRemainingSeconds = 0
currentWindowCost = 0
}
}
}
} catch (error) {
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
}
return {
requests: totalRequests,
tokens,

View File

@@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
}
})
// =============================================================================
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
// =============================================================================
// 普通OAuth的Cookie自动授权
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based OAuth authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// Setup Token的Cookie自动授权
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程Setup Token模式
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based Setup Token authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {

View File

@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
groupId,
dailyQuota,
quotaResetTime,
maxConcurrentTasks
maxConcurrentTasks,
disableAutoProtection
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
}
// 校验上游错误自动防护开关
const normalizedDisableAutoProtection =
disableAutoProtection === true || disableAutoProtection === 'true'
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks:
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0
: 0,
disableAutoProtection: normalizedDisableAutoProtection
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
return res.status(404).json({ error: 'Account not found' })
}
// 规范化上游错误自动防护开关
if (mappedUpdates.disableAutoProtection !== undefined) {
mappedUpdates.disableAutoProtection =
mappedUpdates.disableAutoProtection === true ||
mappedUpdates.disableAutoProtection === 'true'
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除

View File

@@ -0,0 +1,175 @@
/**
* Claude 转发配置 API 路由
* 管理全局 Claude Code 限制和会话绑定配置
*/
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
const logger = require('../../utils/logger')
const router = express.Router()
/**
* GET /admin/claude-relay-config
* 获取 Claude 转发配置
*/
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
try {
const config = await claudeRelayConfigService.getConfig()
return res.json({
success: true,
config
})
} catch (error) {
logger.error('❌ Failed to get Claude relay config:', error)
return res.status(500).json({
error: 'Failed to get configuration',
message: error.message
})
}
})
/**
* PUT /admin/claude-relay-config
* 更新 Claude 转发配置
*/
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
try {
const {
claudeCodeOnlyEnabled,
globalSessionBindingEnabled,
sessionBindingErrorMessage,
sessionBindingTtlDays,
userMessageQueueEnabled,
userMessageQueueDelayMs,
userMessageQueueTimeoutMs
} = req.body
// 验证输入
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
}
if (
globalSessionBindingEnabled !== undefined &&
typeof globalSessionBindingEnabled !== 'boolean'
) {
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
}
if (sessionBindingErrorMessage !== undefined) {
if (typeof sessionBindingErrorMessage !== 'string') {
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
}
if (sessionBindingErrorMessage.length > 500) {
return res
.status(400)
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
}
}
if (sessionBindingTtlDays !== undefined) {
if (
typeof sessionBindingTtlDays !== 'number' ||
sessionBindingTtlDays < 1 ||
sessionBindingTtlDays > 365
) {
return res
.status(400)
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
}
}
// 验证用户消息队列配置
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' })
}
}
const updateData = {}
if (claudeCodeOnlyEnabled !== undefined) {
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
}
if (globalSessionBindingEnabled !== undefined) {
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
}
if (sessionBindingErrorMessage !== undefined) {
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
}
if (sessionBindingTtlDays !== undefined) {
updateData.sessionBindingTtlDays = sessionBindingTtlDays
}
if (userMessageQueueEnabled !== undefined) {
updateData.userMessageQueueEnabled = userMessageQueueEnabled
}
if (userMessageQueueDelayMs !== undefined) {
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
}
if (userMessageQueueTimeoutMs !== undefined) {
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
}
const updatedConfig = await claudeRelayConfigService.updateConfig(
updateData,
req.admin?.username || 'unknown'
)
return res.json({
success: true,
message: 'Configuration updated successfully',
config: updatedConfig
})
} catch (error) {
logger.error('❌ Failed to update Claude relay config:', error)
return res.status(500).json({
error: 'Failed to update configuration',
message: error.message
})
}
})
/**
* GET /admin/claude-relay-config/session-bindings
* 获取会话绑定统计
*/
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
try {
const stats = await claudeRelayConfigService.getSessionBindingStats()
return res.json({
success: true,
data: stats
})
} catch (error) {
logger.error('❌ Failed to get session binding stats:', error)
return res.status(500).json({
error: 'Failed to get session binding statistics',
message: error.message
})
}
})
module.exports = router

View File

@@ -0,0 +1,146 @@
/**
* 并发管理 API 路由
* 提供并发状态查看和手动清理功能
*/
const express = require('express')
const router = express.Router()
const redis = require('../../models/redis')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
/**
* GET /admin/concurrency
* 获取所有并发状态
*/
router.get('/concurrency', authenticateAdmin, async (req, res) => {
try {
const status = await redis.getAllConcurrencyStatus()
// 计算汇总统计
const summary = {
totalKeys: status.length,
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
}
res.json({
success: true,
summary,
concurrencyStatus: status
})
} catch (error) {
logger.error('❌ Failed to get concurrency status:', error)
res.status(500).json({
success: false,
error: 'Failed to get concurrency status',
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)
res.json({
success: true,
concurrencyStatus: status
})
} catch (error) {
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
res.status(500).json({
success: false,
error: 'Failed to get concurrency status',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency/:apiKeyId
* 强制清理特定 API Key 的并发计数
*/
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const result = await redis.forceClearConcurrency(apiKeyId)
logger.warn(
`🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}`
)
res.json({
success: true,
message: `Successfully cleared concurrency for API key ${apiKeyId}`,
result
})
} catch (error) {
logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error)
res.status(500).json({
success: false,
error: 'Failed to clear concurrency',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency
* 强制清理所有并发计数
*/
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
try {
const result = await redis.forceClearAllConcurrency()
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`)
res.json({
success: true,
message: 'Successfully cleared all concurrency',
result
})
} catch (error) {
logger.error('❌ Failed to clear all concurrency:', error)
res.status(500).json({
success: false,
error: 'Failed to clear all concurrency',
message: error.message
})
}
})
/**
* POST /admin/concurrency/cleanup
* 清理过期的并发条目(不影响活跃请求)
*/
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.body
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`)
res.json({
success: true,
message: apiKeyId
? `Successfully cleaned up expired concurrency for API key ${apiKeyId}`
: 'Successfully cleaned up all expired concurrency',
result
})
} catch (error) {
logger.error('❌ Failed to cleanup expired concurrency:', error)
res.status(500).json({
success: false,
error: 'Failed to cleanup expired concurrency',
message: error.message
})
}
})
module.exports = router

View File

@@ -22,6 +22,8 @@ const droidAccountsRoutes = require('./droidAccounts')
const dashboardRoutes = require('./dashboard')
const usageStatsRoutes = require('./usageStats')
const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
// 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径)
@@ -35,6 +37,8 @@ router.use('/', droidAccountsRoutes)
router.use('/', dashboardRoutes)
router.use('/', usageStatsRoutes)
router.use('/', systemRoutes)
router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes)
// 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes)

View File

@@ -1,8 +1,10 @@
const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const ccrAccountService = require('../../services/ccrAccountService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService')
@@ -14,6 +16,65 @@ const pricingService = require('../../services/pricingService')
const router = express.Router()
const accountTypeNames = {
claude: 'Claude官方',
'claude-console': 'Claude Console',
ccr: 'Claude Console Relay',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid',
unknown: '未知渠道'
}
const resolveAccountByPlatform = async (accountId, platform) => {
const serviceMap = {
claude: claudeAccountService,
'claude-console': claudeConsoleAccountService,
gemini: geminiAccountService,
'gemini-api': geminiApiAccountService,
openai: openaiAccountService,
'openai-responses': openaiResponsesAccountService,
droid: droidAccountService,
ccr: ccrAccountService
}
if (platform && serviceMap[platform]) {
try {
const account = await serviceMap[platform].getAccount(accountId)
if (account) {
return { ...account, platform }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
}
}
for (const [platformName, service] of Object.entries(serviceMap)) {
try {
const account = await service.getAccount(accountId)
if (account) {
return { ...account, platform: platformName }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
}
}
return null
}
const getApiKeyName = async (keyId) => {
try {
const keyData = await redis.getApiKey(keyId)
return keyData?.name || keyData?.label || keyId
} catch (error) {
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
return keyId
}
}
// 📊 账户使用统计
// 获取所有账户的使用统计
@@ -148,7 +209,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
accountData = await geminiAccountService.getAccount(accountId)
break
case 'gemini-api': {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
accountData = await geminiApiAccountService.getAccount(accountId)
break
}
@@ -369,7 +429,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
logger.info(` endDate (raw): ${endDate}`)
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`)
logger.info(
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
)
} else {
// 默认最近24小时
endTime = new Date()
@@ -890,7 +952,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
})
]
} else if (group === 'gemini') {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
geminiAccountService.getAllAccounts(),
geminiApiAccountService.getAllAccounts(true)
@@ -1818,4 +1879,628 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
}
})
// 获取 API Key 的请求记录时间线
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const {
page = 1,
pageSize = 50,
startDate,
endDate,
model,
accountId,
sortOrder = 'desc'
} = req.query
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
const startTime = startDate ? new Date(startDate) : null
const endTime = endDate ? new Date(endDate) : null
if (
(startDate && Number.isNaN(startTime?.getTime())) ||
(endDate && Number.isNaN(endTime?.getTime()))
) {
return res.status(400).json({ success: false, error: 'Invalid date range' })
}
if (startTime && endTime && startTime > endTime) {
return res
.status(400)
.json({ success: false, error: 'Start date must be before or equal to end date' })
}
const apiKeyInfo = await redis.getApiKey(keyId)
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
return res.status(404).json({ success: false, error: 'API key not found' })
}
const rawRecords = await redis.getUsageRecords(keyId, 5000)
const accountServices = [
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
]
const accountCache = new Map()
const resolveAccountInfo = async (id, type) => {
if (!id) {
return null
}
const cacheKey = `${type || 'any'}:${id}`
if (accountCache.has(cacheKey)) {
return accountCache.get(cacheKey)
}
let servicesToTry = type
? accountServices.filter((svc) => svc.type === type)
: accountServices
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
if (!servicesToTry.length) {
servicesToTry = accountServices
}
for (const service of servicesToTry) {
try {
const account = await service.getter(id)
if (account) {
const info = {
id,
name: account.name || account.email || id,
type: service.type,
status: account.status || account.isActive
}
accountCache.set(cacheKey, info)
return info
}
} catch (error) {
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
}
}
accountCache.set(cacheKey, null)
return null
}
const toUsageObject = (record) => ({
input_tokens: record.inputTokens || 0,
output_tokens: record.outputTokens || 0,
cache_creation_input_tokens: record.cacheCreateTokens || 0,
cache_read_input_tokens: record.cacheReadTokens || 0,
cache_creation: record.cacheCreation || record.cache_creation || null
})
const withinRange = (record) => {
if (!record.timestamp) {
return false
}
const ts = new Date(record.timestamp)
if (Number.isNaN(ts.getTime())) {
return false
}
if (startTime && ts < startTime) {
return false
}
if (endTime && ts > endTime) {
return false
}
return true
}
const filteredRecords = rawRecords.filter((record) => {
if (!withinRange(record)) {
return false
}
if (model && record.model !== model) {
return false
}
if (accountId && record.accountId !== accountId) {
return false
}
return true
})
filteredRecords.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime()
const bTime = new Date(b.timestamp).getTime()
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
return 0
}
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
})
const summary = {
totalRequests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0
}
const modelSet = new Set()
const accountOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
for (const record of filteredRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
summary.totalRequests += 1
summary.inputTokens += usage.input_tokens
summary.outputTokens += usage.output_tokens
summary.cacheCreateTokens += usage.cache_creation_input_tokens
summary.cacheReadTokens += usage.cache_read_input_tokens
summary.totalTokens += totalTokens
summary.totalCost += computedCost
if (record.model) {
modelSet.add(record.model)
}
if (record.accountId) {
const normalizedType = record.accountType || 'unknown'
if (!accountOptionMap.has(record.accountId)) {
accountOptionMap.set(record.accountId, {
id: record.accountId,
accountTypes: new Set([normalizedType])
})
} else {
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
}
}
if (record.timestamp) {
const ts = new Date(record.timestamp)
if (!Number.isNaN(ts.getTime())) {
if (!earliestTimestamp || ts < earliestTimestamp) {
earliestTimestamp = ts
}
if (!latestTimestamp || ts > latestTimestamp) {
latestTimestamp = ts
}
}
}
}
const totalRecords = filteredRecords.length
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
const startIndex = (safePage - 1) * pageSizeNumber
const pageRecords =
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
const enrichedRecords = []
for (const record of pageRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
accountId: record.accountId || null,
accountName: accountInfo?.name || null,
accountStatus: accountInfo?.status ?? null,
accountType: resolvedAccountType,
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
ephemeral5mTokens: record.ephemeral5mTokens || 0,
ephemeral1hTokens: record.ephemeral1hTokens || 0,
totalTokens,
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
cost: Number(computedCost.toFixed(6)),
costFormatted:
record.costFormatted ||
costData?.formatted?.total ||
CostCalculator.formatCost(computedCost),
costBreakdown: record.costBreakdown || {
input: costData?.costs?.input || 0,
output: costData?.costs?.output || 0,
cacheCreate: costData?.costs?.cacheWrite || 0,
cacheRead: costData?.costs?.cacheRead || 0,
total: costData?.costs?.total || computedCost
},
responseTime: record.responseTime || null
})
}
const accountOptions = []
for (const option of accountOptionMap.values()) {
const types = Array.from(option.accountTypes || [])
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
let resolvedInfo = null
for (const type of types) {
resolvedInfo = await resolveAccountInfo(option.id, type)
if (resolvedInfo && resolvedInfo.name) {
break
}
}
if (!resolvedInfo) {
resolvedInfo = await resolveAccountInfo(option.id)
}
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
if (!resolvedInfo) {
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
}
accountOptions.push({
id: option.id,
name: resolvedInfo?.name || option.id,
accountType: chosenType,
accountTypeName: chosenTypeName,
rawTypes: types
})
}
return res.json({
success: true,
data: {
records: enrichedRecords,
pagination: {
currentPage: safePage,
pageSize: pageSizeNumber,
totalRecords,
totalPages,
hasNextPage: totalPages > 0 && safePage < totalPages,
hasPreviousPage: totalPages > 0 && safePage > 1
},
filters: {
startDate: startTime ? startTime.toISOString() : null,
endDate: endTime ? endTime.toISOString() : null,
model: model || null,
accountId: accountId || null,
sortOrder: normalizedSortOrder
},
apiKeyInfo: {
id: keyId,
name: apiKeyInfo.name || apiKeyInfo.label || keyId
},
summary: {
...summary,
totalCost: Number(summary.totalCost.toFixed(6)),
avgCost:
summary.totalRequests > 0
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
: 0
},
availableFilters: {
models: Array.from(modelSet),
accounts: accountOptions,
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get API key usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get API key usage records', message: error.message })
}
})
// 获取账户的请求记录时间线
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const {
platform,
page = 1,
pageSize = 50,
startDate,
endDate,
model,
apiKeyId,
sortOrder = 'desc'
} = req.query
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
const startTime = startDate ? new Date(startDate) : null
const endTime = endDate ? new Date(endDate) : null
if (
(startDate && Number.isNaN(startTime?.getTime())) ||
(endDate && Number.isNaN(endTime?.getTime()))
) {
return res.status(400).json({ success: false, error: 'Invalid date range' })
}
if (startTime && endTime && startTime > endTime) {
return res
.status(400)
.json({ success: false, error: 'Start date must be before or equal to end date' })
}
const accountInfo = await resolveAccountByPlatform(accountId, platform)
if (!accountInfo) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
const allApiKeys = await apiKeyService.getAllApiKeys(true)
const apiKeyNameCache = new Map(
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
)
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
if (apiKeyId && keysToUse.length === 0) {
keysToUse = [{ id: apiKeyId }]
}
const toUsageObject = (record) => ({
input_tokens: record.inputTokens || 0,
output_tokens: record.outputTokens || 0,
cache_creation_input_tokens: record.cacheCreateTokens || 0,
cache_read_input_tokens: record.cacheReadTokens || 0,
cache_creation: record.cacheCreation || record.cache_creation || null
})
const withinRange = (record) => {
if (!record.timestamp) {
return false
}
const ts = new Date(record.timestamp)
if (Number.isNaN(ts.getTime())) {
return false
}
if (startTime && ts < startTime) {
return false
}
if (endTime && ts > endTime) {
return false
}
return true
}
const filteredRecords = []
const modelSet = new Set()
const apiKeyOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
const batchSize = 10
for (let i = 0; i < keysToUse.length; i += batchSize) {
const batch = keysToUse.slice(i, i + batchSize)
const batchResults = await Promise.all(
batch.map(async (key) => {
try {
const records = await redis.getUsageRecords(key.id, 5000)
return { keyId: key.id, records: records || [] }
} catch (error) {
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
return { keyId: key.id, records: [] }
}
})
)
for (const { keyId, records } of batchResults) {
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
for (const record of records) {
if (record.accountId !== accountId) {
continue
}
if (!withinRange(record)) {
continue
}
if (model && record.model !== model) {
continue
}
const accountType = record.accountType || accountInfo.platform || 'unknown'
const normalizedModel = record.model || 'unknown'
modelSet.add(normalizedModel)
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
if (record.timestamp) {
const ts = new Date(record.timestamp)
if (!Number.isNaN(ts.getTime())) {
if (!earliestTimestamp || ts < earliestTimestamp) {
earliestTimestamp = ts
}
if (!latestTimestamp || ts > latestTimestamp) {
latestTimestamp = ts
}
}
}
filteredRecords.push({
...record,
model: normalizedModel,
accountType,
apiKeyId: keyId,
apiKeyName
})
}
}
}
filteredRecords.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime()
const bTime = new Date(b.timestamp).getTime()
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
return 0
}
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
})
const summary = {
totalRequests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0
}
for (const record of filteredRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
summary.totalRequests += 1
summary.inputTokens += usage.input_tokens
summary.outputTokens += usage.output_tokens
summary.cacheCreateTokens += usage.cache_creation_input_tokens
summary.cacheReadTokens += usage.cache_read_input_tokens
summary.totalTokens += totalTokens
summary.totalCost += computedCost
}
const totalRecords = filteredRecords.length
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
const startIndex = (safePage - 1) * pageSizeNumber
const pageRecords =
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
const enrichedRecords = []
for (const record of pageRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
apiKeyId: record.apiKeyId,
apiKeyName: record.apiKeyName,
accountId,
accountName: accountInfo.name || accountInfo.email || accountId,
accountType: record.accountType,
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
ephemeral5mTokens: record.ephemeral5mTokens || 0,
ephemeral1hTokens: record.ephemeral1hTokens || 0,
totalTokens,
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
cost: Number(computedCost.toFixed(6)),
costFormatted:
record.costFormatted ||
costData?.formatted?.total ||
CostCalculator.formatCost(computedCost),
costBreakdown: record.costBreakdown || {
input: costData?.costs?.input || 0,
output: costData?.costs?.output || 0,
cacheCreate: costData?.costs?.cacheWrite || 0,
cacheRead: costData?.costs?.cacheRead || 0,
total: costData?.costs?.total || computedCost
},
responseTime: record.responseTime || null
})
}
return res.json({
success: true,
data: {
records: enrichedRecords,
pagination: {
currentPage: safePage,
pageSize: pageSizeNumber,
totalRecords,
totalPages,
hasNextPage: totalPages > 0 && safePage < totalPages,
hasPreviousPage: totalPages > 0 && safePage > 1
},
filters: {
startDate: startTime ? startTime.toISOString() : null,
endDate: endTime ? endTime.toISOString() : null,
model: model || null,
apiKeyId: apiKeyId || null,
platform: accountInfo.platform,
sortOrder: normalizedSortOrder
},
accountInfo: {
id: accountId,
name: accountInfo.name || accountInfo.email || accountId,
platform: accountInfo.platform || platform || 'unknown',
status: accountInfo.status ?? accountInfo.isActive ?? null
},
summary: {
...summary,
totalCost: Number(summary.totalCost.toFixed(6)),
avgCost:
summary.totalRequests > 0
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
: 0
},
availableFilters: {
models: Array.from(modelSet),
apiKeys: Array.from(apiKeyOptionMap.values()),
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get account usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get account usage records', message: error.message })
}
})
module.exports = router

View File

@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
logger.info(
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
)
}
return mappedUpdates
}

View File

@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const router = express.Router()
@@ -37,6 +38,73 @@ 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 {
@@ -141,6 +209,56 @@ async function handleMessagesRequest(req, res) {
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 🔒 全局会话绑定验证
let forcedAccount = null
let needSessionBinding = false
let originalSessionIdForBinding = null
try {
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
if (globalBindingEnabled) {
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
if (originalSessionId) {
const validation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!validation.valid) {
logger.api(
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
)
return res.status(403).json({
error: {
type: 'session_binding_error',
message: validation.error
}
})
}
// 如果已有绑定,使用绑定的账户
if (validation.binding) {
forcedAccount = validation.binding
logger.api(
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
)
}
// 标记需要在调度成功后建立绑定
if (validation.isNewSession) {
needSessionBinding = true
originalSessionIdForBinding = originalSessionId
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
}
}
}
} catch (error) {
logger.error('❌ Error in global session binding check:', error)
// 配置服务出错时不阻断请求
}
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
@@ -149,10 +267,21 @@ async function handleMessagesRequest(req, res) {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
requestedModel,
forcedAccount
)
;({ accountId, accountType } = selection)
} catch (error) {
// 处理会话绑定账户不可用的错误
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
return res.status(403).json({
error: {
type: 'session_binding_error',
message: errorMessage
}
})
}
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
@@ -170,6 +299,40 @@ async function handleMessagesRequest(req, res) {
throw error
}
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBinding &&
originalSessionIdForBinding &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 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: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定
try {
await claudeRelayConfigService.setOriginalSessionBinding(
originalSessionIdForBinding,
accountId,
accountType
)
} catch (bindingError) {
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
}
}
// 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号
@@ -503,6 +666,55 @@ async function handleMessagesRequest(req, res) {
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 🔒 全局会话绑定验证(非流式)
let forcedAccountNonStream = null
let needSessionBindingNonStream = false
let originalSessionIdForBindingNonStream = null
try {
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
if (globalBindingEnabled) {
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
if (originalSessionId) {
const validation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!validation.valid) {
logger.api(
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
)
return res.status(403).json({
error: {
type: 'session_binding_error',
message: validation.error
}
})
}
if (validation.binding) {
forcedAccountNonStream = validation.binding
logger.api(
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
)
}
if (validation.isNewSession) {
needSessionBindingNonStream = true
originalSessionIdForBindingNonStream = originalSessionId
logger.api(
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
)
}
}
}
} catch (error) {
logger.error('❌ Error in global session binding check (non-stream):', error)
}
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
@@ -511,10 +723,20 @@ async function handleMessagesRequest(req, res) {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
requestedModel,
forcedAccountNonStream
)
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
return res.status(403).json({
error: {
type: 'session_binding_error',
message: errorMessage
}
})
}
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
@@ -527,6 +749,40 @@ async function handleMessagesRequest(req, res) {
throw error
}
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBindingNonStream &&
originalSessionIdForBindingNonStream &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 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: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定
try {
await claudeRelayConfigService.setOriginalSessionBinding(
originalSessionIdForBindingNonStream,
accountId,
accountType
)
} catch (bindingError) {
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
}
}
// 根据账号类型选择对应的转发服务
let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -824,7 +1080,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
// 可选:根据 API Key 的模型限制过滤
let filteredModels = models
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id))
// 将 restrictedModels 视为黑名单:过滤掉受限模型
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
}
res.json({
@@ -965,6 +1222,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
})
}
// 🔗 会话绑定验证(与 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)

View File

@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const pricingService = require('../services/pricingService')
const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
}
]
// 如果启用了模型限制,过滤模型列表
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id))
}
res.json({
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
})
}
// 检查模型限制
// 模型限制(黑名单):命中则直接拒绝
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
if (!apiKeyData.restrictedModels.includes(modelId)) {
if (apiKeyData.restrictedModels.includes(modelId)) {
return res.status(404).json({
error: {
message: `Model '${modelId}' not found`,
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
// 转换 OpenAI 请求为 Claude 格式
const claudeRequest = openaiToClaude.convertRequest(req.body)
// 检查模型限制
// 模型限制(黑名单):命中受限模型则拒绝
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
const effectiveModel = getEffectiveModel(claudeRequest.model || '')
if (apiKeyData.restrictedModels.includes(effectiveModel)) {
return res.status(403).json({
error: {
message: `Model ${req.body.model} is not allowed for this API key`,

View File

@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
// 从请求体中提取模型和流式标志
let requestedModel = req.body?.model || null
const isCodexModel =
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07则覆盖为 gpt-5
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07并且不是 Codex 系列,则覆盖为 gpt-5
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
requestedModel = 'gpt-5'
req.body.model = 'gpt-5' // 同时更新请求体中的模型

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() {
@@ -69,7 +70,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 +152,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 +185,93 @@ 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) {
res.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: '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,6 +291,23 @@ 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响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -191,6 +368,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
)
}
}
}
}

View File

@@ -3,6 +3,7 @@ const ccrAccountService = require('./ccrAccountService')
const logger = require('../utils/logger')
const config = require('../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
const userMessageQueueService = require('./userMessageQueueService')
class CcrRelayService {
constructor() {
@@ -21,8 +22,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 +222,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 +310,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 +340,74 @@ 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) {
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: '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 +455,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) {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, 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 +515,8 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -380,8 +579,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}`)
// 错误响应处理
@@ -431,6 +633,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) {

View File

@@ -16,6 +16,22 @@ const {
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
const { isOpus45OrNewer } = require('../utils/modelHelper')
/**
* Check if account is Pro (not Max)
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
* @param {Object} info - Subscription info object
* @returns {boolean}
*/
function isProAccount(info) {
// API real-time status takes priority
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return true
}
// Local configured account type
return info.accountType === 'claude_pro'
}
class ClaudeAccountService {
constructor() {
@@ -852,31 +868,39 @@ class ClaudeAccountService {
!this.isSubscriptionExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
// Filter Opus models based on account type and model version
if (modelName && modelName.toLowerCase().includes('opus')) {
const isNewOpus = isOpus45OrNewer(modelName)
activeAccounts = activeAccounts.filter((account) => {
// 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info = JSON.parse(account.subscriptionInfo)
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return false // Claude Pro 不支持 Opus
// Free account: does not support any Opus model
if (info.accountType === 'free') {
return false
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
return false // 明确标记为 Pro 或 Free 的账号不支持
// Pro account: only supports Opus 4.5+
if (isProAccount(info)) {
return isNewOpus
}
// Max account: supports all Opus versions
return true
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
return true
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
return true
})
if (activeAccounts.length === 0) {
throw new Error('No Claude accounts available that support Opus model')
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
throw new Error(`No Claude accounts available that support ${modelDesc} model`)
}
}
@@ -970,31 +994,39 @@ class ClaudeAccountService {
!this.isSubscriptionExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
// Filter Opus models based on account type and model version
if (modelName && modelName.toLowerCase().includes('opus')) {
const isNewOpus = isOpus45OrNewer(modelName)
sharedAccounts = sharedAccounts.filter((account) => {
// 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info = JSON.parse(account.subscriptionInfo)
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return false // Claude Pro 不支持 Opus
// Free account: does not support any Opus model
if (info.accountType === 'free') {
return false
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
return false // 明确标记为 Pro 或 Free 的账号不支持
// Pro account: only supports Opus 4.5+
if (isProAccount(info)) {
return isNewOpus
}
// Max account: supports all Opus versions
return true
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
return true
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
return true
})
if (sharedAccounts.length === 0) {
throw new Error('No shared Claude accounts available that support Opus model')
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
throw new Error(`No shared Claude accounts available that support ${modelDesc} model`)
}
}

View File

@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0 // 最大并发任务数0表示无限制
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options
// 验证必填字段
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数0表示无限制
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
}
const client = redis.getClientSafe()
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
quotaResetTime,
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true'
})
}
}
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
}
accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy)
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
if (updates.maxConcurrentTasks !== undefined) {
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
}
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段

View File

@@ -9,6 +9,7 @@ const {
sanitizeErrorMessage,
isAccountDisabledError
} = require('../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService')
class ClaudeConsoleRelayService {
constructor() {
@@ -29,14 +30,76 @@ 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) {
throw new Error('Claude Console Claude account not found')
}
const autoProtectionDisabled = account.disableAutoProtection === true
logger.info(
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
)
@@ -201,6 +264,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)
@@ -248,27 +328,41 @@ class ClaudeConsoleRelayService {
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
const errorDetails =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
}
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await claudeConsoleAccountService.markAccountRateLimited(accountId)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
@@ -350,6 +444,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
)
}
}
}
}
@@ -368,8 +477,68 @@ 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) {
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: '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) {
@@ -467,16 +636,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) {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
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 {
// 🛑 清理租约刷新定时器
@@ -501,6 +694,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
)
}
}
}
}
@@ -514,7 +722,8 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -577,8 +786,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}`)
// 错误响应处理
@@ -597,6 +809,7 @@ class ClaudeConsoleRelayService {
})
response.data.on('end', async () => {
const autoProtectionDisabled = account.disableAutoProtection === true
// 记录原始错误消息到日志(方便调试,包含供应商信息)
logger.error(
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
@@ -609,24 +822,41 @@ class ClaudeConsoleRelayService {
)
if (response.status === 401) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
}
} else if (response.status === 429) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
logger.warn(
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
}
// 设置响应头
@@ -667,6 +897,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) {

View File

@@ -0,0 +1,443 @@
/**
* Claude 转发配置服务
* 管理全局 Claude Code 限制和会话绑定配置
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
const CONFIG_KEY = 'claude_relay_config'
const SESSION_BINDING_PREFIX = 'original_session_binding:'
// 默认配置
const DEFAULT_CONFIG = {
claudeCodeOnlyEnabled: false,
globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30, // 会话绑定 TTL默认30天
// 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
userMessageQueueLockTtlMs: 5000, // 锁TTL毫秒请求发送后立即释放无需长TTL
updatedAt: null,
updatedBy: null
}
// 内存缓存(避免频繁 Redis 查询)
let configCache = null
let configCacheTime = 0
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
class ClaudeRelayConfigService {
/**
* 从 metadata.user_id 中提取原始 sessionId
* 格式: user_{64位十六进制}_account__session_{uuid}
* @param {Object} requestBody - 请求体
* @returns {string|null} 原始 sessionId 或 null
*/
extractOriginalSessionId(requestBody) {
if (!requestBody?.metadata?.user_id) {
return null
}
const userId = requestBody.metadata.user_id
const match = userId.match(/session_([a-f0-9-]{36})$/i)
return match ? match[1] : null
}
/**
* 获取配置(带缓存)
* @returns {Promise<Object>} 配置对象
*/
async getConfig() {
try {
// 检查缓存
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
return configCache
}
const client = redis.getClient()
if (!client) {
logger.warn('⚠️ Redis not connected, using default config')
return { ...DEFAULT_CONFIG }
}
const data = await client.get(CONFIG_KEY)
if (data) {
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
} else {
configCache = { ...DEFAULT_CONFIG }
}
configCacheTime = Date.now()
return configCache
} catch (error) {
logger.error('❌ Failed to get Claude relay config:', error)
return { ...DEFAULT_CONFIG }
}
}
/**
* 更新配置
* @param {Object} newConfig - 新配置
* @param {string} updatedBy - 更新者
* @returns {Promise<Object>} 更新后的配置
*/
async updateConfig(newConfig, updatedBy) {
try {
const client = redis.getClientSafe()
const currentConfig = await this.getConfig()
const updatedConfig = {
...currentConfig,
...newConfig,
updatedAt: new Date().toISOString(),
updatedBy
}
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
// 更新缓存
configCache = updatedConfig
configCacheTime = Date.now()
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
})
return updatedConfig
} catch (error) {
logger.error('❌ Failed to update Claude relay config:', error)
throw error
}
}
/**
* 检查是否启用全局 Claude Code 限制
* @returns {Promise<boolean>}
*/
async isClaudeCodeOnlyEnabled() {
const cfg = await this.getConfig()
return cfg.claudeCodeOnlyEnabled === true
}
/**
* 检查是否启用全局会话绑定
* @returns {Promise<boolean>}
*/
async isGlobalSessionBindingEnabled() {
const cfg = await this.getConfig()
return cfg.globalSessionBindingEnabled === true
}
/**
* 获取会话绑定错误信息
* @returns {Promise<string>}
*/
async getSessionBindingErrorMessage() {
const cfg = await this.getConfig()
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
}
/**
* 获取原始会话绑定
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object|null>} 绑定信息或 null
*/
async getOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return null
}
try {
const client = redis.getClient()
if (!client) {
return null
}
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
const data = await client.get(key)
if (data) {
return JSON.parse(data)
}
return null
} catch (error) {
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
return null
}
}
/**
* 设置原始会话绑定
* @param {string} originalSessionId - 原始会话ID
* @param {string} accountId - 账户ID
* @param {string} accountType - 账户类型
* @returns {Promise<Object>} 绑定信息
*/
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
if (!originalSessionId || !accountId || !accountType) {
throw new Error('Invalid parameters for session binding')
}
try {
const client = redis.getClientSafe()
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
const binding = {
accountId,
accountType,
createdAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString()
}
// 使用配置的 TTL默认30天
const cfg = await this.getConfig()
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
logger.info(
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
)
return binding
} catch (error) {
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
throw error
}
}
/**
* 更新会话绑定的最后使用时间(续期)
* @param {string} originalSessionId - 原始会话ID
*/
async touchOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return
}
try {
const binding = await this.getOriginalSessionBinding(originalSessionId)
if (!binding) {
return
}
binding.lastUsedAt = new Date().toISOString()
const client = redis.getClientSafe()
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
// 使用配置的 TTL默认30天
const cfg = await this.getConfig()
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
} catch (error) {
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
}
}
/**
* 检查原始会话是否已绑定
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<boolean>}
*/
async isOriginalSessionBound(originalSessionId) {
const binding = await this.getOriginalSessionBinding(originalSessionId)
return binding !== null
}
/**
* 验证绑定的账户是否可用
* @param {Object} binding - 绑定信息
* @returns {Promise<boolean>}
*/
async validateBoundAccount(binding) {
if (!binding || !binding.accountId || !binding.accountType) {
return false
}
try {
const { accountType } = binding
const { accountId } = binding
let accountService
switch (accountType) {
case 'claude-official':
accountService = require('./claudeAccountService')
break
case 'claude-console':
accountService = require('./claudeConsoleAccountService')
break
case 'bedrock':
accountService = require('./bedrockAccountService')
break
case 'ccr':
accountService = require('./ccrAccountService')
break
default:
logger.warn(`Unknown account type for validation: ${accountType}`)
return false
}
const account = await accountService.getAccount(accountId)
// getAccount() 直接返回账户数据对象或 null不是 { success, data } 格式
if (!account) {
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
return false
}
const accountData = account
// 检查账户是否激活
if (accountData.isActive === false || accountData.isActive === 'false') {
logger.warn(
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
)
return false
}
// 检查账户状态(如果存在)
if (accountData.status && accountData.status === 'error') {
logger.warn(
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
)
return false
}
return true
} catch (error) {
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
return false
}
}
/**
* 验证新会话请求
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
*/
async validateNewSession(_requestBody, originalSessionId) {
const cfg = await this.getConfig()
if (!cfg.globalSessionBindingEnabled) {
return { valid: true }
}
// 如果没有 sessionId跳过验证可能是非 Claude Code 客户端)
if (!originalSessionId) {
return { valid: true }
}
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
// 如果会话已存在绑定
if (existingBinding) {
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
// 其他类型bedrock, ccr, claude-console等忽略绑定走正常调度
if (existingBinding.accountType !== 'claude-official') {
logger.info(
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
)
return { valid: true }
}
const accountValid = await this.validateBoundAccount(existingBinding)
if (!accountValid) {
return {
valid: false,
error: cfg.sessionBindingErrorMessage,
code: 'SESSION_BINDING_INVALID'
}
}
// 续期
await this.touchOriginalSessionBinding(originalSessionId)
// 已有绑定,允许继续(这是正常的会话延续)
return { valid: true, binding: existingBinding }
}
// 没有绑定,是新会话
// 注意messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
// 绑定会在调度后创建,仅针对 claude-official 账户
return { valid: true, isNewSession: true }
}
/**
* 删除原始会话绑定
* @param {string} originalSessionId - 原始会话ID
*/
async deleteOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return
}
try {
const client = redis.getClient()
if (!client) {
return
}
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
await client.del(key)
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
} catch (error) {
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
}
}
/**
* 获取会话绑定统计
* @returns {Promise<Object>}
*/
async getSessionBindingStats() {
try {
const client = redis.getClient()
if (!client) {
return { totalBindings: 0 }
}
let cursor = '0'
let count = 0
do {
const [newCursor, keys] = await client.scan(
cursor,
'MATCH',
`${SESSION_BINDING_PREFIX}*`,
'COUNT',
100
)
cursor = newCursor
count += keys.length
} while (cursor !== '0')
return {
totalBindings: count
}
} catch (error) {
logger.error('❌ Failed to get session binding stats:', error)
return { totalBindings: 0 }
}
}
/**
* 清除配置缓存(用于测试或强制刷新)
*/
clearCache() {
configCache = null
configCacheTime = 0
}
}
module.exports = new ClaudeRelayConfigService()

View File

@@ -3,6 +3,7 @@ const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const ProxyHelper = require('../utils/proxyHelper')
const { filterForClaude } = require('../utils/headerFilter')
const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper')
@@ -14,10 +15,11 @@ const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const { formatDateWithTimezone } = require('../utils/dateHelper')
const requestIdentityService = require('./requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const userMessageQueueService = require('./userMessageQueueService')
class ClaudeRelayService {
constructor() {
this.claudeApiUrl = config.claude.apiUrl
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
this.apiVersion = config.claude.apiVersion
this.betaHeader = config.claude.betaHeader
this.systemPrompt = config.claude.systemPrompt
@@ -147,6 +149,9 @@ class ClaudeRelayService {
options = {}
) {
let upstreamRequest = null
let queueLockAcquired = false
let queueRequestId = null
let selectedAccountId = null
try {
// 调试日志查看API Key数据
@@ -191,11 +196,70 @@ class ClaudeRelayService {
}
const { accountId } = accountSelection
const { accountType } = accountSelection
selectedAccountId = accountId
logger.info(
`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
)
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in 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 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 account ${accountId}, requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
let account = await claudeAccountService.getAccount(accountId)
@@ -270,6 +334,23 @@ class ClaudeRelayService {
options
)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for account ${selectedAccountId}:`,
releaseError.message
)
}
}
response.accountId = accountId
response.accountType = accountType
@@ -538,6 +619,21 @@ class ClaudeRelayService {
error.message
)
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for account ${selectedAccountId}:`,
releaseError.message
)
}
}
}
}
@@ -877,62 +973,100 @@ class ClaudeRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
// 需要移除的敏感 headers
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding'
]
// 使用统一的 headerFilter 工具类
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
return filterForClaude(clientHeaders)
}
// 🆕 需要移除的浏览器相关 headers避免CORS问题
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
]
// 🔧 准备请求头和 payload抽离公共逻辑
async _prepareRequestHeadersAndPayload(
body,
clientHeaders,
accountId,
accessToken,
options = {}
) {
const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options
// 应该保留的 headers用于会话一致性和追踪
const allowedHeaders = [
'x-request-id',
'anthropic-version', // 保留API版本
'anthropic-beta' // 保留beta功能
]
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
const filteredHeaders = {}
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 转发客户端的非敏感 headers
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
// 如果在允许列表中,直接保留
if (allowedHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
// 如果不在敏感列表和浏览器列表中,也保留
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
// 应用请求身份转换
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
accountType,
sessionHash,
clientHeaders,
requestOptions,
isStream
})
return filteredHeaders
if (extensionResult.abortResponse) {
return { abortResponse: extensionResult.abortResponse }
}
requestPayload = extensionResult.body
finalHeaders = extensionResult.headers
// 序列化请求体,计算 content-length
const bodyString = JSON.stringify(requestPayload)
const contentLength = Buffer.byteLength(bodyString, 'utf8')
// 构建最终请求头包含认证、版本、User-Agent、Beta 等)
const headers = {
host: 'api.anthropic.com',
connection: 'keep-alive',
'content-type': 'application/json',
'content-length': String(contentLength),
authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
}
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)'
const acceptHeader = headers['accept'] || 'application/json'
delete headers['user-agent']
delete headers['accept']
headers['User-Agent'] = userAgent
headers['Accept'] = acceptHeader
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
return {
requestPayload,
bodyString,
headers,
isRealClaudeCode
}
}
_applyRequestIdentityTransform(body, headers, context = {}) {
@@ -980,46 +1114,24 @@ class ClaudeRelayService {
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
// 使用公共方法准备请求头和 payload
const prepared = await this._prepareRequestHeadersAndPayload(
body,
clientHeaders,
requestOptions,
isStream: false
})
accountId,
accessToken,
{
account,
requestOptions,
isStream: false
}
)
if (extensionResult.abortResponse) {
return extensionResult.abortResponse
if (prepared.abortResponse) {
return prepared.abortResponse
}
requestPayload = extensionResult.body
finalHeaders = extensionResult.headers
const { bodyString, headers } = prepared
return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens
@@ -1033,31 +1145,13 @@ class ClaudeRelayService {
const options = {
hostname: url.hostname,
port: url.port || 443,
path: requestPath,
path: requestPath + (url.search || ''),
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
},
headers,
agent: proxyAgent,
timeout: config.requestTimeout || 600000
}
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['user-agent'] || unifiedUA !== null) {
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
options.headers['user-agent'] = userAgent
}
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
const req = https.request(options, (res) => {
let responseData = Buffer.alloc(0)
@@ -1067,32 +1161,32 @@ class ClaudeRelayService {
res.on('end', () => {
try {
let bodyString = ''
let responseBody = ''
// 根据Content-Encoding处理响应数据
const contentEncoding = res.headers['content-encoding']
if (contentEncoding === 'gzip') {
try {
bodyString = zlib.gunzipSync(responseData).toString('utf8')
responseBody = zlib.gunzipSync(responseData).toString('utf8')
} catch (unzipError) {
logger.error('❌ Failed to decompress gzip response:', unzipError)
bodyString = responseData.toString('utf8')
responseBody = responseData.toString('utf8')
}
} else if (contentEncoding === 'deflate') {
try {
bodyString = zlib.inflateSync(responseData).toString('utf8')
responseBody = zlib.inflateSync(responseData).toString('utf8')
} catch (unzipError) {
logger.error('❌ Failed to decompress deflate response:', unzipError)
bodyString = responseData.toString('utf8')
responseBody = responseData.toString('utf8')
}
} else {
bodyString = responseData.toString('utf8')
responseBody = responseData.toString('utf8')
}
const response = {
statusCode: res.statusCode,
headers: res.headers,
body: bodyString
body: responseBody
}
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
@@ -1111,7 +1205,6 @@ class ClaudeRelayService {
}
req.on('error', async (error) => {
console.error(': ❌ ', error)
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
code: error.code,
errno: error.errno,
@@ -1147,7 +1240,7 @@ class ClaudeRelayService {
})
// 写入请求体
req.write(JSON.stringify(requestPayload))
req.write(bodyString)
req.end()
})
}
@@ -1162,6 +1255,10 @@ class ClaudeRelayService {
streamTransformer = null,
options = {}
) {
let queueLockAcquired = false
let queueRequestId = null
let selectedAccountId = null
try {
// 调试日志查看API Key数据流式请求
logger.info('🔍 [Stream] API Key data received:', {
@@ -1205,6 +1302,70 @@ class ClaudeRelayService {
}
const { accountId } = accountSelection
const { accountType } = accountSelection
selectedAccountId = accountId
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in 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 account ${accountId} (stream), key: ${apiKeyData.name}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: '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 account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
logger.info(
`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
@@ -1271,11 +1432,48 @@ class ClaudeRelayService {
sessionHash,
streamTransformer,
options,
isDedicatedOfficialAccount
isDedicatedOfficialAccount,
// 📬 新增回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for stream account ${selectedAccountId}:`,
releaseError.message
)
}
}
}
)
} catch (error) {
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(`🔌 Claude stream relay ended: Client disconnected`)
} else {
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
}
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for stream account ${selectedAccountId}:`,
releaseError.message
)
}
}
}
}
@@ -1292,7 +1490,8 @@ class ClaudeRelayService {
sessionHash,
streamTransformer = null,
requestOptions = {},
isDedicatedOfficialAccount = false
isDedicatedOfficialAccount = false,
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
) {
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
@@ -1300,79 +1499,39 @@ class ClaudeRelayService {
const isOpusModelRequest =
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
accountType,
sessionHash,
// 使用公共方法准备请求头和 payload
const prepared = await this._prepareRequestHeadersAndPayload(
body,
clientHeaders,
requestOptions,
isStream: true
})
accountId,
accessToken,
{
account,
accountType,
sessionHash,
requestOptions,
isStream: true
}
)
if (extensionResult.abortResponse) {
return extensionResult.abortResponse
if (prepared.abortResponse) {
return prepared.abortResponse
}
requestPayload = extensionResult.body
finalHeaders = extensionResult.headers
const { bodyString, headers } = prepared
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl)
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
path: url.pathname + (url.search || ''),
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
},
headers,
agent: proxyAgent,
timeout: config.requestTimeout || 600000
}
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['user-agent'] || unifiedUA !== null) {
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
options.headers['user-agent'] = userAgent
}
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
const req = https.request(options, async (res) => {
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
@@ -1517,7 +1676,6 @@ class ClaudeRelayService {
})
res.on('end', () => {
console.error(': ❌ ', errorData)
logger.error(
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData
@@ -1579,6 +1737,16 @@ class ClaudeRelayService {
return
}
// 📬 收到成功响应头HTTP 200立即调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseStart && typeof onResponseStart === 'function') {
try {
await onResponseStart()
} catch (callbackError) {
logger.error('❌ Error in onResponseStart callback:', callbackError.message)
}
}
let buffer = ''
const allUsageData = [] // 收集所有的usage事件
let currentUsageData = {} // 当前正在收集的usage数据
@@ -1818,15 +1986,15 @@ class ClaudeRelayService {
// 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => {
if (!headers) {
const get5hStatus = (resHeaders) => {
if (!resHeaders) {
return null
}
// HTTP头部名称不区分大小写需要处理不同情况
return (
headers['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
resHeaders['anthropic-ratelimit-unified-5h-status'] ||
resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] ||
resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
)
}
@@ -1989,18 +2157,24 @@ class ClaudeRelayService {
responseStream.on('close', () => {
logger.debug('🔌 Client disconnected, cleaning up stream')
if (!req.destroyed) {
req.destroy()
req.destroy(new Error('Client disconnected'))
}
})
// 写入请求体
req.write(JSON.stringify(requestPayload))
req.write(bodyString)
req.end()
})
}
// 🛠️ 统一的错误处理方法
async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') {
async _handleServerError(
accountId,
statusCode,
sessionHash = null,
context = '',
accountType = 'claude-official'
) {
try {
await claudeAccountService.recordServerError(accountId, statusCode)
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
@@ -2014,6 +2188,18 @@ class ClaudeRelayService {
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
)
// 标记账户为临时不可用5分钟
try {
await unifiedClaudeScheduler.markAccountTemporarilyUnavailable(
accountId,
accountType,
sessionHash,
300
)
} catch (markError) {
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError)
}
if (errorCount > threshold) {
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
// ⚠️ 只记录5xx/504告警不再自动停止调度避免上游抖动导致误停

View File

@@ -556,7 +556,8 @@ class DroidAccountService {
tokenType = 'Bearer',
authenticationMethod = '',
expiresIn = null,
apiKeys = []
apiKeys = [],
userAgent = '' // 自定义 User-Agent
} = options
const accountId = uuidv4()
@@ -832,7 +833,8 @@ class DroidAccountService {
: '',
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
userAgent: userAgent || '' // 自定义 User-Agent
}
await redis.setDroidAccount(accountId, accountData)
@@ -931,6 +933,11 @@ class DroidAccountService {
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
}
// 处理 userAgent 字段
if (typeof sanitizedUpdates.userAgent === 'string') {
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
}
const parseProxyConfig = (value) => {
if (!value) {
return null

View File

@@ -26,7 +26,7 @@ class DroidRelayService {
comm: '/o/v1/chat/completions'
}
this.userAgent = 'factory-cli/0.19.12'
this.userAgent = 'factory-cli/0.32.1'
this.systemPrompt = SYSTEM_PROMPT
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
}
@@ -241,7 +241,8 @@ class DroidRelayService {
accessToken,
normalizedRequestBody,
normalizedEndpoint,
clientHeaders
clientHeaders,
account
)
if (selectedApiKey) {
@@ -335,7 +336,12 @@ class DroidRelayService {
)
}
} catch (error) {
logger.error(`❌ Droid relay error: ${error.message}`, 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) {
@@ -633,7 +639,7 @@ class DroidRelayService {
// 客户端断开连接时清理
clientResponse.on('close', () => {
if (req && !req.destroyed) {
req.destroy()
req.destroy(new Error('Client disconnected'))
}
})
@@ -737,6 +743,14 @@ class DroidRelayService {
currentUsageData.output_tokens = 0
}
// Capture cache tokens from OpenAI format
currentUsageData.cache_read_input_tokens =
data.usage.input_tokens_details?.cached_tokens || 0
currentUsageData.cache_creation_input_tokens =
data.usage.input_tokens_details?.cache_creation_input_tokens ||
data.usage.cache_creation_input_tokens ||
0
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
}
@@ -758,6 +772,14 @@ class DroidRelayService {
currentUsageData.output_tokens = 0
}
// Capture cache tokens from OpenAI Response API format
currentUsageData.cache_read_input_tokens =
usage.input_tokens_details?.cached_tokens || 0
currentUsageData.cache_creation_input_tokens =
usage.input_tokens_details?.cache_creation_input_tokens ||
usage.cache_creation_input_tokens ||
0
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
}
} catch (parseError) {
@@ -966,11 +988,13 @@ class DroidRelayService {
/**
* 构建请求头
*/
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) {
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) {
// 使用账户配置的 userAgent 或默认值
const userAgent = account?.userAgent || this.userAgent
const headers = {
'content-type': 'application/json',
authorization: `Bearer ${accessToken}`,
'user-agent': this.userAgent,
'user-agent': userAgent,
'x-factory-client': 'cli',
connection: 'keep-alive'
}
@@ -987,9 +1011,15 @@ class DroidRelayService {
}
}
// OpenAI 特定头
// OpenAI 特定头 - 根据模型动态选择 provider
if (endpointType === 'openai') {
headers['x-api-provider'] = 'azure_openai'
const model = (requestBody?.model || '').toLowerCase()
// -max 模型使用 openai provider其他使用 azure_openai
if (model.includes('-max')) {
headers['x-api-provider'] = 'openai'
} else {
headers['x-api-provider'] = 'azure_openai'
}
}
// Comm 端点根据模型动态设置 provider

View File

@@ -1,6 +1,7 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const { filterForOpenAI } = require('../utils/headerFilter')
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
const targetUrl = `${fullAccount.baseApi}${req.path}`
logger.info(`🎯 Forwarding to: ${targetUrl}`)
// 构建请求头
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
const headers = {
...this._filterRequestHeaders(req.headers),
...filterForOpenAI(req.headers),
Authorization: `Bearer ${fullAccount.apiKey}`,
'Content-Type': 'application/json'
}
@@ -425,9 +426,9 @@ class OpenAIResponsesRelayService {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
if (line.startsWith('data:')) {
try {
const jsonStr = line.slice(6)
const jsonStr = line.slice(5).trim()
if (jsonStr === '[DONE]') {
continue
}
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
return { resetsInSeconds, errorData }
}
// 过滤请求头
// 过滤请求头 - 已迁移到 headerFilter 工具类
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
_filterRequestHeaders(headers) {
const filtered = {}
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions'
]
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
return filterForOpenAI(headers)
}
// 估算费用(简化版本,实际应该根据不同的定价模型)

View File

@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
'x-stainless-runtime',
'x-stainless-runtime-version'
]
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
const STAINLESS_HEADER_CASE_MAP = {
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
'x-stainless-timeout': 'X-Stainless-Timeout',
'x-stainless-lang': 'X-Stainless-Lang',
'x-stainless-package-version': 'X-Stainless-Package-Version',
'x-stainless-os': 'X-Stainless-OS',
'x-stainless-arch': 'X-Stainless-Arch',
'x-stainless-runtime': 'X-Stainless-Runtime',
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
}
const MIN_FINGERPRINT_FIELDS = 4
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
return
}
removeHeaderCaseInsensitive(nextHeaders, key)
nextHeaders[key] = fingerprint[key]
// 使用正确的大小写格式返回给上游
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
nextHeaders[properCaseKey] = fingerprint[key]
})
return nextHeaders

View File

@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
/**
* Check if account is Pro (not Max)
*
* ACCOUNT TYPE LOGIC (as of 2025-12-05):
* Pro accounts can be identified by either:
* 1. API real-time data: hasClaudePro=true && hasClaudeMax=false
* 2. Local config data: accountType='claude_pro'
*
* Account type restrictions for Opus models:
* - Free account: No Opus access at all
* - Pro account: Only Opus 4.5+ (new versions)
* - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+)
*
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
* @param {Object} info - Subscription info object
* @returns {boolean} - true if Pro account (not Free, not Max)
*/
function isProAccount(info) {
// API real-time status takes priority
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return true
}
// Local configured account type
return info.accountType === 'claude_pro'
}
class UnifiedClaudeScheduler {
constructor() {
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
return false
}
// 2. Opus 模型的订阅级别检查
// 2. Opus model subscription level check
// VERSION RESTRICTION LOGIC:
// - Free: No Opus models
// - Pro: Only Opus 4.5+ (isOpus45OrNewer = true)
// - Max: All Opus versions
if (requestedModel.toLowerCase().includes('opus')) {
const isNewOpus = isOpus45OrNewer(requestedModel)
if (account.subscriptionInfo) {
try {
const info =
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
// Free account: does not support any Opus model
if (info.accountType === 'free') {
logger.info(
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
`🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}`
)
return false
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
logger.info(
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
)
return false
// Pro account: only supports Opus 4.5+
// Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+)
if (isProAccount(info)) {
if (!isNewOpus) {
logger.info(
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
)
return false
}
// Opus 4.5+ supported
return true
}
// Max account: supports all Opus versions (no restriction)
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
logger.debug(
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
}
}
@@ -139,8 +180,56 @@ class UnifiedClaudeScheduler {
}
// 🎯 统一调度Claude账号官方和Console
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
async selectAccountForApiKey(
apiKeyData,
sessionHash = null,
requestedModel = null,
forcedAccount = null
) {
try {
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
// 其他类型bedrock, ccr, claude-console等忽略绑定走正常调度
if (forcedAccount.accountType !== 'claude-official') {
logger.info(
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
)
// 不使用 forcedAccount继续走下面的正常调度逻辑
} else {
// claude-official 类型需要检查可用性并强制使用
logger.info(
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
const isAvailable = await this._isAccountAvailableForSessionBinding(
forcedAccount.accountId,
forcedAccount.accountType,
requestedModel
)
if (isAvailable) {
logger.info(
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
return {
accountId: forcedAccount.accountId,
accountType: forcedAccount.accountType
}
} else {
// 绑定账户不可用,抛出特定错误(不 fallback
logger.warn(
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
const error = new Error('Session binding account unavailable')
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
error.accountId = forcedAccount.accountId
error.accountType = forcedAccount.accountType
throw error
}
}
}
// 解析供应商前缀
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
@@ -177,30 +266,41 @@ class UnifiedClaudeScheduler {
// 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
boundAccount.id,
'claude-official'
)
if (isTempUnavailable) {
logger.warn(
` Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
` Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
)
} else {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
} else {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
}
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
}
}
}
} else {
@@ -221,12 +321,23 @@ class UnifiedClaudeScheduler {
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
boundConsoleAccount.id,
'claude-console'
)
return {
accountId: apiKeyData.claudeConsoleAccountId,
accountType: 'claude-console'
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
)
} else {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeConsoleAccountId,
accountType: 'claude-console'
}
}
} else {
logger.warn(
@@ -245,12 +356,23 @@ class UnifiedClaudeScheduler {
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
apiKeyData.bedrockAccountId,
'bedrock'
)
return {
accountId: apiKeyData.bedrockAccountId,
accountType: 'bedrock'
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
)
} else {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.bedrockAccountId,
accountType: 'bedrock'
}
}
} else {
logger.warn(
@@ -496,6 +618,18 @@ class UnifiedClaudeScheduler {
continue
}
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
account.id,
'claude-official'
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable`
)
continue
}
// 检查是否被限流
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (isRateLimited) {
@@ -584,6 +718,18 @@ class UnifiedClaudeScheduler {
// 继续处理该账号
}
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
currentAccount.id,
'claude-console'
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
)
continue
}
// 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
currentAccount.id
@@ -682,7 +828,15 @@ class UnifiedClaudeScheduler {
account.accountType === 'shared' &&
this._isSchedulable(account.schedulable)
) {
// 检查是否可调度
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
account.id,
'bedrock'
)
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`)
continue
}
availableAccounts.push({
...account,
@@ -731,6 +885,13 @@ class UnifiedClaudeScheduler {
continue
}
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr')
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`)
continue
}
// 检查是否被限流
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
@@ -1099,6 +1260,42 @@ class UnifiedClaudeScheduler {
}
}
// ⏱️ 标记账户为临时不可用状态用于5xx等临时故障默认5分钟后自动恢复
async markAccountTemporarilyUnavailable(
accountId,
accountType,
sessionHash = null,
ttlSeconds = 300
) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
await client.setex(key, ttlSeconds, '1')
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
logger.warn(
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
return { success: false }
}
}
// 🔍 检查账户是否临时不可用
async isAccountTemporarilyUnavailable(accountId, accountType) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
return (await client.exists(key)) === 1
} catch (error) {
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
return false
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(
accountId,
@@ -1562,6 +1759,67 @@ class UnifiedClaudeScheduler {
return []
}
}
/**
* 🔒 检查 claude-official 账户是否可用于会话绑定
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
* @param {string} accountId - 账户ID
* @param {string} accountType - 账户类型(应为 'claude-official'
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
* @returns {Promise<boolean>}
*/
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
try {
// 此方法仅处理 claude-official 类型
if (accountType !== 'claude-official') {
logger.warn(
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
)
return true // 非 claude-official 类型不受限制
}
const account = await redis.getClaudeAccount(accountId)
if (!account) {
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
return false
}
const isActive = account.isActive === 'true' || account.isActive === true
const { status } = account
if (!isActive) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
return false
}
if (status === 'error' || status === 'temp_error') {
logger.warn(
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
)
return false
}
// 检查是否被限流
if (await claudeAccountService.isAccountRateLimited(accountId)) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
return false
}
// 检查临时不可用
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
return false
}
return true
} catch (error) {
logger.error(
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
error
)
return false
}
}
}
module.exports = new UnifiedClaudeScheduler()

View File

@@ -0,0 +1,348 @@
/**
* 用户消息队列服务
* 为 Claude 账户实现基于消息类型的串行排队机制
*
* 当请求的最后一条消息是用户输入role: user
* 同一账户的此类请求需要串行等待,并在请求之间添加延迟
*/
const { v4: uuidv4 } = require('uuid')
const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger')
// 清理任务间隔
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
// 轮询等待配置
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
class UserMessageQueueService {
constructor() {
this.cleanupTimer = null
}
/**
* 检测请求是否为真正的用户消息请求
* 区分真正的用户输入和 tool_result 消息
*
* Claude API 消息格式:
* - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] }
* - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] }
*
* @param {Object} requestBody - 请求体
* @returns {boolean} - 是否为真正的用户消息(排除 tool_result
*/
isUserMessageRequest(requestBody) {
const messages = requestBody?.messages
if (!Array.isArray(messages) || messages.length === 0) {
return false
}
const lastMessage = messages[messages.length - 1]
// 检查 role 是否为 user
if (lastMessage?.role !== 'user') {
return false
}
// 检查 content 是否包含 tool_result 类型
const { content } = lastMessage
if (Array.isArray(content)) {
// 如果 content 数组中任何元素是 tool_result则不是真正的用户消息
const hasToolResult = content.some(
(block) => block?.type === 'tool_result' || block?.type === 'tool_use_result'
)
if (hasToolResult) {
return false
}
}
// role 是 user 且不包含 tool_result是真正的用户消息
return true
}
/**
* 获取当前配置(支持 Web 界面配置优先)
* @returns {Promise<Object>} 配置对象
*/
async getConfig() {
// 默认配置(防止 config.userMessageQueue 未定义)
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
const queueConfig = config.userMessageQueue || {}
const defaults = {
enabled: queueConfig.enabled ?? false,
delayMs: queueConfig.delayMs ?? 200,
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000因为锁持有时间短
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 50005秒足以覆盖请求发送
}
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
try {
const claudeRelayConfigService = require('./claudeRelayConfigService')
const webConfig = await claudeRelayConfigService.getConfig()
return {
enabled:
webConfig.userMessageQueueEnabled !== undefined
? webConfig.userMessageQueueEnabled
: defaults.enabled,
delayMs:
webConfig.userMessageQueueDelayMs !== undefined
? webConfig.userMessageQueueDelayMs
: defaults.delayMs,
timeoutMs:
webConfig.userMessageQueueTimeoutMs !== undefined
? webConfig.userMessageQueueTimeoutMs
: defaults.timeoutMs,
lockTtlMs:
webConfig.userMessageQueueLockTtlMs !== undefined
? webConfig.userMessageQueueLockTtlMs
: defaults.lockTtlMs
}
} catch {
// 回退到环境变量配置
return defaults
}
}
/**
* 检查功能是否启用
* @returns {Promise<boolean>}
*/
async isEnabled() {
const cfg = await this.getConfig()
return cfg.enabled === true
}
/**
* 获取账户队列锁(阻塞等待)
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID可选会自动生成
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
*/
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) {
const cfg = await this.getConfig()
if (!cfg.enabled) {
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
}
const reqId = requestId || uuidv4()
const timeout = timeoutMs || cfg.timeoutMs
const startTime = Date.now()
let retryCount = 0
logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, {
requestId: reqId,
timeoutMs: timeout
})
while (Date.now() - startTime < timeout) {
const result = await redis.acquireUserMessageLock(
accountId,
reqId,
cfg.lockTtlMs,
cfg.delayMs
)
// 检测 Redis 错误,立即返回系统错误而非继续轮询
if (result.redisError) {
logger.error(`📬 User message queue: Redis error while acquiring lock`, {
accountId,
requestId: reqId,
errorMessage: result.errorMessage
})
return {
acquired: false,
requestId: reqId,
error: 'queue_backend_error',
errorMessage: result.errorMessage
}
}
if (result.acquired) {
logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, {
requestId: reqId,
waitedMs: Date.now() - startTime,
retries: retryCount
})
return { acquired: true, requestId: reqId }
}
// 需要等待
if (result.waitMs > 0) {
// 需要延迟(上一个请求刚完成)
await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime)))
} else {
// 锁被占用,使用指数退避轮询等待
const basePollInterval = Math.min(
POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount),
POLL_INTERVAL_MAX_MS
)
// 添加 ±15% 随机抖动,避免高并发下的周期性碰撞
const jitter = basePollInterval * (0.85 + Math.random() * 0.3)
const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS)
await this._sleep(pollInterval)
retryCount++
}
}
// 超时
logger.warn(`📬 User message queue: timeout waiting for lock`, {
accountId,
requestId: reqId,
timeoutMs: timeout
})
return {
acquired: false,
requestId: reqId,
error: 'queue_timeout'
}
}
/**
* 释放账户队列锁
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @returns {Promise<boolean>}
*/
async releaseQueueLock(accountId, requestId) {
if (!accountId || !requestId) {
return false
}
const released = await redis.releaseUserMessageLock(accountId, requestId)
if (released) {
logger.debug(`📬 User message queue: lock released for account ${accountId}`, {
requestId
})
} else {
logger.warn(`📬 User message queue: failed to release lock (not owner?)`, {
accountId,
requestId
})
}
return released
}
/**
* 获取队列统计信息
* @param {string} accountId - 账户ID
* @returns {Promise<Object>}
*/
async getQueueStats(accountId) {
return await redis.getUserMessageQueueStats(accountId)
}
/**
* 服务启动时清理所有残留的队列锁
* 防止服务重启后旧锁阻塞新请求
* @returns {Promise<number>} 清理的锁数量
*/
async cleanupStaleLocks() {
try {
const accountIds = await redis.scanUserMessageQueueLocks()
let cleanedCount = 0
for (const accountId of accountIds) {
try {
await redis.forceReleaseUserMessageLock(accountId)
cleanedCount++
logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`)
} catch (error) {
logger.error(
`📬 User message queue: failed to clean lock for account ${accountId}:`,
error
)
}
}
if (cleanedCount > 0) {
logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`)
}
return cleanedCount
} catch (error) {
logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error)
return 0
}
}
/**
* 启动定时清理任务
* 始终启动,每次执行时检查配置以支持运行时动态启用/禁用
*/
startCleanupTask() {
if (this.cleanupTimer) {
return
}
this.cleanupTimer = setInterval(async () => {
// 每次运行时检查配置,以便在运行时动态启用/禁用
const currentConfig = await this.getConfig()
if (!currentConfig.enabled) {
logger.debug('📬 User message queue: cleanup skipped (feature disabled)')
return
}
await this._cleanupOrphanLocks()
}, CLEANUP_INTERVAL_MS)
logger.info('📬 User message queue: cleanup task started')
}
/**
* 停止定时清理任务
*/
stopCleanupTask() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer)
this.cleanupTimer = null
logger.info('📬 User message queue: cleanup task stopped')
}
}
/**
* 清理孤儿锁
* 检测异常情况锁存在但没有设置过期时间lockTtlRaw === -1
* 正常情况下所有锁都应该有 TTLRedis 会自动过期
* @private
*/
async _cleanupOrphanLocks() {
try {
const accountIds = await redis.scanUserMessageQueueLocks()
for (const accountId of accountIds) {
const stats = await redis.getUserMessageQueueStats(accountId)
// 检测异常情况锁存在isLocked=true但没有过期时间lockTtlRaw=-1
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
if (stats.isLocked && stats.lockTtlRaw === -1) {
logger.warn(
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
{ lockHolder: stats.lockHolder }
)
await redis.forceReleaseUserMessageLock(accountId)
}
}
} catch (error) {
logger.error('📬 User message queue: cleanup task error:', error)
}
}
/**
* 睡眠辅助函数
* @param {number} ms - 毫秒
* @private
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
module.exports = new UserMessageQueueService()

View File

@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
title: 'Claude Code Compact System Prompt',
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
},
exploreAgentSystemPrompt: {
category: 'system',
title: 'Claude Code Explore Agent System Prompt',
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
},
outputStyleInsightsPrompt: {
category: 'output_style',
title: 'Output Style Insights Addendum',

121
src/utils/headerFilter.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* 统一的 CDN Headers 过滤列表
*
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
* 避免触发上游 API如 88code的安全检查
*/
// Cloudflare CDN headers橙色云代理模式会添加这些
const cdnHeaders = [
'x-real-ip',
'x-forwarded-for',
'x-forwarded-proto',
'x-forwarded-host',
'x-forwarded-port',
'x-accel-buffering',
'cf-ray',
'cf-connecting-ip',
'cf-ipcountry',
'cf-visitor',
'cf-request-id',
'cdn-loop',
'true-client-ip'
]
/**
* 为 OpenAI/Responses API 过滤 headers
* 在原有 skipHeaders 基础上添加 CDN headers
*/
function filterForOpenAI(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
/**
* 为 Claude/Anthropic API 过滤 headers
* 使用白名单模式,只允许指定的 headers 通过
*/
function filterForClaude(headers) {
// 白名单模式:只允许以下 headers
const allowedHeaders = [
'accept',
'x-stainless-retry-count',
'x-stainless-timeout',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version',
'x-stainless-helper-method',
'anthropic-dangerous-direct-browser-access',
'anthropic-version',
'x-app',
'anthropic-beta',
'accept-language',
'sec-fetch-mode',
'accept-encoding',
'user-agent',
'content-type',
'connection'
]
const filtered = {}
Object.keys(headers || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
}
})
return filtered
}
/**
* 为 Gemini API 过滤 headers如果需要转发客户端 headers 时使用)
* 目前 Gemini 服务不转发客户端 headers仅提供此方法备用
*/
function filterForGemini(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'connection',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
module.exports = {
cdnHeaders,
filterForOpenAI,
filterForClaude,
filterForGemini
}

View File

@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
const logFormat = createLogFormat(false)
const consoleFormat = createLogFormat(true)
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
// 📁 确保日志目录存在并设置权限
if (!fs.existsSync(config.logging.dirname)) {
@@ -159,18 +160,20 @@ const createRotateTransport = (filename, level = null) => {
transport.level = level
}
// 监听轮转事件
transport.on('rotate', (oldFilename, newFilename) => {
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
})
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
if (!isTestEnv) {
transport.on('rotate', (oldFilename, newFilename) => {
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
})
transport.on('new', (newFilename) => {
console.log(`📄 New log file created: ${newFilename}`)
})
transport.on('new', (newFilename) => {
console.log(`📄 New log file created: ${newFilename}`)
})
transport.on('archive', (zipFilename) => {
console.log(`🗜️ Log archived: ${zipFilename}`)
})
transport.on('archive', (zipFilename) => {
console.log(`🗜️ Log archived: ${zipFilename}`)
})
}
return transport
}

View File

@@ -70,9 +70,119 @@ function getVendorType(modelStr) {
return vendor
}
/**
* Check if the model is Opus 4.5 or newer.
*
* VERSION LOGIC (as of 2025-12-05):
* - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible)
* - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only)
*
* Supported naming formats:
* - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101
* - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5
* - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229
* - Special: opus-latest, claude-opus-latest → always returns true
*
* @param {string} modelName - Model name
* @returns {boolean} - Whether the model is Opus 4.5 or newer
*/
function isOpus45OrNewer(modelName) {
if (!modelName) {
return false
}
const lowerModel = modelName.toLowerCase()
if (!lowerModel.includes('opus')) {
return false
}
// Handle 'latest' special case
if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) {
return true
}
// Old format: claude-{version}-opus (version before opus)
// e.g., claude-3-opus-20240229, claude-3.5-opus
const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/)
if (oldFormatMatch) {
const majorVersion = parseInt(oldFormatMatch[1], 10)
const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0
// Old format version refers to Claude major version
// majorVersion > 4: 5.x, 6.x, ... → true
// majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true
// Others (3.x, 4.0-4.4): → false
if (majorVersion > 4) {
return true
}
if (majorVersion === 4 && minorVersion >= 5) {
return true
}
return false
}
// New format 1: opus-{major}.{minor} (dot-separated)
// e.g., claude-opus-4.5, opus-4.5
const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/)
if (dotFormatMatch) {
const majorVersion = parseInt(dotFormatMatch[1], 10)
const minorVersion = parseInt(dotFormatMatch[2], 10)
// Same version logic as old format
// opus-5.0, opus-6.0 → true
// opus-4.5, opus-4.6 → true
// opus-4.0, opus-4.4 → false
if (majorVersion > 4) {
return true
}
if (majorVersion === 4 && minorVersion >= 5) {
return true
}
return false
}
// New format 2: opus-{major}[-{minor}][-date] (hyphen-separated)
// e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805
// If opus-{major} is followed by 8-digit date, there's no minor version
// Extract content after 'opus'
const opusIndex = lowerModel.indexOf('opus')
const afterOpus = lowerModel.substring(opusIndex + 4)
// Match: -{major}-{minor}-{date} or -{major}-{date} or -{major}
// IMPORTANT: Minor version regex is (\d{1,2}) not (\d+)
// This prevents matching 8-digit dates as minor version
// Example: opus-4-20250514 → major=4, minor=undefined (not 20250514)
// Example: opus-4-5-20251101 → major=4, minor=5
// Future-proof: Supports up to 2-digit minor versions (0-99)
const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/)
if (versionMatch) {
const majorVersion = parseInt(versionMatch[1], 10)
const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0
// Same version logic: >= 4.5 returns true
// opus-5-0-date, opus-6-date → true
// opus-4-5-date, opus-4-10-date → true (supports 2-digit minor)
// opus-4-date (no minor, treated as 4.0) → false
// opus-4-1-date, opus-4-4-date → false
if (majorVersion > 4) {
return true
}
if (majorVersion === 4 && minorVersion >= 5) {
return true
}
return false
}
// Other cases containing 'opus' but cannot parse version, assume legacy
return false
}
module.exports = {
parseVendorPrefixedModel,
hasVendorPrefix,
getEffectiveModel,
getVendorType
getVendorType,
isOpus45OrNewer
}

View File

@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
}
// Cookie自动授权配置常量
const COOKIE_OAUTH_CONFIG = {
CLAUDE_AI_URL: 'https://claude.ai',
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
}
/**
* 生成随机的 state 参数
* @returns {string} 随机生成的 state (base64url编码)
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
return Object.keys(ext).length > 0 ? ext : null
}
// =============================================================================
// Cookie自动授权相关方法 (基于Clove项目实现)
// =============================================================================
/**
* 构建带Cookie的请求头
* @param {string} sessionKey - sessionKey值
* @returns {object} 请求头对象
*/
function buildCookieHeaders(sessionKey) {
return {
Accept: 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
Cookie: `sessionKey=${sessionKey}`,
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
}
/**
* 使用Cookie获取组织UUID和能力列表
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
*/
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
const headers = buildCookieHeaders(sessionKey)
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
}
logger.debug('🔄 Fetching organization info with Cookie', {
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
if (!response.data || !Array.isArray(response.data)) {
throw new Error('获取组织信息失败:响应格式无效')
}
// 找到具有chat能力且能力最多的组织
let bestOrg = null
let maxCapabilities = []
for (const org of response.data) {
const capabilities = org.capabilities || []
// 必须有chat能力
if (!capabilities.includes('chat')) {
continue
}
// 选择能力最多的组织
if (capabilities.length > maxCapabilities.length) {
bestOrg = org
maxCapabilities = capabilities
}
}
if (!bestOrg || !bestOrg.uuid) {
throw new Error('未找到具有chat能力的组织')
}
logger.success('✅ Found organization', {
uuid: bestOrg.uuid,
capabilities: maxCapabilities
})
return {
organizationUuid: bestOrg.uuid,
capabilities: maxCapabilities
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
throw new Error(`获取组织信息失败HTTP ${status}`)
} else if (error.request) {
throw new Error('获取组织信息失败:网络错误或超时')
}
throw error
}
}
/**
* 使用Cookie自动获取授权code
* @param {string} sessionKey - sessionKey值
* @param {string} organizationUuid - 组织UUID
* @param {string} scope - 授权scope
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
*/
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
// 生成PKCE参数
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// 构建授权URL
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
'{organization_uuid}',
organizationUuid
)
// 构建请求payload
const payload = {
response_type: 'code',
client_id: OAUTH_CONFIG.CLIENT_ID,
organization_uuid: organizationUuid,
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}
const headers = {
...buildCookieHeaders(sessionKey),
'Content-Type': 'application/json'
}
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
}
logger.debug('🔄 Requesting authorization with Cookie', {
url: authorizeUrl,
scope,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.post(authorizeUrl, payload, axiosConfig)
// 从响应中获取redirect_uri
const redirectUri = response.data?.redirect_uri
if (!redirectUri) {
throw new Error('授权响应中未找到redirect_uri')
}
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
// 解析redirect_uri获取authorization code
const url = new URL(redirectUri)
const authorizationCode = url.searchParams.get('code')
const responseState = url.searchParams.get('state')
if (!authorizationCode) {
throw new Error('redirect_uri中未找到授权码')
}
// 构建完整的授权码包含state如果有的话
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
logger.success('✅ Got authorization code via Cookie', {
codeLength: authorizationCode.length,
codePrefix: `${authorizationCode.substring(0, 10)}...`
})
return {
authorizationCode: fullCode,
codeVerifier,
state
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
const errorData = error.response.data
let errorMessage = `HTTP ${status}`
if (errorData) {
if (typeof errorData === 'string') {
errorMessage += `: ${errorData}`
} else if (errorData.error) {
errorMessage += `: ${errorData.error}`
}
}
throw new Error(`授权请求失败:${errorMessage}`)
} else if (error.request) {
throw new Error('授权请求失败:网络错误或超时')
}
throw error
}
}
/**
* 完整的Cookie自动授权流程
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @param {boolean} isSetupToken - 是否为Setup Token模式
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
*/
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
logger.info('🍪 Starting Cookie-based OAuth flow', {
isSetupToken,
hasProxy: !!proxyConfig
})
// 步骤1获取组织信息
logger.debug('Step 1/3: Fetching organization info...')
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
// 步骤2确定scope并获取授权code
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
logger.debug('Step 2/3: Getting authorization code...', { scope })
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
sessionKey,
organizationUuid,
scope,
proxyConfig
)
// 步骤3交换token
logger.debug('Step 3/3: Exchanging token...')
const tokenData = isSetupToken
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
logger.success('✅ Cookie-based OAuth flow completed', {
isSetupToken,
organizationUuid,
hasAccessToken: !!tokenData.accessToken,
hasRefreshToken: !!tokenData.refreshToken
})
return {
claudeAiOauth: tokenData,
organizationUuid,
capabilities
}
}
module.exports = {
OAUTH_CONFIG,
COOKIE_OAUTH_CONFIG,
generateOAuthParams,
generateSetupTokenParams,
exchangeCodeForTokens,
@@ -584,5 +882,10 @@ module.exports = {
generateCodeChallenge,
generateAuthUrl,
generateSetupTokenAuthUrl,
createProxyAgent
createProxyAgent,
// Cookie自动授权相关方法
buildCookieHeaders,
getOrganizationInfo,
authorizeWithCookie,
oauthWithCookie
}

View File

@@ -0,0 +1,81 @@
const logger = require('./logger')
function parseList(envValue) {
if (!envValue) {
return []
}
return envValue
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
}
const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES))
const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS)
const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522])
function normalizeErrorPayload(payload) {
if (!payload) {
return {}
}
if (typeof payload === 'string') {
try {
return normalizeErrorPayload(JSON.parse(payload))
} catch (e) {
return { message: payload }
}
}
if (payload.error && typeof payload.error === 'object') {
return {
type: payload.error.type || payload.error.error || payload.error.code,
code: payload.error.code || payload.error.error || payload.error.type,
message: payload.error.message || payload.error.msg || payload.message || payload.error.error
}
}
return {
type: payload.type || payload.code,
code: payload.code || payload.type,
message: payload.message || ''
}
}
function isUnstableUpstreamError(statusCode, payload) {
const normalizedStatus = Number(statusCode)
if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) {
return true
}
if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) {
return true
}
const { type, code, message } = normalizeErrorPayload(payload)
const lowerType = (type || '').toString().toLowerCase()
const lowerCode = (code || '').toString().toLowerCase()
const lowerMessage = (message || '').toString().toLowerCase()
if (lowerType === 'server_error' || lowerCode === 'server_error') {
return true
}
if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) {
return true
}
if (unstableKeywords.length > 0) {
return unstableKeywords.some((kw) => lowerMessage.includes(kw))
}
return false
}
function logUnstable(accountLabel, statusCode) {
logger.warn(
`Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable`
)
}
module.exports = {
isUnstableUpstreamError,
logUnstable
}

View File

@@ -65,8 +65,9 @@ class ClaudeCodeValidator {
const { bestScore } = bestSimilarityByTemplates(rawText)
if (bestScore < threshold) {
logger.error(
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
)
logger.warn(`Claude system prompt detail: ${rawText}`)
return false
}
}

View File

@@ -0,0 +1,434 @@
/**
* 用户消息队列服务测试
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
*/
const redis = require('../src/models/redis')
const userMessageQueueService = require('../src/services/userMessageQueueService')
describe('UserMessageQueueService', () => {
describe('isUserMessageRequest', () => {
it('should return true when last message role is user', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' }
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return false when last message role is assistant', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' }
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when last message contains tool_result', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Let me check that' },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'test-id',
content: 'Tool result'
}
]
}
]
}
// tool_result 消息虽然 role 是 user但不是真正的用户消息
// 应该返回 false不进入用户消息队列
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when last message contains multiple tool_results', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Run multiple tools' },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Result 1'
},
{
type: 'tool_result',
tool_use_id: 'tool-2',
content: 'Result 2'
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return true when user message has array content with text type', () => {
const requestBody = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello, this is a user message'
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return true when user message has mixed text and image content', () => {
const requestBody = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'What is in this image?'
},
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: '...' }
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return false when messages is empty', () => {
const requestBody = { messages: [] }
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when messages is not an array', () => {
const requestBody = { messages: 'not an array' }
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when messages is undefined', () => {
const requestBody = {}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when requestBody is null', () => {
expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false)
})
it('should return false when requestBody is undefined', () => {
expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false)
})
it('should return false when last message has no role', () => {
const requestBody = {
messages: [{ content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should handle single user message', () => {
const requestBody = {
messages: [{ role: 'user', content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should handle single assistant message', () => {
const requestBody = {
messages: [{ role: 'assistant', content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
})
describe('getConfig', () => {
it('should return config with expected properties', async () => {
const config = await userMessageQueueService.getConfig()
expect(config).toHaveProperty('enabled')
expect(config).toHaveProperty('delayMs')
expect(config).toHaveProperty('timeoutMs')
expect(config).toHaveProperty('lockTtlMs')
expect(typeof config.enabled).toBe('boolean')
expect(typeof config.delayMs).toBe('number')
expect(typeof config.timeoutMs).toBe('number')
expect(typeof config.lockTtlMs).toBe('number')
})
})
describe('isEnabled', () => {
it('should return boolean', async () => {
const enabled = await userMessageQueueService.isEnabled()
expect(typeof enabled).toBe('boolean')
})
})
describe('acquireQueueLock', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should acquire lock immediately when no lock exists', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(result.requestId).toBe('req-1')
expect(result.error).toBeUndefined()
})
it('should skip lock acquisition when queue disabled', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: false,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
const result = await userMessageQueueService.acquireQueueLock('acct-1')
expect(result.acquired).toBe(true)
expect(result.skipped).toBe(true)
expect(acquireSpy).not.toHaveBeenCalled()
})
it('should generate requestId when not provided', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const result = await userMessageQueueService.acquireQueueLock('acct-1')
expect(result.acquired).toBe(true)
expect(result.requestId).toBeDefined()
expect(result.requestId.length).toBeGreaterThan(0)
})
it('should wait and retry when lock is held by another request', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 1000,
lockTtlMs: 120000
})
let callCount = 0
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
callCount++
if (callCount < 3) {
return { acquired: false, waitMs: -1 } // lock held
}
return { acquired: true, waitMs: 0 }
})
// Mock sleep to speed up test
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(callCount).toBe(3)
})
it('should respect delay when previous request just completed', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 1000,
lockTtlMs: 120000
})
let callCount = 0
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
callCount++
if (callCount === 1) {
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
}
return { acquired: true, waitMs: 0 }
})
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
})
it('should timeout and return error when wait exceeds timeout', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 100, // very short timeout
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: false,
waitMs: -1 // always held
})
// Use real timers for timeout test but mock sleep to be instant
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
// Simulate time passing
await new Promise((resolve) => setTimeout(resolve, 60))
})
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
expect(result.acquired).toBe(false)
expect(result.error).toBe('queue_timeout')
})
})
describe('releaseQueueLock', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should release lock successfully when holding the lock', async () => {
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
expect(result).toBe(true)
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
})
it('should return false when not holding the lock', async () => {
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
expect(result).toBe(false)
})
it('should return false when accountId is missing', async () => {
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
expect(result).toBe(false)
expect(releaseSpy).not.toHaveBeenCalled()
})
it('should return false when requestId is missing', async () => {
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
expect(result).toBe(false)
expect(releaseSpy).not.toHaveBeenCalled()
})
})
describe('queue serialization behavior', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should allow different accounts to acquire locks simultaneously', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const [result1, result2] = await Promise.all([
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
])
expect(result1.acquired).toBe(true)
expect(result2.acquired).toBe(true)
})
it('should serialize requests for same account', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 50,
timeoutMs: 5000,
lockTtlMs: 120000
})
const lockState = { held: false, holderId: null }
jest
.spyOn(redis, 'acquireUserMessageLock')
.mockImplementation(async (accountId, requestId) => {
if (!lockState.held) {
lockState.held = true
lockState.holderId = requestId
return { acquired: true, waitMs: 0 }
}
return { acquired: false, waitMs: -1 }
})
jest
.spyOn(redis, 'releaseUserMessageLock')
.mockImplementation(async (accountId, requestId) => {
if (lockState.holderId === requestId) {
lockState.held = false
lockState.holderId = null
return true
}
return false
})
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
// First request acquires lock
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result1.acquired).toBe(true)
// Second request should fail to acquire (lock held)
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
// Release first lock
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
// Now second request should acquire
const result2 = await acquirePromise
expect(result2.acquired).toBe(true)
})
})
})

View File

@@ -3789,7 +3789,7 @@
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14",
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",

View File

@@ -1451,6 +1451,26 @@
</p>
</div>
</div>
<!-- 上游错误处理 -->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>上游错误处理</label
>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段 -->
@@ -1924,6 +1944,22 @@
rows="4"
/>
</div>
<!-- Droid User-Agent 配置 (OAuth/Manual 模式) -->
<div v-if="form.platform === 'droid'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>自定义 User-Agent (可选)</label
>
<input
v-model="form.userAgent"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</p>
</div>
</div>
<!-- API Key 模式输入 -->
@@ -1969,6 +2005,22 @@
</p>
</div>
<!-- Droid User-Agent 配置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>自定义 User-Agent (可选)</label
>
<input
v-model="form.userAgent"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</p>
</div>
<div
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
>
@@ -2029,6 +2081,7 @@
<!-- 步骤2: OAuth授权 -->
<OAuthFlow
v-if="oauthStep === 2 && form.addType === 'oauth'"
ref="oauthFlowRef"
:platform="form.platform"
:proxy="form.proxy"
@back="oauthStep = 1"
@@ -2052,11 +2105,45 @@
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
Claude Setup Token 授权
</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<div class="space-y-4">
<!-- 授权方式选择 -->
<div class="mb-4">
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式:
</p>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-link mr-1" />手动授权
</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-cookie mr-1" />Cookie 自动授权
</span>
</label>
</div>
</div>
<!-- 手动授权流程 -->
<div v-if="authMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
@@ -2182,6 +2269,113 @@
</div>
</div>
</div>
<!-- Cookie自动授权流程 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
使用 sessionKey 自动完成授权,无需手动打开链接。
</p>
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="space-y-4">
<div>
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-cookie text-blue-500" />sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ parsedSessionKeyCount }} 个
</span>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': cookieAuthError }"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
{{ cookieAuthError }}
</p>
</div>
<!-- 帮助说明 -->
<div>
<button
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i
:class="
showSessionKeyHelp
? 'fas fa-chevron-down mr-1'
: 'fas fa-chevron-right mr-1'
"
/>
如何获取 sessionKey
</button>
<div
v-if="showSessionKeyHelp"
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
>
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
<li>
4. 在左侧选择 <strong>"Cookies"</strong> →
<strong>"https://claude.ai"</strong>
</li>
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
</ol>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以 "sk-ant-" 开头
</p>
</div>
</div>
<!-- 开始授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 授权中... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>提示:</strong>如果您设置了代理Cookie授权也会使用相同的代理配置。
</p>
</div>
</div>
</div>
</div>
</div>
@@ -2196,6 +2390,7 @@
上一步
</button>
<button
v-if="authMethod === 'manual'"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchangeSetupToken || setupTokenExchanging"
type="button"
@@ -2927,6 +3122,26 @@
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
</div>
</div>
<!-- 上游错误处理(编辑模式)-->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
上游错误处理
</label>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
@@ -3456,6 +3671,22 @@
</div>
</div>
<!-- Droid User-Agent 配置 (编辑模式) -->
<div v-if="form.platform === 'droid'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>自定义 User-Agent (可选)</label
>
<input
v-model="form.userAgent"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</p>
</div>
<!-- 代理设置 -->
<ProxyConfig v-model="form.proxy" />
@@ -3538,6 +3769,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
const isEdit = computed(() => !!props.account)
const show = ref(true)
// OAuthFlow 组件引用
const oauthFlowRef = ref(null)
// OAuth步骤
const oauthStep = ref(1)
const loading = ref(false)
@@ -3551,6 +3785,22 @@ const setupTokenAuthCode = ref('')
const setupTokenCopied = ref(false)
const setupTokenSessionId = ref('')
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// Claude Code 统一 User-Agent 信息
const unifiedUserAgent = ref('')
const clearingCache = ref(false)
@@ -3750,6 +4000,7 @@ const form = ref({
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection: props.account?.disableAutoProtection === true,
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
@@ -4193,10 +4444,198 @@ const exchangeSetupTokenCode = async () => {
}
}
// 处理OAuth成功
const handleOAuthSuccess = async (tokenInfo) => {
// =============================================================================
// Cookie自动授权相关方法
// =============================================================================
// Cookie自动授权支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
const isSetupToken = form.value.addType === 'setup-token'
const proxyPayload = buildProxyPayload(form.value.proxy)
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const payload = {
sessionKey: sessionKeys[i],
...(proxyPayload && { proxy: proxyPayload })
}
let result
if (isSetupToken) {
result = await accountsStore.oauthSetupTokenWithCookie(payload)
} else {
result = await accountsStore.oauthWithCookie(payload)
}
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
try {
// 成功后处理OAuth数据传递数组
// cookieAuthLoading 保持 true直到账号创建完成
await handleOAuthSuccess(results)
} finally {
cookieAuthLoading.value = false
}
} else {
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
showSessionKeyHelp.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
// 切换到手动模式时清除Cookie相关状态
if (authMethod.value === 'manual') {
resetCookieAuth()
} else {
// 切换到Cookie模式时清除手动授权状态
setupTokenAuthUrl.value = ''
setupTokenAuthCode.value = ''
setupTokenSessionId.value = ''
}
}
// 构建 Claude 账户数据(辅助函数)
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
const proxyPayload = buildProxyPayload(form.value.proxy)
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
const data = {
name: accountName,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload,
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true
}
}
// 处理 extInfo
if (claudeOauthPayload) {
const extInfoPayload = {}
const extSource = claudeOauthPayload.extInfo
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
if (!extSource) {
if (claudeOauthPayload.organization?.uuid) {
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
}
if (claudeOauthPayload.account?.uuid) {
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
}
}
if (Object.keys(extInfoPayload).length > 0) {
data.extInfo = extInfoPayload
}
}
return data
}
// 处理OAuth成功支持批量
const handleOAuthSuccess = async (tokenInfoOrList) => {
loading.value = true
try {
const currentPlatform = form.value.platform
// Claude 平台支持批量创建
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
const tokenInfoList = tokenInfoOrList
const isBatch = tokenInfoList.length > 1
const baseName = form.value.name
const results = []
const errors = []
for (let i = 0; i < tokenInfoList.length; i++) {
const tokenInfo = tokenInfoList[i]
// 批量时自动命名
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
// 如果启用统一客户端标识,为每个账户生成独立 ID
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
try {
const result = await accountsStore.createClaudeAccount(data)
results.push(result)
} catch (error) {
errors.push({ name: accountName, error: error.message })
}
}
// 处理结果
if (results.length > 0) {
const msg = isBatch
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
: '账户创建成功'
showToast(msg, 'success')
emit('success', results[0]) // 兼容单个创建的返回
}
if (errors.length > 0) {
showToast(`${errors.length} 个账户创建失败`, 'error')
}
return
}
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
// OAuth模式也需要确保生成客户端ID
if (
form.value.platform === 'claude' &&
@@ -4218,8 +4657,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
proxy: proxyPayload
}
const currentPlatform = form.value.platform
if (currentPlatform === 'claude') {
// Claude使用claudeAiOauth字段
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
@@ -4380,6 +4817,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
oauthFlowRef.value?.resetCookieAuth()
}
}
@@ -4665,6 +5104,10 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -4993,6 +5436,8 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -5614,7 +6059,9 @@ watch(
dailyUsage: newAccount.dailyUsage || 0,
quotaResetTime: newAccount.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true
}
// 如果是Claude Console账户加载实时使用情况

View File

@@ -44,12 +44,20 @@
</p>
</div>
</div>
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
@click="handleClose"
>
<i class="fas fa-times" />
</button>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
@click="goTimeline"
>
<i class="fas fa-clock" /> 请求时间线
</button>
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
@click="handleClose"
>
<i class="fas fa-times" />
</button>
</div>
</div>
<!-- 内容区域 -->
@@ -325,6 +333,7 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import Chart from 'chart.js/auto'
import { useThemeStore } from '@/stores/theme'
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore)
const router = useRouter()
const chartCanvas = ref(null)
let chartInstance = null
@@ -579,6 +589,14 @@ const handleClose = () => {
emit('close')
}
const goTimeline = () => {
if (!props.account?.id) return
router.push({
path: `/accounts/${props.account.id}/usage-records`,
query: { platform: props.account.platform || props.account.accountType }
})
}
watch(
() => props.show,
(visible) => {

View File

@@ -13,128 +13,269 @@
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<!-- 授权方式选择 -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式
</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
</label>
</div>
</div>
<!-- Cookie自动授权表单 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
使用 claude.ai sessionKey 自动完成 OAuth 授权流程无需手动打开浏览器
</p>
<!-- sessionKey输入 -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
<i class="fas fa-cookie text-blue-500" />
sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
{{ parsedSessionKeyCount }}
</span>
<button
class="text-blue-500 hover:text-blue-600"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i class="fas fa-question-circle" />
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y font-mono text-sm"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
</div>
<!-- 帮助说明 -->
<div
v-if="showSessionKeyHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
</h5>
<ol
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
>
<li>在浏览器中登录 <strong>claude.ai</strong></li>
<li>
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
打开开发者工具
</li>
<li>切换到 <strong>Application</strong>应用标签页</li>
<li>
在左侧找到 <strong>Cookies</strong> <strong>https://claude.ai</strong>
</li>
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
<li>复制其 <strong>Value</strong>列的内容</li>
</ol>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
开头
</p>
</div>
<!-- 错误信息 -->
<div
v-if="cookieAuthError"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1" />
{{ cookieAuthError }}
</p>
</div>
<!-- 授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<!-- 手动授权流程 -->
<div v-else>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接"
@click="copyAuthUrl"
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
<button
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
</div>
</div>
</div>
<!-- 步骤2: 访问链接并授权 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<!-- 步骤2: 访问链接并授权 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 步骤3: 输入授权码 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
<!-- 步骤3: 输入授权码 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
</p>
</div>
</div>
</div>
</div>
@@ -636,7 +777,9 @@
>
上一步
</button>
<!-- Cookie自动授权模式不显示此按钮Claude平台 -->
<button
v-if="!(platform === 'claude' && authMethod === 'cookie')"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchange || exchanging"
type="button"
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
const remainingSeconds = ref(0)
let countdownTimer = null
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// 计算是否可以交换code
const canExchange = computed(() => {
if (props.platform === 'droid') {
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
onBeforeUnmount(() => {
stopCountdown()
})
// Cookie自动授权处理支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
// 构建代理配置
const proxyConfig = props.proxy?.enabled
? {
type: props.proxy.type,
host: props.proxy.host,
port: parseInt(props.proxy.port),
username: props.proxy.username || null,
password: props.proxy.password || null
}
: null
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const result = await accountsStore.oauthWithCookie({
sessionKey: sessionKeys[i],
proxy: proxyConfig
})
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
// emit 后父组件会调用 handleOAuthSuccess 创建账号
// cookieAuthLoading 保持 true成功后表单会关闭失败时父组件会处理
emit('success', results) // 返回数组(单个时也是数组)
// 注意:不在这里设置 cookieAuthLoading = false
// 父组件创建账号完成后表单会关闭/重置
} else {
// 全部授权失败时才恢复按钮状态
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
cookieAuthLoading.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
resetCookieAuth()
authUrl.value = ''
authCode.value = ''
sessionId.value = ''
}
// 暴露方法供父组件调用
defineExpose({
resetCookieAuth
})
</script>

View File

@@ -0,0 +1,211 @@
<template>
<el-dialog
:append-to-body="true"
class="record-detail-modal"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:show-close="false"
top="10vh"
width="720px"
@close="emitClose"
>
<template #header>
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
请求详情
</p>
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ record?.model || '未知模型' }}
</p>
</div>
<button
aria-label="关闭"
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
@click="emitClose"
>
<i class="fas fa-times" />
</button>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">时间</span>
<span class="font-medium">{{ formattedTime }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">模型</span>
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">账户</span>
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">渠道</span>
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
</li>
</ul>
</div>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
<span class="font-semibold text-blue-600 dark:text-blue-400">
{{ formatNumber(record?.inputTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
<span class="font-semibold text-green-600 dark:text-green-400">
{{ formatNumber(record?.outputTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
<span class="font-semibold text-purple-600 dark:text-purple-400">
{{ formatNumber(record?.cacheCreateTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
<span class="font-semibold text-orange-600 dark:text-orange-400">
{{ formatNumber(record?.cacheReadTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">总计</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(record?.totalTokens) }}
</span>
</li>
</ul>
</div>
</div>
<div
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.input }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.output }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.cacheCreate }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.cacheRead }}
</span>
</div>
</div>
<div
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
{{ record?.costFormatted || formattedCosts.total }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<el-button type="primary" @click="emitClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
import dayjs from 'dayjs'
import { formatNumber } from '@/utils/format'
const props = defineProps({
show: {
type: Boolean,
default: false
},
record: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['close'])
const emitClose = () => emit('close')
const formattedTime = computed(() => {
if (!props.record?.timestamp) return '未知时间'
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
})
const formattedCosts = computed(() => {
const breakdown = props.record?.costBreakdown || {}
const formatValue = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`
if (num >= 0.001) return `$${num.toFixed(4)}`
return `$${num.toFixed(6)}`
}
return {
input: formatValue(breakdown.input),
output: formatValue(breakdown.output),
cacheCreate: formatValue(breakdown.cacheCreate),
cacheRead: formatValue(breakdown.cacheRead),
total: formatValue(breakdown.total)
}
})
</script>
<style scoped>
.record-detail-modal :deep(.el-dialog__header) {
margin: 0;
padding: 16px 16px 0;
}
.record-detail-modal :deep(.el-dialog__body) {
padding: 12px 16px 4px;
}
.record-detail-modal :deep(.el-dialog__footer) {
padding: 8px 16px 16px;
}
</style>

View File

@@ -231,6 +231,9 @@
<!-- 底部按钮 -->
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
<button class="btn btn-primary px-4 py-2 text-sm" type="button" @click="openTimeline">
查看请求时间线
</button>
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
关闭
</button>
@@ -256,7 +259,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'open-timeline'])
// 计算属性
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
@@ -320,6 +323,10 @@ const formatTokenCount = (count) => {
const close = () => {
emit('close')
}
const openTimeline = () => {
emit('open-timeline', props.apiKey?.id)
}
</script>
<style scoped>

View File

@@ -77,7 +77,21 @@ const getActionClass = (action) => {
return colorMap[action.color] || colorMap.gray
}
const instanceId = Symbol('action-dropdown')
const handleGlobalOpen = (event) => {
if (event?.detail?.id !== instanceId) {
closeDropdown()
}
}
const toggleDropdown = async () => {
if (!isOpen.value) {
window.dispatchEvent(
new CustomEvent('action-dropdown-open', {
detail: { id: instanceId }
})
)
}
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
const trigger = triggerRef.value.getBoundingClientRect()
const dropdownHeight = 200 // 预估高度
const dropdownWidth = 160 // 预估宽度
const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
const gap = 8
const spaceBelow = window.innerHeight - trigger.bottom
const spaceAbove = trigger.top
const spaceRight = window.innerWidth - trigger.right
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
// 计算垂直位置
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
top = trigger.bottom + 4
top = trigger.bottom + gap
} else {
top = trigger.top - dropdownHeight - 4
top = trigger.top - dropdownHeight - gap
}
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列)
if (spaceLeft >= dropdownWidth) {
left = trigger.left - dropdownWidth + trigger.width
} else if (spaceRight >= dropdownWidth) {
left = trigger.left
// 计算水平位置 - 优先向右展开,避免遮挡左侧内容
if (spaceRight >= dropdownWidth + gap) {
left = trigger.right + gap
} else if (spaceLeft >= dropdownWidth + gap) {
left = trigger.left - dropdownWidth - gap + trigger.width
} else {
left = window.innerWidth - dropdownWidth - 10
}
@@ -164,11 +179,13 @@ onMounted(() => {
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClickOutside)
window.addEventListener('action-dropdown-open', handleGlobalOpen)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
})
</script>

View File

@@ -41,19 +41,25 @@
<div
v-for="option in options"
:key="option.value"
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
:class="[
option.value === modelValue
isSelected(option.value)
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
: option.isGroup
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
]"
:style="{
paddingLeft: option.indent ? `${12 + option.indent * 16}px` : '12px',
paddingRight: '12px'
}"
@click="selectOption(option)"
>
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
<span>{{ option.label }}</span>
<i
v-if="option.value === modelValue"
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
v-if="isSelected(option.value)"
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
></i>
</div>
</div>
@@ -68,7 +74,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
type: [String, Number, Array],
default: ''
},
options: {
@@ -86,6 +92,10 @@ const props = defineProps({
iconColor: {
type: String,
default: 'text-gray-500'
},
multiple: {
type: Boolean,
default: false
}
})
@@ -96,7 +106,18 @@ const triggerRef = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
const isSelected = (value) => {
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
}
return props.modelValue === value
}
const selectedLabel = computed(() => {
if (props.multiple) {
const count = Array.isArray(props.modelValue) ? props.modelValue.length : 0
return count > 0 ? `已选 ${count}` : ''
}
const selected = props.options.find((opt) => opt.value === props.modelValue)
return selected ? selected.label : ''
})
@@ -114,9 +135,21 @@ const closeDropdown = () => {
}
const selectOption = (option) => {
emit('update:modelValue', option.value)
emit('change', option.value)
closeDropdown()
if (props.multiple) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const idx = current.indexOf(option.value)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(option.value)
}
emit('update:modelValue', current)
emit('change', current)
} else {
emit('update:modelValue', option.value)
emit('change', option.value)
closeDropdown()
}
}
const updateDropdownPosition = () => {

View File

@@ -11,7 +11,9 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
const MainLayout = () => import('@/components/layout/MainLayout.vue')
const DashboardView = () => import('@/views/DashboardView.vue')
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
const AccountsView = () => import('@/views/AccountsView.vue')
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
const TutorialView = () => import('@/views/TutorialView.vue')
const SettingsView = () => import('@/views/SettingsView.vue')
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
@@ -85,6 +87,18 @@ const routes = [
}
]
},
{
path: '/api-keys/:keyId/usage-records',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'ApiKeyUsageRecords',
component: ApiKeyUsageRecordsView
}
]
},
{
path: '/accounts',
component: MainLayout,
@@ -97,6 +111,18 @@ const routes = [
}
]
},
{
path: '/accounts/:accountId/usage-records',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'AccountUsageRecords',
component: AccountUsageRecordsView
}
]
},
{
path: '/tutorial',
component: MainLayout,

View File

@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// Cookie自动授权 - 普通OAuth
const oauthWithCookie = async (payload) => {
try {
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// Cookie自动授权 - Setup Token
const oauthSetupTokenWithCookie = async (payload) => {
try {
const response = await apiClient.post(
'/admin/claude-accounts/setup-token-with-cookie',
payload
)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 生成Gemini OAuth URL
const generateGeminiAuthUrl = async (proxyConfig) => {
try {
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
exchangeClaudeCode,
generateClaudeSetupTokenUrl,
exchangeClaudeSetupTokenCode,
oauthWithCookie,
oauthSetupTokenWithCookie,
generateGeminiAuthUrl,
exchangeGeminiCode,
generateOpenAIAuthUrl,

View File

@@ -67,22 +67,72 @@ export const useDashboardStore = defineStore('dashboard', () => {
groupLabel: 'Claude账户'
})
// 本地偏好
const STORAGE_KEYS = {
preset: 'dashboard:date:preset',
granularity: 'dashboard:trend:granularity'
}
const defaultPreset = 'today'
const defaultGranularity = 'day'
const getPresetOptions = (granularity) =>
granularity === 'hour'
? [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
const readFromStorage = (key, fallback) => {
try {
const value = localStorage.getItem(key)
return value || fallback
} catch (error) {
return fallback
}
}
const saveToStorage = (key, value) => {
try {
localStorage.setItem(key, value)
} catch (error) {
// 忽略存储错误,避免影响渲染
}
}
const normalizePresetForGranularity = (preset, granularity) => {
const options = getPresetOptions(granularity)
const hasPreset = options.some((opt) => opt.value === preset)
if (hasPreset) return preset
return granularity === 'hour' ? 'last24h' : defaultPreset
}
const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity)
const initialGranularity = ['day', 'hour'].includes(storedGranularity)
? storedGranularity
: defaultGranularity
const initialPreset = normalizePresetForGranularity(
readFromStorage(STORAGE_KEYS.preset, defaultPreset),
initialGranularity
)
// 日期筛选
const dateFilter = ref({
type: 'preset', // preset 或 custom
preset: '7days', // today, 7days, 30days
preset: initialPreset, // today, 7days, 30days
customStart: '',
customEnd: '',
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
presetOptions: getPresetOptions(initialGranularity)
})
// 趋势图粒度
const trendGranularity = ref('day') // 'day' 或 'hour'
const trendGranularity = ref(initialGranularity) // 'day' 或 'hour'
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
const accountUsageGroup = ref('claude') // claude | openai | gemini
@@ -138,6 +188,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
const persistDatePreferences = (
preset = dateFilter.value.preset,
granularity = trendGranularity.value
) => {
saveToStorage(STORAGE_KEYS.preset, preset)
saveToStorage(STORAGE_KEYS.granularity, granularity)
}
const getEffectiveGranularity = () =>
dateFilter.value.type === 'preset' &&
dateFilter.value.preset === 'today' &&
trendGranularity.value === 'day'
? 'hour'
: trendGranularity.value
// 方法
async function loadDashboardData(timeRange = null) {
loading.value = true
@@ -232,7 +297,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadUsageTrend(days = 7, granularity = 'day') {
async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) {
try {
let url = '/admin/usage-trend?'
@@ -241,26 +306,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
// 使用预设计算时间范围与loadApiKeysTrend保持一致
const now = new Date()
@@ -268,6 +316,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
// 今日使用系统时区的当日0点-23:59
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
@@ -318,34 +372,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadModelStats(period = 'daily') {
async function loadModelStats(period = 'daily', granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = `/admin/model-stats?period=${period}`
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
const systemTz = 8
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
// 系统时区字符串直传,避免额外时区转换
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
// 小时粒度的预设时间范围
const now = new Date()
let startTime, endTime
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
@@ -374,7 +422,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
// 天粒度的预设时间范围需要传递startDate和endDate参数
const now = new Date()
let startDate, endDate
@@ -409,36 +457,20 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadApiKeysTrend(metric = 'requests') {
async function loadApiKeysTrend(metric = 'requests', granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = '/admin/api-keys-usage-trend?'
let days = 7
if (trendGranularity.value === 'hour') {
if (currentGranularity === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
// 使用自定义时间范围 - 系统时区字符串直传
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
// 使用预设计算时间范围与setDateFilterPreset保持一致
const now = new Date()
@@ -446,6 +478,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
@@ -511,29 +548,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = '/admin/account-usage-trend?'
let days = 7
if (trendGranularity.value === 'hour') {
if (currentGranularity === 'hour') {
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
const convertToUTC = (systemTzTimeStr) => {
const systemTz = 8
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
const now = new Date()
let startTime
@@ -541,6 +567,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
@@ -603,110 +634,87 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
// 日期筛选相关方法
function setDateFilterPreset(preset) {
function setDateFilterPreset(preset, options = {}) {
const { silent = false, skipSave = false } = options
const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value)
dateFilter.value.type = 'preset'
dateFilter.value.preset = preset
dateFilter.value.preset = normalizedPreset
// 根据预设计算并设置具体的日期范围
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
if (option) {
const now = new Date()
let startDate, endDate
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
const now = new Date()
let startDate
let endDate
if (trendGranularity.value === 'hour') {
// 小时粒度的预设
switch (preset) {
case 'last24h': {
// 近24小时从当前时间往前推24小时
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
// 昨天:获取本地时间的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
// 转换为系统时区的昨天0点和23:59
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
// 前天:获取本地时间的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
// 转换为系统时区的前天0点和23:59
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
if (trendGranularity.value === 'hour') {
switch (normalizedPreset) {
case 'today': {
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
break
}
} else {
// 天粒度的预设
startDate = new Date(now)
endDate = new Date(now)
if (preset === 'today') {
// 今日:从凌晨开始
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else {
// 其他预设:按天数计算
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
case 'last24h': {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
}
}
} else {
startDate = new Date(now)
endDate = new Date(now)
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
// 设置 customRange 为 Element Plus 需要的格式
// 对于小时粒度的昨天/前天,需要特殊处理显示
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
// 获取本地日期
const targetDate = new Date()
if (preset === 'yesterday') {
targetDate.setDate(targetDate.getDate() - 1)
} else {
targetDate.setDate(targetDate.getDate() - 2)
}
// 显示系统时区的完整一天
const year = targetDate.getFullYear()
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
const day = String(targetDate.getDate()).padStart(2, '0')
dateFilter.value.customRange = [
`${year}-${month}-${day} 00:00:00`,
`${year}-${month}-${day} 23:59:59`
]
} else {
// 其他情况近24小时或天粒度
const formatDateForDisplay = (date) => {
// 固定使用UTC+8来显示时间
const systemTz = 8
const tzOffset = systemTz * 60 * 60 * 1000
const localTime = new Date(date.getTime() + tzOffset)
const year = localTime.getUTCFullYear()
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(localTime.getUTCDate()).padStart(2, '0')
const hours = String(localTime.getUTCHours()).padStart(2, '0')
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customRange = [
formatDateForDisplay(startDate),
formatDateForDisplay(endDate)
]
if (normalizedPreset === 'today') {
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else if (option?.days) {
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
}
}
// 触发数据刷新
refreshChartsData()
const formatDateForDisplay = (date) => {
// 使用本地时间直接格式化,避免多余的时区偏移导致日期错位
const localTime = new Date(date)
const year = localTime.getFullYear()
const month = String(localTime.getMonth() + 1).padStart(2, '0')
const day = String(localTime.getDate()).padStart(2, '0')
const hours = String(localTime.getHours()).padStart(2, '0')
const minutes = String(localTime.getMinutes()).padStart(2, '0')
const seconds = String(localTime.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customStart = startDate ? startDate.toISOString().split('T')[0] : ''
dateFilter.value.customEnd = endDate ? endDate.toISOString().split('T')[0] : ''
dateFilter.value.customRange =
startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
}
function onCustomDateRangeChange(value) {
@@ -751,20 +759,17 @@ export const useDashboardStore = defineStore('dashboard', () => {
refreshChartsData()
} else if (value === null) {
// 清空时恢复默认
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset)
}
}
function setTrendGranularity(granularity) {
function setTrendGranularity(granularity, options = {}) {
const { silent = false, skipSave = false, presetOverride } = options
trendGranularity.value = granularity
// 根据粒度更新预设选项
if (granularity === 'hour') {
dateFilter.value.presetOptions = [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
dateFilter.value.presetOptions = getPresetOptions('hour')
// 检查当前自定义日期范围是否超过24小时
if (
@@ -777,46 +782,53 @@ export const useDashboardStore = defineStore('dashboard', () => {
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时已切换到近24小时', 'warning')
setDateFilterPreset('last24h')
setDateFilterPreset('last24h', { silent, skipSave })
return
}
}
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
setDateFilterPreset('last24h')
return
}
} else {
// 天粒度
dateFilter.value.presetOptions = [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
setDateFilterPreset('7days')
return
}
dateFilter.value.presetOptions = getPresetOptions('day')
}
// 触发数据刷新
refreshChartsData()
if (dateFilter.value.type === 'custom') {
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
return
}
const nextPreset =
presetOverride ||
normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value)
setDateFilterPreset(nextPreset, { silent: true, skipSave: true })
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
}
async function refreshChartsData() {
// 根据当前筛选条件刷新数据
let days
let modelPeriod = 'monthly'
const effectiveGranularity = getEffectiveGranularity()
if (dateFilter.value.type === 'preset') {
const option = dateFilter.value.presetOptions.find(
(opt) => opt.value === dateFilter.value.preset
)
if (trendGranularity.value === 'hour') {
if (effectiveGranularity === 'hour') {
// 小时粒度
days = 1 // 小时粒度默认查看1天的数据
modelPeriod = 'daily' // 小时粒度使用日统计
@@ -832,7 +844,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
} else {
// 自定义日期范围
if (trendGranularity.value === 'hour') {
if (effectiveGranularity === 'hour') {
// 小时粒度下的自定义范围,计算小时数
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
@@ -845,16 +857,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
await Promise.all([
loadUsageTrend(days, trendGranularity.value),
loadModelStats(modelPeriod),
loadApiKeysTrend(apiKeysTrendMetric.value),
loadAccountUsageTrend(accountUsageGroup.value)
loadUsageTrend(days, effectiveGranularity),
loadModelStats(modelPeriod, effectiveGranularity),
loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity),
loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity)
])
}
function setAccountUsageGroup(group) {
accountUsageGroup.value = group
return loadAccountUsageTrend(group)
return loadAccountUsageTrend(group, getEffectiveGranularity())
}
function calculateDaysBetween(start, end) {
@@ -870,6 +882,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
return date > new Date()
}
// 初始化日期筛选:同步本地偏好并填充范围
setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true })
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
return {
// 状态
loading,

View File

@@ -0,0 +1,574 @@
<template>
<div class="space-y-4 p-4 lg:p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<button
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
@click="goBack"
>
返回
</button>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
账户请求详情时间线
</p>
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
{{ accountDisplayName }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">渠道{{ platformDisplayName }}</p>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-clock text-blue-500" />
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
<span v-else>显示近 5000 条记录</span>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(summary.totalRequests) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400"> Token</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(summary.totalTokens) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{{ formatCost(summary.totalCost) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatCost(summary.avgCost) }}
</p>
</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex flex-wrap items-center gap-3">
<el-date-picker
v-model="filters.dateRange"
class="max-w-[320px]"
clearable
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
type="datetimerange"
unlink-panels
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
/>
<el-select
v-model="filters.model"
class="w-[180px]"
clearable
filterable
placeholder="所有模型"
>
<el-option
v-for="modelOption in availableModels"
:key="modelOption"
:label="modelOption"
:value="modelOption"
/>
</el-select>
<el-select
v-model="filters.apiKeyId"
class="w-[220px]"
clearable
filterable
placeholder="所有 API Key"
>
<el-option
v-for="apiKey in availableApiKeys"
:key="apiKey.id"
:label="apiKey.name || apiKey.id"
:value="apiKey.id"
/>
</el-select>
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
<el-option label="时间降序" value="desc" />
<el-option label="时间升序" value="asc" />
</el-select>
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
<el-button :loading="exporting" type="primary" @click="exportCsv">
<i class="fas fa-file-export mr-2" /> 导出 CSV
</el-button>
</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div
v-if="loading"
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
</div>
<div v-else>
<div
v-if="records.length === 0"
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-inbox text-2xl" />
<p>暂无记录</p>
</div>
<div v-else class="space-y-4">
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
时间
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
API Key
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
模型
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
输入
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
输出
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
缓存(/)
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
Token
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
费用
</th>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
>
<tr v-for="record in records" :key="record.timestamp + record.model">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ formatDate(record.timestamp) }}
</td>
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
<div class="flex flex-col">
<span class="font-semibold">
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
ID: {{ record.apiKeyId }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ record.model }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
{{ formatNumber(record.inputTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
>
{{ formatNumber(record.outputTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
>
{{ formatNumber(record.cacheCreateTokens) }} /
{{ formatNumber(record.cacheReadTokens) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ formatNumber(record.totalTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
>
{{ record.costFormatted || formatCost(record.cost) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<el-button size="small" @click="openDetail(record)">详情</el-button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-3 md:hidden">
<div
v-for="record in records"
:key="record.timestamp + record.model"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
渠道{{ platformDisplayName }}
</p>
</div>
<el-button size="small" @click="openDetail(record)">详情</el-button>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
<div>模型{{ record.model }}</div>
<div> Token{{ formatNumber(record.totalTokens) }}</div>
<div>输入{{ formatNumber(record.inputTokens) }}</div>
<div>输出{{ formatNumber(record.outputTokens) }}</div>
<div>
缓存创/{{ formatNumber(record.cacheCreateTokens) }} /
{{ formatNumber(record.cacheReadTokens) }}
</div>
<div class="text-yellow-600 dark:text-yellow-400">
费用{{ record.costFormatted || formatCost(record.cost) }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between px-4 pb-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ pagination.totalRecords }} 条记录
</div>
<el-pagination
background
:current-page="pagination.currentPage"
layout="prev, pager, next, sizes"
:page-size="pagination.pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="pagination.totalRecords"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</div>
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { useRoute, useRouter } from 'vue-router'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import { formatNumber } from '@/utils/format'
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
const route = useRoute()
const router = useRouter()
const accountId = computed(() => route.params.accountId)
const platform = computed(() => route.query.platform)
const loading = ref(false)
const exporting = ref(false)
const records = ref([])
const availableModels = ref([])
const availableApiKeys = ref([])
const pagination = reactive({
currentPage: 1,
pageSize: 50,
totalRecords: 0
})
const filters = reactive({
dateRange: null,
model: '',
apiKeyId: '',
sortOrder: 'desc'
})
const summary = reactive({
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
avgCost: 0
})
const accountInfo = reactive({
id: accountId.value,
name: '',
platform: platform.value || ''
})
const detailVisible = ref(false)
const activeRecord = ref(null)
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
const platformDisplayName = computed(() => {
const map = {
claude: 'Claude官方',
'claude-console': 'Claude Console',
ccr: 'Claude Console Relay',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid',
unknown: '未知渠道'
}
const key = accountInfo.platform || platform.value || 'unknown'
return map[key] || '未知渠道'
})
const dateRangeHint = computed(() => {
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
})
const formatDate = (value) => {
if (!value) return '--'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatCost = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`
if (num >= 0.001) return `$${num.toFixed(4)}`
return `$${num.toFixed(6)}`
}
const buildParams = (page) => {
const params = {
page,
pageSize: pagination.pageSize,
sortOrder: filters.sortOrder
}
if (filters.model) params.model = filters.model
if (filters.apiKeyId) params.apiKeyId = filters.apiKeyId
if (filters.dateRange && filters.dateRange.length === 2) {
params.startDate = dayjs(filters.dateRange[0]).toISOString()
params.endDate = dayjs(filters.dateRange[1]).toISOString()
}
if (platform.value) {
params.platform = platform.value
}
return params
}
const syncResponseState = (data) => {
records.value = data.records || []
const pageInfo = data.pagination || {}
pagination.currentPage = pageInfo.currentPage || 1
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
pagination.totalRecords = pageInfo.totalRecords || 0
const filterEcho = data.filters || {}
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
if (filterEcho.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
if (filterEcho.startDate && filterEcho.endDate) {
const nextRange = [filterEcho.startDate, filterEcho.endDate]
const currentRange = filters.dateRange || []
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
filters.dateRange = nextRange
}
}
const summaryData = data.summary || {}
summary.totalRequests = summaryData.totalRequests || 0
summary.totalTokens = summaryData.totalTokens || 0
summary.totalCost = summaryData.totalCost || 0
summary.avgCost = summaryData.avgCost || 0
accountInfo.id = data.accountInfo?.id || accountId.value
accountInfo.name = data.accountInfo?.name || ''
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
availableModels.value = data.availableFilters?.models || []
availableApiKeys.value = data.availableFilters?.apiKeys || []
}
const fetchRecords = async (page = pagination.currentPage) => {
loading.value = true
try {
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
params: buildParams(page)
})
syncResponseState(response.data || {})
} catch (error) {
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
} finally {
loading.value = false
}
}
const handlePageChange = (page) => {
pagination.currentPage = page
fetchRecords(page)
}
const handleSizeChange = (size) => {
pagination.pageSize = size
pagination.currentPage = 1
fetchRecords(1)
}
const resetFilters = () => {
filters.model = ''
filters.apiKeyId = ''
filters.dateRange = null
filters.sortOrder = 'desc'
pagination.currentPage = 1
fetchRecords(1)
}
const openDetail = (record) => {
activeRecord.value = record
detailVisible.value = true
}
const closeDetail = () => {
detailVisible.value = false
activeRecord.value = null
}
const goBack = () => {
router.push('/accounts')
}
const exportCsv = async () => {
if (exporting.value) return
exporting.value = true
try {
const aggregated = []
let page = 1
let totalPages = 1
const maxPages = 50 // 50 * 200 = 10000超过后端 5000 上限已足够
while (page <= totalPages && page <= maxPages) {
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
params: { ...buildParams(page), pageSize: 200 }
})
const payload = response.data || {}
aggregated.push(...(payload.records || []))
totalPages = payload.pagination?.totalPages || 1
page += 1
}
if (aggregated.length === 0) {
showToast('没有可导出的记录', 'info')
return
}
const headers = [
'时间',
'API Key',
'模型',
'输入Token',
'输出Token',
'缓存创建Token',
'缓存读取Token',
'总Token',
'费用'
]
const csvRows = [headers.join(',')]
aggregated.forEach((record) => {
const row = [
formatDate(record.timestamp),
record.apiKeyName || record.apiKeyId || '',
record.model || '',
record.inputTokens || 0,
record.outputTokens || 0,
record.cacheCreateTokens || 0,
record.cacheReadTokens || 0,
record.totalTokens || 0,
record.costFormatted || formatCost(record.cost)
]
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob([csvRows.join('\n')], {
type: 'text/csv;charset=utf-8;'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `account-${accountId.value}-usage-records.csv`
link.click()
URL.revokeObjectURL(url)
showToast('导出 CSV 成功', 'success')
} catch (error) {
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
} finally {
exporting.value = false
}
}
watch(
() => [filters.model, filters.apiKeyId, filters.sortOrder],
() => {
pagination.currentPage = 1
fetchRecords(1)
}
)
watch(
() => filters.dateRange,
() => {
pagination.currentPage = 1
fetchRecords(1)
},
{ deep: true }
)
onMounted(() => {
fetchRecords()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,549 @@
<template>
<div class="space-y-4 p-4 lg:p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<button
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
@click="goBack"
>
返回
</button>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
API Key 请求详情时间线
</p>
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
{{ apiKeyDisplayName }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</p>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-clock text-blue-500" />
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
<span v-else>显示近 5000 条记录</span>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(summary.totalRequests) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400"> Token</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(summary.totalTokens) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{{ formatCost(summary.totalCost) }}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatCost(summary.avgCost) }}
</p>
</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex flex-wrap items-center gap-3">
<el-date-picker
v-model="filters.dateRange"
class="max-w-[320px]"
clearable
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
type="datetimerange"
unlink-panels
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
/>
<el-select
v-model="filters.model"
class="w-[180px]"
clearable
filterable
placeholder="所有模型"
>
<el-option
v-for="modelOption in availableModels"
:key="modelOption"
:label="modelOption"
:value="modelOption"
/>
</el-select>
<el-select
v-model="filters.accountId"
class="w-[220px]"
clearable
filterable
placeholder="所有账户"
>
<el-option
v-for="account in availableAccounts"
:key="account.id"
:label="`${account.name}${account.accountTypeName}`"
:value="account.id"
/>
</el-select>
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
<el-option label="时间降序" value="desc" />
<el-option label="时间升序" value="asc" />
</el-select>
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
<el-button :loading="exporting" type="primary" @click="exportCsv">
<i class="fas fa-file-export mr-2" /> 导出 CSV
</el-button>
</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div
v-if="loading"
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
</div>
<div v-else>
<div
v-if="records.length === 0"
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-inbox text-2xl" />
<p>暂无记录</p>
</div>
<div v-else class="space-y-4">
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
时间
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
账户
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
模型
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
输入
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
输出
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
缓存(/)
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
Token
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
费用
</th>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
>
<tr v-for="record in records" :key="record.timestamp + record.model">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ formatDate(record.timestamp) }}
</td>
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
<div class="flex flex-col">
<span class="font-semibold">{{ record.accountName || '未知账户' }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ record.accountTypeName || '未知渠道' }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ record.model }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
{{ formatNumber(record.inputTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
>
{{ formatNumber(record.outputTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
>
{{ formatNumber(record.cacheCreateTokens) }} /
{{ formatNumber(record.cacheReadTokens) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
{{ formatNumber(record.totalTokens) }}
</td>
<td
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
>
{{ record.costFormatted || formatCost(record.cost) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<el-button size="small" @click="openDetail(record)">详情</el-button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-3 md:hidden">
<div
v-for="record in records"
:key="record.timestamp + record.model"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ record.accountName || '未知账户' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDate(record.timestamp) }}
</p>
</div>
<el-button size="small" @click="openDetail(record)">详情</el-button>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
<div>模型{{ record.model }}</div>
<div> Token{{ formatNumber(record.totalTokens) }}</div>
<div>输入{{ formatNumber(record.inputTokens) }}</div>
<div>输出{{ formatNumber(record.outputTokens) }}</div>
<div>
缓存创/{{ formatNumber(record.cacheCreateTokens) }} /
{{ formatNumber(record.cacheReadTokens) }}
</div>
<div class="text-yellow-600 dark:text-yellow-400">
费用{{ record.costFormatted || formatCost(record.cost) }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between px-4 pb-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ pagination.totalRecords }} 条记录
</div>
<el-pagination
background
:current-page="pagination.currentPage"
layout="prev, pager, next, sizes"
:page-size="pagination.pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="pagination.totalRecords"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</div>
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { useRoute, useRouter } from 'vue-router'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import { formatNumber } from '@/utils/format'
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
const route = useRoute()
const router = useRouter()
const keyId = computed(() => route.params.keyId)
const loading = ref(false)
const exporting = ref(false)
const records = ref([])
const availableModels = ref([])
const availableAccounts = ref([])
const pagination = reactive({
currentPage: 1,
pageSize: 50,
totalRecords: 0
})
const filters = reactive({
dateRange: null,
model: '',
accountId: '',
sortOrder: 'desc'
})
const summary = reactive({
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
avgCost: 0
})
const apiKeyInfo = reactive({
id: keyId.value,
name: ''
})
const detailVisible = ref(false)
const activeRecord = ref(null)
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
const dateRangeHint = computed(() => {
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
})
const formatDate = (value) => {
if (!value) return '--'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatCost = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`
if (num >= 0.001) return `$${num.toFixed(4)}`
return `$${num.toFixed(6)}`
}
const buildParams = (page) => {
const params = {
page,
pageSize: pagination.pageSize,
sortOrder: filters.sortOrder
}
if (filters.model) params.model = filters.model
if (filters.accountId) params.accountId = filters.accountId
if (filters.dateRange && filters.dateRange.length === 2) {
params.startDate = dayjs(filters.dateRange[0]).toISOString()
params.endDate = dayjs(filters.dateRange[1]).toISOString()
}
return params
}
const syncResponseState = (data) => {
records.value = data.records || []
const pageInfo = data.pagination || {}
pagination.currentPage = pageInfo.currentPage || 1
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
pagination.totalRecords = pageInfo.totalRecords || 0
const filterEcho = data.filters || {}
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
if (filterEcho.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
if (filterEcho.startDate && filterEcho.endDate) {
const nextRange = [filterEcho.startDate, filterEcho.endDate]
const currentRange = filters.dateRange || []
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
filters.dateRange = nextRange
}
}
const summaryData = data.summary || {}
summary.totalRequests = summaryData.totalRequests || 0
summary.totalTokens = summaryData.totalTokens || 0
summary.totalCost = summaryData.totalCost || 0
summary.avgCost = summaryData.avgCost || 0
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
apiKeyInfo.name = data.apiKeyInfo?.name || ''
availableModels.value = data.availableFilters?.models || []
availableAccounts.value = data.availableFilters?.accounts || []
}
const fetchRecords = async (page = pagination.currentPage) => {
loading.value = true
try {
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
params: buildParams(page)
})
syncResponseState(response.data || {})
} catch (error) {
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
} finally {
loading.value = false
}
}
const handlePageChange = (page) => {
pagination.currentPage = page
fetchRecords(page)
}
const handleSizeChange = (size) => {
pagination.pageSize = size
pagination.currentPage = 1
fetchRecords(1)
}
const resetFilters = () => {
filters.model = ''
filters.accountId = ''
filters.dateRange = null
filters.sortOrder = 'desc'
pagination.currentPage = 1
fetchRecords(1)
}
const openDetail = (record) => {
activeRecord.value = record
detailVisible.value = true
}
const closeDetail = () => {
detailVisible.value = false
activeRecord.value = null
}
const goBack = () => {
router.push('/api-keys')
}
const exportCsv = async () => {
if (exporting.value) return
exporting.value = true
try {
const aggregated = []
let page = 1
let totalPages = 1
const maxPages = 50 // 50 * 200 = 10000超过后端 5000 上限已足够
while (page <= totalPages && page <= maxPages) {
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
params: { ...buildParams(page), pageSize: 200 }
})
const payload = response.data || {}
aggregated.push(...(payload.records || []))
totalPages = payload.pagination?.totalPages || 1
page += 1
}
if (aggregated.length === 0) {
showToast('没有可导出的记录', 'info')
return
}
const headers = [
'时间',
'账户',
'渠道',
'模型',
'输入Token',
'输出Token',
'缓存创建Token',
'缓存读取Token',
'总Token',
'费用'
]
const csvRows = [headers.join(',')]
aggregated.forEach((record) => {
const row = [
formatDate(record.timestamp),
record.accountName || '',
record.accountTypeName || '',
record.model || '',
record.inputTokens || 0,
record.outputTokens || 0,
record.cacheCreateTokens || 0,
record.cacheReadTokens || 0,
record.totalTokens || 0,
record.costFormatted || formatCost(record.cost)
]
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob([csvRows.join('\n')], {
type: 'text/csv;charset=utf-8;'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `api-key-${keyId.value}-usage-records.csv`
link.click()
URL.revokeObjectURL(url)
showToast('导出 CSV 成功', 'success')
} catch (error) {
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
} finally {
exporting.value = false
}
}
watch(
() => [filters.model, filters.accountId, filters.sortOrder],
() => {
pagination.currentPage = 1
fetchRecords(1)
}
)
watch(
() => filters.dateRange,
() => {
pagination.currentPage = 1
fetchRecords(1)
},
{ deep: true }
)
onMounted(() => {
fetchRecords()
})
</script>

View File

@@ -116,6 +116,29 @@
</div>
</div>
<!-- 模型筛选器 -->
<div class="group relative min-w-[140px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<div class="relative">
<CustomDropdown
v-model="selectedModels"
icon="fa-cube"
icon-color="text-orange-500"
:multiple="true"
:options="modelOptions"
placeholder="所有模型"
/>
<span
v-if="selectedModels.length > 0"
class="absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-xs text-white shadow-sm"
>
{{ selectedModels.length }}
</span>
</div>
</div>
<!-- 搜索模式与搜索框 -->
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
<div class="sm:w-44">
@@ -2085,17 +2108,18 @@
@save="handleSaveExpiry"
/>
<!-- 使用详情弹窗 -->
<UsageDetailModal
:api-key="selectedApiKeyForDetail || {}"
:show="showUsageDetailModal"
@close="showUsageDetailModal = false"
@open-timeline="openTimeline"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
@@ -2114,6 +2138,7 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
// 响应式数据
const router = useRouter()
const clientsStore = useClientsStore()
const authStore = useAuthStore()
const apiKeys = ref([])
@@ -2218,6 +2243,10 @@ const selectedApiKeyForDetail = ref(null)
const selectedTagFilter = ref('')
const availableTags = ref([])
// 模型筛选相关
const selectedModels = ref([])
const availableModels = ref([])
// 搜索相关
const searchKeyword = ref('')
const searchMode = ref('apiKey')
@@ -2234,6 +2263,14 @@ const tagOptions = computed(() => {
return options
})
const modelOptions = computed(() => {
return availableModels.value.map((model) => ({
value: model,
label: model,
icon: 'fa-cube'
}))
})
const selectedTagCount = computed(() => {
if (!selectedTagFilter.value) return 0
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
@@ -2472,6 +2509,18 @@ const loadAccounts = async (forceRefresh = false) => {
}
}
// 加载已使用的模型列表
const loadUsedModels = async () => {
try {
const data = await apiClient.get('/admin/api-keys/used-models')
if (data.success) {
availableModels.value = data.data || []
}
} catch (error) {
console.error('Failed to load used models:', error)
}
}
// 加载API Keys使用后端分页
const loadApiKeys = async (clearStatsCache = true) => {
apiKeysLoading.value = true
@@ -2500,6 +2549,11 @@ const loadApiKeys = async (clearStatsCache = true) => {
params.set('tag', selectedTagFilter.value)
}
// 模型筛选参数
if (selectedModels.value.length > 0) {
params.set('models', selectedModels.value.join(','))
}
// 排序参数(支持费用排序)
const validSortFields = [
'name',
@@ -4193,19 +4247,15 @@ const formatWindowTime = (seconds) => {
// 显示使用详情
const showUsageDetails = (apiKey) => {
// 获取异步加载的统计数据
const cachedStats = getCachedStats(apiKey.id)
// 合并异步统计数据到 apiKey 对象
const enrichedApiKey = {
...apiKey,
// 合并实时限制数据
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
// 合并 usage 数据(用于详情弹窗中的统计卡片)
usage: {
...apiKey.usage,
total: {
@@ -4226,6 +4276,13 @@ const showUsageDetails = (apiKey) => {
showUsageDetailModal.value = true
}
const openTimeline = (keyId) => {
const id = keyId || selectedApiKeyForDetail.value?.id
if (!id) return
showUsageDetailModal.value = false
router.push(`/api-keys/${id}/usage-records`)
}
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
// const formatTime = (seconds) => {
// if (seconds === null || seconds === undefined) return '--:--'
@@ -4706,6 +4763,12 @@ watch(selectedTagFilter, () => {
loadApiKeys(false)
})
// 监听模型筛选变化
watch(selectedModels, () => {
currentPage.value = 1
loadApiKeys(false)
})
// 监听排序变化,重新加载数据
watch([apiKeysSortBy, apiKeysSortOrder], () => {
loadApiKeys(false)
@@ -4740,7 +4803,7 @@ onMounted(async () => {
fetchCostSortStatus()
// 先加载 API Keys优先显示列表
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys(), loadUsedModels()])
// 初始化全选状态
updateSelectAllState()

View File

@@ -1036,6 +1036,7 @@ function createUsageTrendChart() {
type: 'linear',
display: true,
position: 'left',
min: 0,
title: {
display: true,
text: 'Token数量',
@@ -1055,6 +1056,7 @@ function createUsageTrendChart() {
type: 'linear',
display: true,
position: 'right',
min: 0,
title: {
display: true,
text: '请求数',
@@ -1073,7 +1075,8 @@ function createUsageTrendChart() {
y2: {
type: 'linear',
display: false, // 隐藏费用轴在tooltip中显示
position: 'right'
position: 'right',
min: 0
}
}
}
@@ -1253,6 +1256,7 @@ function createApiKeysUsageTrendChart() {
},
y: {
beginAtZero: true,
min: 0,
title: {
display: true,
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
@@ -1428,6 +1432,7 @@ function createAccountUsageTrendChart() {
},
y: {
beginAtZero: true,
min: 0,
title: {
display: true,
text: '消耗金额 (USD)',

View File

@@ -36,6 +36,18 @@
<i class="fas fa-bell mr-2"></i>
通知设置
</button>
<button
:class="[
'border-b-2 pb-2 text-sm font-medium transition-colors',
activeSection === 'claude'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="activeSection = 'claude'"
>
<i class="fas fa-robot mr-2"></i>
Claude 转发
</button>
</nav>
</div>
@@ -629,6 +641,276 @@
</button>
</div>
</div>
<!-- Claude 转发配置部分 -->
<div v-show="activeSection === 'claude'">
<!-- 加载状态 -->
<div v-if="claudeConfigLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
</div>
<div v-else>
<!-- Claude Code 客户端限制 -->
<div
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center">
<div
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-orange-500 to-amber-600 text-white shadow-lg"
>
<i class="fas fa-terminal"></i>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
仅允许 Claude Code 客户端
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
启用后所有
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
>/api/v1/messages</code
>
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
>/claude/v1/messages</code
>
端点将强制验证 Claude Code CLI 客户端
</p>
</div>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="claudeConfig.claudeCodeOnlyEnabled"
class="peer sr-only"
type="checkbox"
@change="saveClaudeConfig"
/>
<div
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-orange-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-orange-800"
></div>
</label>
</div>
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<i class="fas fa-info-circle mt-0.5 text-amber-500"></i>
<div class="ml-3">
<p class="text-sm text-amber-700 dark:text-amber-300">
此设置与 API Key 级别的客户端限制是 <strong>OR 逻辑</strong>全局启用或 API
Key 设置中启用都会执行 Claude Code 验证
</p>
</div>
</div>
</div>
</div>
<!-- 全局会话绑定 -->
<div
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center">
<div
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-lg"
>
<i class="fas fa-link"></i>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
强制会话绑定
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
启用后系统会将原始会话 ID 绑定到首次使用的账户确保上下文的一致性
</p>
</div>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="claudeConfig.globalSessionBindingEnabled"
class="peer sr-only"
type="checkbox"
@change="saveClaudeConfig"
/>
<div
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-purple-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-purple-800"
></div>
</label>
</div>
<!-- 绑定配置详情仅在启用时显示 -->
<div v-if="claudeConfig.globalSessionBindingEnabled" class="mt-6 space-y-4">
<!-- 绑定有效期 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-clock mr-2 text-gray-400"></i>
绑定有效期
</label>
<input
v-model.number="claudeConfig.sessionBindingTtlDays"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="365"
min="1"
placeholder="30"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
会话绑定到账户后的有效时间过期后会自动解除绑定
</p>
</div>
<!-- 错误提示消息 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-exclamation-triangle mr-2 text-gray-400"></i>
旧会话污染提示
</label>
<textarea
v-model="claudeConfig.sessionBindingErrorMessage"
class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
placeholder="你的本地session已污染请清理后使用。"
rows="2"
@change="saveClaudeConfig"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当检测到为旧的sessionId且未在系统中有调度记录时提示返回给客户端的错误消息
</p>
</div>
</div>
<div class="mt-4 rounded-lg bg-purple-50 p-4 dark:bg-purple-900/20">
<div class="flex">
<i class="fas fa-lightbulb mt-0.5 text-purple-500"></i>
<div class="ml-3">
<p class="text-sm text-purple-700 dark:text-purple-300">
<strong>工作原理</strong>系统会提取请求中的原始 session ID 来自
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
>metadata.user_id</code
> 并将其与首次调度的账户绑定后续使用相同 session ID
的请求将自动路由到同一账户
</p>
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
<strong>新会话识别</strong>如果绑定会话历史中没有该sessionId但请求中
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
>messages.length > 1</code
> 系统会认为这是一个污染的会话并拒绝请求
</p>
</div>
</div>
</div>
</div>
<!-- 用户消息串行队列 -->
<div
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center">
<div
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 text-white shadow-lg"
>
<i class="fas fa-list-ol"></i>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
用户消息串行队列
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
启用后同一账户的用户消息请求将串行执行并在请求之间添加延迟防止触发上游限流
</p>
</div>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="claudeConfig.userMessageQueueEnabled"
class="peer sr-only"
type="checkbox"
@change="saveClaudeConfig"
/>
<div
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-teal-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-teal-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-teal-800"
></div>
</label>
</div>
<!-- 队列配置详情仅在启用时显示 -->
<div v-if="claudeConfig.userMessageQueueEnabled" class="mt-6 space-y-4">
<!-- 请求间隔 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-hourglass-half mr-2 text-gray-400"></i>
请求间隔毫秒
</label>
<input
v-model.number="claudeConfig.userMessageQueueDelayMs"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="10000"
min="0"
placeholder="200"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
同一账户的用户消息请求之间的最小间隔时间0-10000毫秒
</p>
</div>
<!-- 队列超时 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-stopwatch mr-2 text-gray-400"></i>
队列超时毫秒
</label>
<input
v-model.number="claudeConfig.userMessageQueueTimeoutMs"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="300000"
min="1000"
placeholder="30000"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
请求在队列中等待的最大时间超时将返回 503 错误1000-300000毫秒
</p>
</div>
</div>
<div class="mt-4 rounded-lg bg-teal-50 p-4 dark:bg-teal-900/20">
<div class="flex">
<i class="fas fa-info-circle mt-0.5 text-teal-500"></i>
<div class="ml-3">
<p class="text-sm text-teal-700 dark:text-teal-300">
<strong>工作原理</strong>系统检测请求中最后一条消息的
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">role</code>
是否为
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">user</code
>用户消息请求需要排队串行执行而工具调用结果助手消息续传等不受此限制
</p>
</div>
</div>
</div>
</div>
<!-- 配置更新信息 -->
<div
v-if="claudeConfig.updatedAt"
class="rounded-lg bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
>
<i class="fas fa-history mr-2"></i>
最后更新{{ formatDateTime(claudeConfig.updatedAt) }}
<span v-if="claudeConfig.updatedBy" class="ml-2">
<strong>{{ claudeConfig.updatedBy }}</strong> 修改
</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1274,6 +1556,20 @@ const webhookConfig = ref({
}
})
// Claude 转发配置
const claudeConfigLoading = ref(false)
const claudeConfig = ref({
claudeCodeOnlyEnabled: false,
globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30,
userMessageQueueEnabled: true,
userMessageQueueDelayMs: 200,
userMessageQueueTimeoutMs: 30000,
updatedAt: null,
updatedBy: null
})
// 平台表单相关
const showAddPlatformModal = ref(false)
const editingPlatform = ref(null)
@@ -1311,6 +1607,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
if (!isMounted.value) return
if (newSection === 'webhook') {
await loadWebhookConfig()
} else if (newSection === 'claude') {
await loadClaudeConfig()
}
})
@@ -1522,6 +1820,73 @@ const saveWebhookConfig = async () => {
}
}
// 加载 Claude 转发配置
const loadClaudeConfig = async () => {
if (!isMounted.value) return
claudeConfigLoading.value = true
try {
const response = await apiClient.get('/admin/claude-relay-config', {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
claudeConfig.value = {
claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false,
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
sessionBindingErrorMessage:
response.config?.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? true,
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 30000,
updatedAt: response.config?.updatedAt || null,
updatedBy: response.config?.updatedBy || null
}
}
} catch (error) {
if (error.name === 'AbortError') return
if (!isMounted.value) return
showToast('获取 Claude 转发配置失败', 'error')
console.error(error)
} finally {
if (isMounted.value) {
claudeConfigLoading.value = false
}
}
}
// 保存 Claude 转发配置
const saveClaudeConfig = async () => {
if (!isMounted.value) return
try {
const payload = {
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays,
userMessageQueueEnabled: claudeConfig.value.userMessageQueueEnabled,
userMessageQueueDelayMs: claudeConfig.value.userMessageQueueDelayMs,
userMessageQueueTimeoutMs: claudeConfig.value.userMessageQueueTimeoutMs
}
const response = await apiClient.put('/admin/claude-relay-config', payload, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
claudeConfig.value = {
...claudeConfig.value,
updatedAt: response.config?.updatedAt || new Date().toISOString(),
updatedBy: response.config?.updatedBy || null
}
showToast('Claude 转发配置已保存', 'success')
}
} catch (error) {
if (error.name === 'AbortError') return
if (!isMounted.value) return
showToast('保存 Claude 转发配置失败', 'error')
console.error(error)
}
}
// 验证 URL
const validateUrl = () => {
// Bark和SMTP平台不需要验证URL