Compare commits

...

58 Commits

Author SHA1 Message Date
github-actions[bot]
2c5a74eb5d chore: sync VERSION file with release v1.1.244 [skip ci] 2025-12-26 06:39:48 +00:00
shaw
09c9b88c27 fix: 修复无权访问 Claude 服务的问题 2025-12-26 14:39:29 +08:00
github-actions[bot]
dd417e780c chore: sync VERSION file with release v1.1.243 [skip ci] 2025-12-26 06:35:54 +00:00
Wesley Liddick
822466fc6e Merge pull request #837 from Chapoly1305/guestPreview
Feat:登陆前服务状态概览
2025-12-26 01:35:38 -05:00
github-actions[bot]
b892ac30a0 chore: sync VERSION file with release v1.1.242 [skip ci] 2025-12-26 05:59:55 +00:00
Wesley Liddick
b8f34b4630 Merge pull request #844 from dadongwo/antigravity
feat: 实现 Antigravity OAuth 账户支持与路径分流
2025-12-26 00:59:42 -05:00
Wesley Liddick
c9621e9efb Merge pull request #846 from bgColorGray/feat/passthrough-system-prompt [skip ci]
feat: allow passing system prompt to Claude
2025-12-26 00:59:29 -05:00
Wesley Liddick
3f98267738 Merge branch 'main' into antigravity 2025-12-26 00:56:27 -05:00
Wesley Liddick
e187b8946a Merge pull request #825 from atoz03/feat/account-quota [skip ci]
Feat:account quota
2025-12-26 00:53:33 -05:00
Wesley Liddick
8917019a78 Merge pull request #814 from Guccbai/feature/multi-select-permissions [skip ci]
feat(permissions): 服务权限从单选改为多选
2025-12-26 00:52:42 -05:00
pengyujie
e57a7bd614 feat: allow passing system prompt to Claude
Add CRS_PASSTHROUGH_SYSTEM_PROMPT to optionally forward OpenAI-format system messages to Claude, improving compatibility with clients that rely on strict system instructions (e.g. MineContext).
2025-12-25 20:02:26 +08:00
shaw
b6da77cabe docs: update readme 2025-12-25 14:27:23 +08:00
github-actions[bot]
e561387e81 chore: sync VERSION file with release v1.1.241 [skip ci] 2025-12-25 06:23:55 +00:00
shaw
982cca1020 fix: 修复鉴权检测的重大安全漏洞 2025-12-25 14:23:35 +08:00
github-actions[bot]
792ba51290 chore: sync VERSION file with release v1.1.240 [skip ci] 2025-12-25 02:46:09 +00:00
Wesley Liddick
74d138a2fb Merge pull request #842 from IanShaw027/feat/account-export-api
feat(admin): 添加账户导出同步 API
2025-12-24 21:45:55 -05:00
IanShaw027
b88698191e style(admin): fix ESLint curly rule violations in sync.js
为单行 if 语句添加花括号以符合 ESLint curly 规则要求
2025-12-24 17:57:30 -08:00
IanShaw027
11c38b23d1 style(admin): format sync.js with prettier
修复 CI 格式化检查失败问题
2025-12-24 17:52:51 -08:00
IanShaw027
b2dfc2eb25 feat(admin): 添加账户导出同步 API
- 新增 /api/accounts 端点,支持导出所有账户数据
- 新增 /api/proxies 端点,支持导出所有代理配置
- 支持 Sub2API 从 CRS 批量同步账户
- 包含完整的 credentials 和 extra 字段
- 提供账户类型标识 (oauth/setup_token/api_key)

相关 PR: Sub2API 端实现账户同步功能
2025-12-24 17:35:11 -08:00
github-actions[bot]
59ce0f091c chore: sync VERSION file with release v1.1.239 [skip ci] 2025-12-24 11:56:05 +00:00
shaw
67c20fa30e feat: 为 claude-official 账户添加 403 错误重试机制
针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时:
- 休息 2 秒后进行重试
- 最多重试 2 次(总共最多 3 次请求)
- 重试后仍是 403 才标记账户为 blocked

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

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

- 遗留键会被自动识别并删除,同时记录日志
2025-12-24 17:51:19 +08:00
Chapoly1305
b16968c3e5 feat: 模型使用分布改用环形图展示
- 将条形图改为 Doughnut 环形图,更直观展示占比
- 右侧图例显示模型名称和百分比
- 支持8种渐变配色,明暗主题自适应
- 移除旧的条形图相关样式

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:59:02 -05:00
Chapoly1305
e754589ad5 style: 优化公开统计概览的宽屏布局
- 移除状态概览的最大宽度限制,与其他标签页保持一致
- 重构 PublicStatsOverview 组件布局为响应式两列设计
- 顶部状态栏:服务状态左侧,平台可用性右侧
- 主内容区:今日统计(4项)与模型分布并排显示
- 图表区域独立占满宽度
- 各区块添加独立圆角背景,视觉层次更清晰

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:48:59 -05:00
Chapoly1305
cfeb4658ad feat: 模型使用分布支持自定义时间范围
- 后端:getPublicModelStats 支持 today/24h/7d/30d/all 五种时间范围
- 后端:新增 publicStatsModelDistributionPeriod 设置项
- 前端:设置页面添加横向选项卡式时间范围选择器
- 前端:公开统计组件显示当前数据时间范围标签

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:43:26 -05:00
Chapoly1305
0d94d3b449 feat: 公开统计功能增强 - 独立设置栏目和双Y轴折线图
- 将公开统计设置从品牌设置移至独立栏目
- 用三合一双Y轴折线图替代条形图(Chart.js + vue-chartjs)
- 左Y轴显示Tokens,右Y轴显示活跃数量
- 添加暂无数据状态的友好提示
- 修复Y轴可能显示负数的问题(设置min:0)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:58:20 +00:00
Chapoly1305
0c1bdf53d6 feat: 丰富公开统计概览数据并添加可选显示项
- 后端:添加 OEM 设置选项控制公开统计显示内容
  - publicStatsShowModelDistribution: 模型使用分布
  - publicStatsShowTokenTrends: Token 使用趋势(近7天)
  - publicStatsShowApiKeysTrends: API Keys 活跃趋势(近7天)
  - publicStatsShowAccountTrends: 账号活跃趋势(近7天)
- 后端:扩展 /admin/public-stats API 返回趋势数据
- 前端:PublicStatsOverview 组件支持显示趋势柱状图
- 前端:设置页面添加公开统计选项复选框
- 前端:从登录页移除公开统计概览(已移至首页)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:42:14 +00:00
Chapoly1305
ab474c3322 feat: 将公开统计概览移至首页状态概览标签
- 在首页添加"状态概览"标签,作为默认显示页面
- 修复 PublicStatsOverview.vue 属性顺序 lint 错误
- 修复 LoginView.vue prettier 格式问题

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:30:00 +00:00
Guccbai
534fbf6ac2 fix(eslint): 修复 ESLint 检查错误
- 修复 apiKeyService.js 中 if 语句缺少大括号的 curly 错误
- 移除 openaiGeminiRoutes.js 中重复声明 apiKeyService 导致的 no-shadow 错误

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:35:11 +08:00
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -05:00
sususu
0994eb346f format 2025-12-16 18:32:11 +08:00
sususu
4863a37328 feat: 添加 Claude Code 遥测端点并优化日志级别
- 添加 /api/event_logging/batch 端点处理客户端遥测请求
- 将遥测相关请求日志改为 debug 级别,减少日志噪音
2025-12-16 18:31:07 +08:00
61 changed files with 7730 additions and 218 deletions

View File

@@ -166,3 +166,7 @@ DEFAULT_USER_ROLE=user
USER_SESSION_TIMEOUT=86400000 USER_SESSION_TIMEOUT=86400000
MAX_API_KEYS_PER_USER=1 MAX_API_KEYS_PER_USER=1
ALLOW_USER_DELETE_API_KEYS=false ALLOW_USER_DELETE_API_KEYS=false
# Pass through incoming OpenAI-format system prompts to Claude.
# Enable this when using generic OpenAI-compatible clients (e.g. MineContext) that rely on system prompts.
# CRS_PASSTHROUGH_SYSTEM_PROMPT=true

View File

@@ -1,5 +1,10 @@
# Claude Relay Service # Claude Relay Service
> [!CAUTION]
> **安全更新通知**v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
>
> **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center"> <div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -426,6 +431,8 @@ export ANTHROPIC_MODEL="gemini-2.5-pro"
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。 如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
> 💡 **IntelliJ IDEA 用户推荐**[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
**Gemini CLI 设置环境变量:** **Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问** **方式一(推荐):通过 Gemini Assist API 方式访问**

View File

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

View File

@@ -1 +1 @@
1.1.235 1.1.244

View File

@@ -205,6 +205,14 @@ const config = {
hotReload: process.env.HOT_RELOAD === 'true' hotReload: process.env.HOT_RELOAD === 'true'
}, },
// 💰 账户余额相关配置
accountBalance: {
// 是否允许执行自定义余额脚本(安全开关)
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
// 默认保持开启如需禁用请显式设置BALANCE_SCRIPT_ENABLED=false
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
},
// 📬 用户消息队列配置 // 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: { userMessageQueue: {

10
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"ldapjs": "^3.0.7", "ldapjs": "^3.0.7",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"ora": "^5.4.1", "ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5", "rate-limiter-flexible": "^5.0.5",
@@ -7028,6 +7029,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",

View File

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

71
pnpm-lock.yaml generated
View File

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

View File

@@ -52,6 +52,16 @@ class Application {
await redis.connect() await redis.connect()
logger.success('✅ Redis connected successfully') logger.success('✅ Redis connected successfully')
// 💳 初始化账户余额查询服务Provider 注册)
try {
const accountBalanceService = require('./services/accountBalanceService')
const { registerAllProviders } = require('./services/balanceProviders')
registerAllProviders(accountBalanceService)
logger.info('✅ 账户余额查询服务已初始化')
} catch (error) {
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
}
// 💰 初始化价格服务 // 💰 初始化价格服务
logger.info('🔄 Initializing pricing service...') logger.info('🔄 Initializing pricing service...')
await pricingService.initialize() await pricingService.initialize()
@@ -68,6 +78,10 @@ class Application {
logger.info('🔄 Initializing admin credentials...') logger.info('🔄 Initializing admin credentials...')
await this.initializeAdmin() await this.initializeAdmin()
// 🔒 安全启动:清理无效/伪造的管理员会话
logger.info('🔒 Cleaning up invalid admin sessions...')
await this.cleanupInvalidSessions()
// 💰 初始化费用数据 // 💰 初始化费用数据
logger.info('💰 Checking cost data initialization...') logger.info('💰 Checking cost data initialization...')
const costInitService = require('./services/costInitService') const costInitService = require('./services/costInitService')
@@ -445,6 +459,54 @@ class Application {
} }
} }
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
async cleanupInvalidSessions() {
try {
const client = redis.getClient()
// 获取所有 session:* 键
const sessionKeys = await client.keys('session:*')
let validCount = 0
let invalidCount = 0
for (const key of sessionKeys) {
// 跳过 admin_credentials系统凭据
if (key === 'session:admin_credentials') {
continue
}
const sessionData = await client.hgetall(key)
// 检查会话完整性:必须有 username 和 loginTime
const hasUsername = !!sessionData.username
const hasLoginTime = !!sessionData.loginTime
if (!hasUsername || !hasLoginTime) {
// 无效会话 - 可能是漏洞利用创建的伪造会话
invalidCount++
logger.security(
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
)
await client.del(key)
} else {
validCount++
}
}
if (invalidCount > 0) {
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
}
logger.success(
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
)
} catch (error) {
// 清理失败不应阻止服务启动
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
}
}
// 🔍 Redis健康检查 // 🔍 Redis健康检查
async checkRedisHealth() { async checkRedisHealth() {
try { try {
@@ -600,10 +662,11 @@ class Application {
const now = Date.now() const now = Date.now()
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项 // 使用 Lua 脚本批量清理所有过期项
for (const key of keys) { for (const key of keys) {
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑) // 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型 // - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型 // - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型 // - concurrency:queue:* (不含stats/wait_times) 是 String 类型
@@ -618,11 +681,21 @@ class Application {
} }
try { try {
const cleaned = await redis.client.eval( // 使用原子 Lua 脚本:先检查类型,再执行清理
// 返回值0 = 正常清理无删除1 = 清理后删除空键,-1 = 遗留键已删除
const result = await redis.client.eval(
` `
local key = KEYS[1] local key = KEYS[1]
local now = tonumber(ARGV[1]) local now = tonumber(ARGV[1])
-- 先检查键类型,只对 Sorted Set 执行清理
local keyType = redis.call('TYPE', key)
if keyType.ok ~= 'zset' then
-- 非 ZSET 类型的遗留键,直接删除
redis.call('DEL', key)
return -1
end
-- 清理过期项 -- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now) redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -641,8 +714,10 @@ class Application {
key, key,
now now
) )
if (cleaned === 1) { if (result === 1) {
totalCleaned++ totalCleaned++
} else if (result === -1) {
legacyCleaned++
} }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error) logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -652,6 +727,9 @@ class Application {
if (totalCleaned > 0) { if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
} }
if (legacyCleaned > 0) {
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
}
} catch (error) { } catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error) logger.error('❌ Concurrency cleanup task failed:', error)
} }
@@ -680,6 +758,19 @@ class Application {
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)' '🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
) )
} }
// 🧪 启动账户定时测试调度器
// 根据配置定期测试账户连通性并保存测试历史
const accountTestSchedulerEnabled =
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
config.accountTestScheduler?.enabled !== false
if (accountTestSchedulerEnabled) {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.start()
logger.info('🧪 Account test scheduler service started')
} else {
logger.info('🧪 Account test scheduler service disabled')
}
} }
setupGracefulShutdown() { setupGracefulShutdown() {
@@ -734,6 +825,15 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error) logger.error('❌ Error stopping cost rank service:', error)
} }
// 停止账户定时测试调度器
try {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.stop()
logger.info('🧪 Account test scheduler service stopped')
} catch (error) {
logger.error('❌ Error stopping account test scheduler service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏) // 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try { try {
logger.info('🔢 Cleaning up all concurrency counters...') logger.info('🔢 Cleaning up all concurrency counters...')

View File

@@ -87,8 +87,7 @@ function generateSessionHash(req) {
* 检查 API Key 权限 * 检查 API Key 权限
*/ */
function checkPermissions(apiKeyData, requiredPermission = 'gemini') { function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData?.permissions || 'all' return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
return permissions === 'all' || permissions === requiredPermission
} }
/** /**

View File

@@ -1389,6 +1389,18 @@ const authenticateAdmin = async (req, res, next) => {
}) })
} }
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 检查会话活跃性(可选:检查最后活动时间) // 检查会话活跃性(可选:检查最后活动时间)
const now = new Date() const now = new Date()
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
@@ -1744,10 +1756,14 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none' const referer = req.get('Referer') || 'none'
// 记录请求开始 // 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') { if (req.originalUrl !== '/health') {
// 避免健康检查日志过多 if (isDebugRoute) {
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} }
}
res.on('finish', () => { res.on('finish', () => {
const duration = Date.now() - start const duration = Date.now() - start
@@ -1778,8 +1794,15 @@ const requestLogger = (req, res, next) => {
logMetadata logMetadata
) )
} else if (req.originalUrl !== '/health') { } else if (req.originalUrl !== '/health') {
if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
} }
}
// API Key相关日志 // API Key相关日志
if (req.apiKey) { if (req.apiKey) {

View File

@@ -96,7 +96,25 @@ class RedisClient {
logger.warn('⚠️ Redis connection closed') logger.warn('⚠️ Redis connection closed')
}) })
// 只有在 lazyConnect 模式下才需要手动调用 connect()
// 如果 Redis 已经连接或正在连接中,则跳过
if (
this.client.status !== 'connecting' &&
this.client.status !== 'connect' &&
this.client.status !== 'ready'
) {
await this.client.connect() await this.client.connect()
} else {
// 等待 ready 状态
await new Promise((resolve, reject) => {
if (this.client.status === 'ready') {
resolve()
} else {
this.client.once('ready', resolve)
this.client.once('error', reject)
}
})
}
return this.client return this.client
} catch (error) { } catch (error) {
logger.error('💥 Failed to connect to Redis:', error) logger.error('💥 Failed to connect to Redis:', error)
@@ -1503,6 +1521,123 @@ class RedisClient {
return await this.client.del(key) return await this.client.del(key)
} }
// 💰 账户余额缓存API 查询结果)
async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
const key = `account_balance:${platform}:${accountId}`
const payload = {
balance:
balanceData && balanceData.balance !== null && balanceData.balance !== undefined
? String(balanceData.balance)
: '',
currency: balanceData?.currency || 'USD',
lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
queryMethod: balanceData?.queryMethod || 'api',
status: balanceData?.status || 'success',
errorMessage: balanceData?.errorMessage || balanceData?.error || '',
rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
}
await this.client.hset(key, payload)
await this.client.expire(key, ttl)
}
async getAccountBalance(platform, accountId) {
const key = `account_balance:${platform}:${accountId}`
const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
if (!data || Object.keys(data).length === 0) {
return null
}
let rawData = null
if (data.rawData) {
try {
rawData = JSON.parse(data.rawData)
} catch (error) {
rawData = null
}
}
let quota = null
if (data.quota) {
try {
quota = JSON.parse(data.quota)
} catch (error) {
quota = null
}
}
return {
balance: data.balance ? parseFloat(data.balance) : null,
currency: data.currency || 'USD',
lastRefreshAt: data.lastRefreshAt || null,
queryMethod: data.queryMethod || null,
status: data.status || null,
errorMessage: data.errorMessage || '',
rawData,
quota,
ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
}
}
// 📊 账户余额缓存(本地统计)
async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
const key = `account_balance_local:${platform}:${accountId}`
await this.client.hset(key, {
estimatedBalance: JSON.stringify(statisticsData || {}),
lastCalculated: new Date().toISOString()
})
await this.client.expire(key, ttl)
}
async getLocalBalance(platform, accountId) {
const key = `account_balance_local:${platform}:${accountId}`
const data = await this.client.hgetall(key)
if (!data || !data.estimatedBalance) {
return null
}
try {
return JSON.parse(data.estimatedBalance)
} catch (error) {
return null
}
}
async deleteAccountBalance(platform, accountId) {
const key = `account_balance:${platform}:${accountId}`
const localKey = `account_balance_local:${platform}:${accountId}`
await this.client.del(key, localKey)
}
// 🧩 账户余额脚本配置
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
const key = `account_balance_script:${platform}:${accountId}`
await this.client.set(key, JSON.stringify(scriptConfig || {}))
}
async getBalanceScriptConfig(platform, accountId) {
const key = `account_balance_script:${platform}:${accountId}`
const raw = await this.client.get(key)
if (!raw) {
return null
}
try {
return JSON.parse(raw)
} catch (error) {
return null
}
}
async deleteBalanceScriptConfig(platform, accountId) {
const key = `account_balance_script:${platform}:${accountId}`
return await this.client.del(key)
}
// 📈 系统统计 // 📈 系统统计
async getSystemStats() { async getSystemStats() {
const keys = await Promise.all([ const keys = await Promise.all([
@@ -2122,6 +2257,27 @@ class RedisClient {
const results = [] const results = []
for (const key of keys) { for (const key of keys) {
// 跳过已知非 Sorted Set 类型的键
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:') ||
(key.startsWith('concurrency:queue:') &&
!key.includes(':stats:') &&
!key.includes(':wait_times:'))
) {
continue
}
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
continue
}
// 提取 apiKeyId去掉 concurrency: 前缀) // 提取 apiKeyId去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '') const apiKeyId = key.replace('concurrency:', '')
@@ -2184,6 +2340,23 @@ class RedisClient {
} }
} }
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.warn(
`⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
)
return {
apiKeyId,
key,
activeCount: 0,
expiredCount: 0,
activeRequests: [],
exists: true,
invalidType: keyType
}
}
// 获取所有成员和分数 // 获取所有成员和分数
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES') const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
@@ -2233,20 +2406,36 @@ class RedisClient {
const client = this.getClientSafe() const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}` const key = `concurrency:${apiKeyId}`
// 获取清理前的状态 // 检查键类型
const beforeCount = await client.zcard(key) const keyType = await client.type(key)
// 删除整个 key let beforeCount = 0
let isLegacy = false
if (keyType === 'zset') {
// 正常的 zset 键,获取条目数
beforeCount = await client.zcard(key)
} else if (keyType !== 'none') {
// 非 zset 且非空的遗留键
isLegacy = true
logger.warn(
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
)
}
// 删除键(无论什么类型)
await client.del(key) await client.del(key)
logger.warn( logger.warn(
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries` `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
) )
return { return {
apiKeyId, apiKeyId,
key, key,
clearedCount: beforeCount, clearedCount: beforeCount,
type: keyType,
legacy: isLegacy,
success: true success: true
} }
} catch (error) { } catch (error) {
@@ -2265,25 +2454,47 @@ class RedisClient {
const keys = await client.keys('concurrency:*') const keys = await client.keys('concurrency:*')
let totalCleared = 0 let totalCleared = 0
let legacyCleared = 0
const clearedKeys = [] const clearedKeys = []
for (const key of keys) { for (const key of keys) {
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType === 'zset') {
const count = await client.zcard(key) const count = await client.zcard(key)
await client.del(key) await client.del(key)
totalCleared += count totalCleared += count
clearedKeys.push({ clearedKeys.push({
key, key,
clearedCount: count clearedCount: count,
type: 'zset'
}) })
} else {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleared++
clearedKeys.push({
key,
clearedCount: 0,
type: keyType,
legacy: true
})
}
} }
logger.warn( logger.warn(
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries` `🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
) )
return { return {
keysCleared: keys.length, keysCleared: clearedKeys.length,
totalEntriesCleared: totalCleared, totalEntriesCleared: totalCleared,
legacyKeysCleared: legacyCleared,
clearedKeys, clearedKeys,
success: true success: true
} }
@@ -2311,9 +2522,30 @@ class RedisClient {
} }
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
const cleanedKeys = [] const cleanedKeys = []
for (const key of keys) { for (const key of keys) {
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType !== 'zset') {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleaned++
cleanedKeys.push({
key,
cleanedCount: 0,
type: keyType,
legacy: true
})
continue
}
// 只清理过期的条目 // 只清理过期的条目
const cleaned = await client.zremrangebyscore(key, '-inf', now) const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) { if (cleaned > 0) {
@@ -2332,13 +2564,14 @@ class RedisClient {
} }
logger.info( logger.info(
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys` `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
) )
return { return {
keysProcessed: keys.length, keysProcessed: keys.length,
keysCleaned: cleanedKeys.length, keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned, totalEntriesCleaned: totalCleaned,
legacyKeysRemoved: legacyCleaned,
cleanedKeys, cleanedKeys,
success: true success: true
} }
@@ -3157,4 +3390,249 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
} }
} }
// ============================================================================
// 账户测试历史相关操作
// ============================================================================
const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年用户通常长期使用
/**
* 保存账户测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型 (claude/gemini/openai等)
* @param {Object} testResult - 测试结果对象
* @param {boolean} testResult.success - 是否成功
* @param {string} testResult.message - 测试消息/响应
* @param {number} testResult.latencyMs - 延迟毫秒数
* @param {string} testResult.error - 错误信息(如有)
* @param {string} testResult.timestamp - 测试时间戳
*/
redisClient.saveAccountTestResult = async function (accountId, platform, testResult) {
const key = `account:test_history:${platform}:${accountId}`
try {
const record = JSON.stringify({
...testResult,
timestamp: testResult.timestamp || new Date().toISOString()
})
// 使用 LPUSH + LTRIM 保持最近5条记录
const client = this.getClientSafe()
await client.lpush(key, record)
await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1)
await client.expire(key, ACCOUNT_TEST_HISTORY_TTL)
logger.debug(`📝 Saved test result for ${platform} account ${accountId}`)
} catch (error) {
logger.error(`Failed to save test result for ${accountId}:`, error)
}
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史记录数组(最新在前)
*/
redisClient.getAccountTestHistory = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const records = await client.lrange(key, 0, -1)
return records.map((r) => JSON.parse(r))
} catch (error) {
logger.error(`Failed to get test history for ${accountId}:`, error)
return []
}
}
/**
* 获取账户最新测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 最新测试结果
*/
redisClient.getAccountLatestTestResult = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const record = await client.lindex(key, 0)
return record ? JSON.parse(record) : null
} catch (error) {
logger.error(`Failed to get latest test result for ${accountId}:`, error)
return null
}
}
/**
* 批量获取多个账户的测试历史
* @param {Array<{accountId: string, platform: string}>} accounts - 账户列表
* @returns {Promise<Object>} 以 accountId 为 key 的测试历史映射
*/
redisClient.getAccountsTestHistory = async function (accounts) {
const result = {}
try {
const client = this.getClientSafe()
const pipeline = client.pipeline()
for (const { accountId, platform } of accounts) {
const key = `account:test_history:${platform}:${accountId}`
pipeline.lrange(key, 0, -1)
}
const responses = await pipeline.exec()
accounts.forEach(({ accountId }, index) => {
const [err, records] = responses[index]
if (!err && records) {
result[accountId] = records.map((r) => JSON.parse(r))
} else {
result[accountId] = []
}
})
} catch (error) {
logger.error('Failed to get batch test history:', error)
}
return result
}
/**
* 保存定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} config - 配置对象
* @param {boolean} config.enabled - 是否启用定时测试
* @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点)
* @param {string} config.model - 测试使用的模型
*/
redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.hset(key, {
enabled: testConfig.enabled ? 'true' : 'false',
cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点
model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型
updatedAt: new Date().toISOString()
})
// 设置过期时间1年
await client.expire(key, ACCOUNT_TEST_CONFIG_TTL)
} catch (error) {
logger.error(`Failed to save test config for ${accountId}:`, error)
}
}
/**
* 获取定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 配置对象
*/
redisClient.getAccountTestConfig = async function (accountId, platform) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const testConfig = await client.hgetall(key)
if (!testConfig || Object.keys(testConfig).length === 0) {
return null
}
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
return {
enabled: testConfig.enabled === 'true',
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929',
updatedAt: testConfig.updatedAt
}
} catch (error) {
logger.error(`Failed to get test config for ${accountId}:`, error)
return null
}
}
/**
* 获取所有启用定时测试的账户
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 账户ID列表及 cron 配置
*/
redisClient.getEnabledTestAccounts = async function (platform) {
const accountIds = []
let cursor = '0'
try {
const client = this.getClientSafe()
do {
const [newCursor, keys] = await client.scan(
cursor,
'MATCH',
`account:test_config:${platform}:*`,
'COUNT',
100
)
cursor = newCursor
for (const key of keys) {
const testConfig = await client.hgetall(key)
if (testConfig && testConfig.enabled === 'true') {
const accountId = key.replace(`account:test_config:${platform}:`, '')
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
accountIds.push({
accountId,
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929'
})
}
}
} while (cursor !== '0')
return accountIds
} catch (error) {
logger.error(`Failed to get enabled test accounts for ${platform}:`, error)
return []
}
}
/**
* 保存账户上次测试时间(用于调度器判断是否需要测试)
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
*/
redisClient.setAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期
} catch (error) {
logger.error(`Failed to set last test time for ${accountId}:`, error)
}
}
/**
* 获取账户上次测试时间
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<number|null>} 上次测试时间戳
*/
redisClient.getAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const timestamp = await client.get(key)
return timestamp ? parseInt(timestamp, 10) : null
} catch (error) {
logger.error(`Failed to get last test time for ${accountId}:`, error)
return null
}
}
module.exports = redisClient module.exports = redisClient

View File

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

View File

@@ -8,6 +8,43 @@ const config = require('../../../config/config')
const router = express.Router() const router = express.Router()
// 有效的权限值列表
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
/**
* 验证权限数组格式
* @param {any} permissions - 权限值(可以是数组或其他)
* @returns {string|null} - 返回错误消息null 表示验证通过
*/
function validatePermissions(permissions) {
// 空值或未定义表示全部服务
if (permissions === undefined || permissions === null || permissions === '') {
return null
}
// 兼容旧格式字符串
if (typeof permissions === 'string') {
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
return null
}
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
}
// 新格式数组
if (Array.isArray(permissions)) {
// 空数组表示全部服务
if (permissions.length === 0) {
return null
}
// 验证数组中的每个值
for (const perm of permissions) {
if (!VALID_PERMISSIONS.includes(perm)) {
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
}
return null
}
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
// 👥 用户管理 (用于API Key分配) // 👥 用户管理 (用于API Key分配)
// 获取所有用户列表用于API Key分配 // 获取所有用户列表用于API Key分配
@@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
} }
} }
// 验证服务权限字段 // 验证服务权限字段(支持数组格式)
if ( const permissionsError = validatePermissions(permissions)
permissions !== undefined && if (permissionsError) {
permissions !== null && return res.status(400).json({ error: permissionsError })
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
} }
const newKey = await apiKeyService.generateApiKey({ const newKey = await apiKeyService.generateApiKey({
@@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
.json({ error: 'Base name must be less than 90 characters to allow for numbering' }) .json({ error: 'Base name must be less than 90 characters to allow for numbering' })
} }
if ( // 验证服务权限字段(支持数组格式)
permissions !== undefined && const batchPermissionsError = validatePermissions(permissions)
permissions !== null && if (batchPermissionsError) {
permissions !== '' && return res.status(400).json({ error: batchPermissionsError })
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
} }
// 生成批量API Keys // 生成批量API Keys
@@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
}) })
} }
if ( // 验证服务权限字段(支持数组格式)
updates.permissions !== undefined && if (updates.permissions !== undefined) {
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) const updatePermissionsError = validatePermissions(updates.permissions)
) { if (updatePermissionsError) {
return res.status(400).json({ return res.status(400).json({ error: updatePermissionsError })
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' }
})
} }
logger.info( logger.info(
@@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
} }
if (permissions !== undefined) { if (permissions !== undefined) {
// 验证权限值 // 验证服务权限字段(支持数组格式)
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { const singlePermissionsError = validatePermissions(permissions)
return res.status(400).json({ if (singlePermissionsError) {
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' return res.status(400).json({ error: singlePermissionsError })
})
} }
updates.permissions = permissions updates.permissions = permissions
} }

View File

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

View File

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

View File

@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
dailyQuota, dailyQuota,
quotaResetTime, quotaResetTime,
maxConcurrentTasks, maxConcurrentTasks,
disableAutoProtection disableAutoProtection,
interceptWarmup
} = req.body } = req.body
if (!name || !apiUrl || !apiKey) { if (!name || !apiUrl || !apiKey) {
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks) ? Number(maxConcurrentTasks)
: 0, : 0,
disableAutoProtection: normalizedDisableAutoProtection disableAutoProtection: normalizedDisableAutoProtection,
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
}) })
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组) // 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)

View File

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

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

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

View File

@@ -4,6 +4,10 @@ const path = require('path')
const axios = require('axios') const axios = require('axios')
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService') const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const droidAccountService = require('../../services/droidAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
@@ -254,30 +258,43 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => {
// ==================== OEM 设置管理 ==================== // ==================== OEM 设置管理 ====================
// 获取OEM设置(公开接口,用于显示) // 默认OEM设置
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问 const defaultOemSettings = {
router.get('/oem-settings', async (req, res) => {
try {
const client = redis.getClient()
const oemSettings = await client.get('oem:settings')
// 默认设置
const defaultSettings = {
siteName: 'Claude Relay Service', siteName: 'Claude Relay Service',
siteIcon: '', siteIcon: '',
siteIconData: '', // Base64编码的图标数据 siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮 showAdminButton: true, // 是否显示管理后台按钮
publicStatsEnabled: false, // 是否在首页显示公开统计概览
// 公开统计显示选项
publicStatsShowModelDistribution: true, // 显示模型使用分布
publicStatsModelDistributionPeriod: 'today', // 模型使用分布时间范围: today, 24h, 7d, 30d, all
publicStatsShowTokenTrends: false, // 显示Token使用趋势
publicStatsShowApiKeysTrends: false, // 显示API Keys使用趋势
publicStatsShowAccountTrends: false, // 显示账号使用趋势
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
let settings = defaultSettings // 获取OEM设置的辅助函数
async function getOemSettings() {
const client = redis.getClient()
const oemSettings = await client.get('oem:settings')
let settings = { ...defaultOemSettings }
if (oemSettings) { if (oemSettings) {
try { try {
settings = { ...defaultSettings, ...JSON.parse(oemSettings) } settings = { ...defaultOemSettings, ...JSON.parse(oemSettings) }
} catch (err) { } catch (err) {
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message) logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
} }
} }
return settings
}
// 获取OEM设置公开接口用于显示
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
router.get('/oem-settings', async (req, res) => {
try {
const settings = await getOemSettings()
// 添加 LDAP 启用状态到响应中 // 添加 LDAP 启用状态到响应中
return res.json({ return res.json({
@@ -296,7 +313,18 @@ router.get('/oem-settings', async (req, res) => {
// 更新OEM设置 // 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => { router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try { try {
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body const {
siteName,
siteIcon,
siteIconData,
showAdminButton,
publicStatsEnabled,
publicStatsShowModelDistribution,
publicStatsModelDistributionPeriod,
publicStatsShowTokenTrends,
publicStatsShowApiKeysTrends,
publicStatsShowAccountTrends
} = req.body
// 验证输入 // 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -323,11 +351,24 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
} }
} }
// 验证时间范围值
const validPeriods = ['today', '24h', '7d', '30d', 'all']
const periodValue = validPeriods.includes(publicStatsModelDistributionPeriod)
? publicStatsModelDistributionPeriod
: 'today'
const settings = { const settings = {
siteName: siteName.trim(), siteName: siteName.trim(),
siteIcon: (siteIcon || '').trim(), siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据 siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true showAdminButton: showAdminButton !== false, // 默认为true
publicStatsEnabled: publicStatsEnabled === true, // 默认为false
// 公开统计显示选项
publicStatsShowModelDistribution: publicStatsShowModelDistribution !== false, // 默认为true
publicStatsModelDistributionPeriod: periodValue, // 时间范围
publicStatsShowTokenTrends: publicStatsShowTokenTrends === true, // 默认为false
publicStatsShowApiKeysTrends: publicStatsShowApiKeysTrends === true, // 默认为false
publicStatsShowAccountTrends: publicStatsShowAccountTrends === true, // 默认为false
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
@@ -398,4 +439,420 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
} }
}) })
// ==================== 公开统计概览 ====================
// 获取公开统计数据(无需认证,用于首页展示)
// 只在 publicStatsEnabled 开启时返回数据
router.get('/public-stats', async (req, res) => {
try {
// 检查是否启用了公开统计
const settings = await getOemSettings()
if (!settings.publicStatsEnabled) {
return res.json({
success: true,
enabled: false,
data: null
})
}
// 辅助函数:规范化布尔值
const normalizeBoolean = (value) => value === true || value === 'true'
const isRateLimitedFlag = (status) => {
if (!status) {
return false
}
if (typeof status === 'string') {
return status === 'limited'
}
if (typeof status === 'object') {
return status.isRateLimited === true
}
return false
}
// 并行获取统计数据
const [
claudeAccounts,
claudeConsoleAccounts,
geminiAccounts,
bedrockAccountsResult,
droidAccounts,
todayStats,
modelStats
] = await Promise.all([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
droidAccountService.getAllAccounts(),
redis.getTodayStats(),
getPublicModelStats(settings.publicStatsModelDistributionPeriod || 'today')
])
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
// 计算各平台正常账户数
const normalClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const normalGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(
acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
)
).length
const normalBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const normalDroidAccounts = droidAccounts.filter(
(acc) =>
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
normalizeBoolean(acc.schedulable) &&
!isRateLimitedFlag(acc.rateLimitStatus)
).length
// 计算总正常账户数
const totalNormalAccounts =
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalDroidAccounts
// 判断服务状态
const isHealthy = redis.isConnected && totalNormalAccounts > 0
// 构建公开统计数据(脱敏后的数据)
const publicStats = {
// 服务状态
serviceStatus: isHealthy ? 'healthy' : 'degraded',
uptime: process.uptime(),
// 平台可用性(只显示是否有可用账户,不显示具体数量)
platforms: {
claude: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
gemini: normalGeminiAccounts > 0,
bedrock: normalBedrockAccounts > 0,
droid: normalDroidAccounts > 0
},
// 今日统计
todayStats: {
requests: todayStats.requestsToday || 0,
tokens: todayStats.tokensToday || 0,
inputTokens: todayStats.inputTokensToday || 0,
outputTokens: todayStats.outputTokensToday || 0
},
// 系统时区
systemTimezone: config.system.timezoneOffset || 8,
// 显示选项
showOptions: {
modelDistribution: settings.publicStatsShowModelDistribution !== false,
tokenTrends: settings.publicStatsShowTokenTrends === true,
apiKeysTrends: settings.publicStatsShowApiKeysTrends === true,
accountTrends: settings.publicStatsShowAccountTrends === true
}
}
// 根据设置添加可选数据
if (settings.publicStatsShowModelDistribution !== false) {
// modelStats 现在返回 { stats: [], period }
publicStats.modelDistribution = modelStats.stats
publicStats.modelDistributionPeriod = modelStats.period
}
// 获取趋势数据最近7天
if (
settings.publicStatsShowTokenTrends ||
settings.publicStatsShowApiKeysTrends ||
settings.publicStatsShowAccountTrends
) {
const trendData = await getPublicTrendData(settings)
if (settings.publicStatsShowTokenTrends && trendData.tokenTrends) {
publicStats.tokenTrends = trendData.tokenTrends
}
if (settings.publicStatsShowApiKeysTrends && trendData.apiKeysTrends) {
publicStats.apiKeysTrends = trendData.apiKeysTrends
}
if (settings.publicStatsShowAccountTrends && trendData.accountTrends) {
publicStats.accountTrends = trendData.accountTrends
}
}
return res.json({
success: true,
enabled: true,
data: publicStats
})
} catch (error) {
logger.error('❌ Failed to get public stats:', error)
return res.status(500).json({
success: false,
error: 'Failed to get public stats',
message: error.message
})
}
})
// 获取公开模型统计的辅助函数
// period: 'today' | '24h' | '7d' | '30d' | 'all'
async function getPublicModelStats(period = 'today') {
try {
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
// 根据period生成日期范围
const getDatePatterns = () => {
const patterns = []
if (period === 'today') {
patterns.push(`usage:model:daily:*:${today}`)
} else if (period === '24h') {
// 过去24小时 = 今天 + 昨天
patterns.push(`usage:model:daily:*:${today}`)
const yesterday = new Date(tzDate)
yesterday.setDate(yesterday.getDate() - 1)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(yesterday)}`)
} else if (period === '7d') {
// 过去7天
for (let i = 0; i < 7; i++) {
const date = new Date(tzDate)
date.setDate(date.getDate() - i)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
}
} else if (period === '30d') {
// 过去30天
for (let i = 0; i < 30; i++) {
const date = new Date(tzDate)
date.setDate(date.getDate() - i)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
}
} else if (period === 'all') {
// 所有数据
patterns.push('usage:model:daily:*')
} else {
// 默认今天
patterns.push(`usage:model:daily:*:${today}`)
}
return patterns
}
const patterns = getDatePatterns()
let allKeys = []
for (const pattern of patterns) {
const keys = await client.keys(pattern)
allKeys.push(...keys)
}
// 去重
allKeys = [...new Set(allKeys)]
if (allKeys.length === 0) {
return { stats: [], period }
}
// 模型名标准化
const normalizeModelName = (model) => {
if (!model || model === 'unknown') {
return model
}
if (model.includes('.anthropic.') || model.includes('.claude')) {
let normalized = model.replace(/^[a-z0-9-]+\./, '')
normalized = normalized.replace('anthropic.', '')
normalized = normalized.replace(/-v\d+:\d+$/, '')
return normalized
}
return model.replace(/-v\d+:\d+|:latest$/, '')
}
// 聚合模型数据
const modelStatsMap = new Map()
let totalRequests = 0
for (const key of allKeys) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!match) {
continue
}
const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const requests = parseInt(data.requests) || 0
totalRequests += requests
const stats = modelStatsMap.get(normalizedModel) || { requests: 0 }
stats.requests += requests
modelStatsMap.set(normalizedModel, stats)
}
}
// 转换为数组并计算占比
const modelStats = []
for (const [model, stats] of modelStatsMap) {
modelStats.push({
model,
percentage: totalRequests > 0 ? Math.round((stats.requests / totalRequests) * 100) : 0
})
}
// 按占比排序取前5个
modelStats.sort((a, b) => b.percentage - a.percentage)
return { stats: modelStats.slice(0, 5), period }
} catch (error) {
logger.warn('⚠️ Failed to get public model stats:', error.message)
return { stats: [], period }
}
}
// 获取公开趋势数据的辅助函数最近7天
async function getPublicTrendData(settings) {
const result = {
tokenTrends: null,
apiKeysTrends: null,
accountTrends: null
}
try {
const client = redis.getClientSafe()
const days = 7
// 生成最近7天的日期列表
const dates = []
for (let i = days - 1; i >= 0; i--) {
const date = new Date()
date.setDate(date.getDate() - i)
dates.push(redis.getDateStringInTimezone(date))
}
// Token使用趋势
if (settings.publicStatsShowTokenTrends) {
const tokenTrends = []
for (const dateStr of dates) {
const pattern = `usage:model:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayTokens = 0
let dayRequests = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
dayRequests += parseInt(data.requests) || 0
}
}
tokenTrends.push({
date: dateStr,
tokens: dayTokens,
requests: dayRequests
})
}
result.tokenTrends = tokenTrends
}
// API Keys使用趋势脱敏只显示总数不显示具体Key
if (settings.publicStatsShowApiKeysTrends) {
const apiKeysTrends = []
for (const dateStr of dates) {
const pattern = `usage:apikey:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayRequests = 0
let dayTokens = 0
let activeKeys = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
const requests = parseInt(data.requests) || 0
if (requests > 0) {
activeKeys++
dayRequests += requests
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
}
}
}
apiKeysTrends.push({
date: dateStr,
activeKeys,
requests: dayRequests,
tokens: dayTokens
})
}
result.apiKeysTrends = apiKeysTrends
}
// 账号使用趋势(脱敏:只显示总数,不显示具体账号)
if (settings.publicStatsShowAccountTrends) {
const accountTrends = []
for (const dateStr of dates) {
const pattern = `usage:account:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayRequests = 0
let dayTokens = 0
let activeAccounts = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
const requests = parseInt(data.requests) || 0
if (requests > 0) {
activeAccounts++
dayRequests += requests
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
}
}
}
accountTrends.push({
date: dateStr,
activeAccounts,
requests: dayRequests,
tokens: dayTokens
})
}
result.accountTrends = accountTrends
}
} catch (error) {
logger.warn('⚠️ Failed to get public trend data:', error.message)
}
return result
}
module.exports = router module.exports = router

View File

@@ -12,6 +12,13 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const claudeAccountService = require('../services/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
} = require('../utils/warmupInterceptor')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer') const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
const { const {
@@ -398,6 +405,23 @@ async function handleMessagesRequest(req, res) {
} }
} }
// 🔥 预热请求拦截检查(在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
if (isStream) {
return sendMockWarmupStream(res, req.body.model)
} else {
return res.json(buildMockWarmupResponse(req.body.model))
}
}
}
// 根据账号类型选择对应的转发服务并调用 // 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号 // 官方Claude账号使用原有的转发服务会自己选择账号
@@ -897,6 +921,21 @@ async function handleMessagesRequest(req, res) {
} }
} }
// 🔥 预热请求拦截检查(非流式,在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(req.body.model))
}
}
// 根据账号类型选择对应的转发服务 // 根据账号类型选择对应的转发服务
let response let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -1465,9 +1504,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2 const maxAttempts = 2
let attempt = 0 let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => { const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
@@ -1663,5 +1699,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
} }
}) })
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
router.post('/api/event_logging/batch', (req, res) => {
res.status(200).json({ success: true })
})
module.exports = router module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest module.exports.handleMessagesRequest = handleMessagesRequest

View File

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

View File

@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const { getAvailableModels } = require('../services/geminiRelayService') const { getAvailableModels } = require('../services/geminiRelayService')
const crypto = require('crypto') const crypto = require('crypto')
const apiKeyService = require('../services/apiKeyService')
// 生成会话哈希 // 生成会话哈希
function generateSessionHash(req) { function generateSessionHash(req) {
@@ -31,8 +32,7 @@ function ensureAntigravityProjectId(account) {
// 检查 API Key 权限 // 检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'gemini') { function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData.permissions || 'all' return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
return permissions === 'all' || permissions === requiredPermission
} }
// 转换 OpenAI 消息格式到 Gemini 格式 // 转换 OpenAI 消息格式到 Gemini 格式
@@ -532,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计 // 记录使用统计
if (!usageReported && totalUsage.totalTokenCount > 0) { if (!usageReported && totalUsage.totalTokenCount > 0) {
try { try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage( await apiKeyService.recordUsage(
apiKeyData.id, apiKeyData.id,
totalUsage.promptTokenCount || 0, totalUsage.promptTokenCount || 0,
@@ -634,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计 // 记录使用统计
if (openaiResponse.usage) { if (openaiResponse.usage) {
try { try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage( await apiKeyService.recordUsage(
apiKeyData.id, apiKeyData.id,
openaiResponse.usage.prompt_tokens || 0, openaiResponse.usage.prompt_tokens || 0,

View File

@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
// 检查 API Key 是否具备 OpenAI 权限 // 检查 API Key 是否具备 OpenAI 权限
function checkOpenAIPermissions(apiKeyData) { function checkOpenAIPermissions(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all' return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
return permissions === 'all' || permissions === 'openai'
} }
function normalizeHeaders(headers = {}) { function normalizeHeaders(headers = {}) {

View File

@@ -8,6 +8,7 @@ const {
handleStreamGenerateContent: geminiHandleStreamGenerateContent handleStreamGenerateContent: geminiHandleStreamGenerateContent
} = require('../handlers/geminiHandlers') } = require('../handlers/geminiHandlers')
const openaiRoutes = require('./openaiRoutes') const openaiRoutes = require('./openaiRoutes')
const apiKeyService = require('../services/apiKeyService')
const router = express.Router() const router = express.Router()
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
return await openaiRoutes.handleResponses(req, res) return await openaiRoutes.handleResponses(req, res)
} else if (backend === 'gemini') { } else if (backend === 'gemini') {
// Gemini 后端 // Gemini 后端
if (permissions !== 'all' && permissions !== 'gemini') { if (!apiKeyService.hasPermission(permissions, 'gemini')) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
message: 'This API key does not have permission to access Gemini', message: 'This API key does not have permission to access Gemini',

View File

@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
// 获取当前会话 // 获取当前会话
const sessionData = await redis.getSession(token) const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({ return res.status(401).json({
error: 'Invalid token', error: 'Invalid token',
message: 'Session expired or invalid' message: 'Session expired or invalid'
}) })
} }
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取当前管理员信息 // 获取当前管理员信息
const adminData = await redis.getSession('admin_credentials') const adminData = await redis.getSession('admin_credentials')
if (!adminData) { if (!adminData) {
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
// 获取当前会话 // 获取当前会话
const sessionData = await redis.getSession(token) const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({ return res.status(401).json({
error: 'Invalid token', error: 'Invalid token',
message: 'Session expired or invalid' message: 'Session expired or invalid'
}) })
} }
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取管理员信息 // 获取管理员信息
const adminData = await redis.getSession('admin_credentials') const adminData = await redis.getSession('admin_credentials')
if (!adminData) { if (!adminData) {
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
const sessionData = await redis.getSession(token) const sessionData = await redis.getSession(token)
if (!sessionData) { // 🔒 安全修复检查空对象hgetall 对不存在的 key 返回 {}
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({ return res.status(401).json({
error: 'Invalid token', error: 'Invalid token',
message: 'Session expired or invalid' message: 'Session expired or invalid'
}) })
} }
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 更新最后活动时间 // 更新最后活动时间
sessionData.lastActivity = new Date().toISOString() sessionData.lastActivity = new Date().toISOString()
await redis.setSession(token, sessionData, config.security.adminSessionTimeout) await redis.setSession(token, sessionData, config.security.adminSessionTimeout)

View File

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

View File

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

View File

@@ -37,6 +37,51 @@ const ACCOUNT_CATEGORY_MAP = {
droid: 'droid' droid: 'droid'
} }
/**
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
* @param {string|array} permissions - 权限数据
* @returns {array} - 权限数组,空数组表示全部服务
*/
function normalizePermissions(permissions) {
if (!permissions) {
return [] // 空 = 全部服务
}
if (Array.isArray(permissions)) {
return permissions
}
// 尝试解析 JSON 字符串(新格式存储)
if (typeof permissions === 'string') {
if (permissions.startsWith('[')) {
try {
const parsed = JSON.parse(permissions)
if (Array.isArray(parsed)) {
return parsed
}
} catch (e) {
// 解析失败,继续处理为普通字符串
}
}
// 旧格式 'all' 转为空数组
if (permissions === 'all') {
return []
}
// 旧单个字符串转为数组
return [permissions]
}
return []
}
/**
* 检查是否有访问特定服务的权限
* @param {string|array} permissions - 权限数据
* @param {string} service - 服务名称claude/gemini/openai/droid
* @returns {boolean} - 是否有权限
*/
function hasPermission(permissions, service) {
const perms = normalizePermissions(permissions)
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
}
function normalizeAccountTypeKey(type) { function normalizeAccountTypeKey(type) {
if (!type) { if (!type) {
return null return null
@@ -89,7 +134,7 @@ class ApiKeyService {
azureOpenaiAccountId = null, azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持 bedrockAccountId = null, // 添加 Bedrock 账号ID支持
droidAccountId = null, droidAccountId = null,
permissions = 'all', // 可选值:'claude''gemini'、'openai'、'droid' 或 'all' permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
isActive = true, isActive = true,
concurrencyLimit = 0, concurrencyLimit = 0,
rateLimitWindow = null, rateLimitWindow = null,
@@ -132,7 +177,7 @@ class ApiKeyService {
azureOpenaiAccountId: azureOpenaiAccountId || '', azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
droidAccountId: droidAccountId || '', droidAccountId: droidAccountId || '',
permissions: permissions || 'all', permissions: JSON.stringify(normalizePermissions(permissions)),
enableModelRestriction: String(enableModelRestriction), enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []), restrictedModels: JSON.stringify(restrictedModels || []),
enableClientRestriction: String(enableClientRestriction || false), enableClientRestriction: String(enableClientRestriction || false),
@@ -186,7 +231,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId, droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions, permissions: normalizePermissions(keyData.permissions),
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels), restrictedModels: JSON.parse(keyData.restrictedModels),
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
@@ -338,7 +383,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId, droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all', permissions: normalizePermissions(keyData.permissions),
tokenLimit: parseInt(keyData.tokenLimit), tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
@@ -467,7 +512,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, bedrockAccountId: keyData.bedrockAccountId,
droidAccountId: keyData.droidAccountId, droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all', permissions: normalizePermissions(keyData.permissions),
tokenLimit: parseInt(keyData.tokenLimit), tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
@@ -525,7 +570,7 @@ class ApiKeyService {
key.isActive = key.isActive === 'true' key.isActive = key.isActive === 'true'
key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true'
key.enableClientRestriction = key.enableClientRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据 key.permissions = normalizePermissions(key.permissions)
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.totalCostLimit = parseFloat(key.totalCostLimit || 0) key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
@@ -1568,7 +1613,7 @@ class ApiKeyService {
userId: keyData.userId, userId: keyData.userId,
userUsername: keyData.userUsername, userUsername: keyData.userUsername,
createdBy: keyData.createdBy, createdBy: keyData.createdBy,
permissions: keyData.permissions, permissions: normalizePermissions(keyData.permissions),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0), totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
// 所有平台账户绑定字段 // 所有平台账户绑定字段
@@ -1820,4 +1865,8 @@ const apiKeyService = new ApiKeyService()
// 为了方便其他服务调用,导出 recordUsage 方法 // 为了方便其他服务调用,导出 recordUsage 方法
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
// 导出权限辅助函数供路由使用
apiKeyService.hasPermission = hasPermission
apiKeyService.normalizePermissions = normalizePermissions
module.exports = apiKeyService module.exports = apiKeyService

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
const vm = require('vm')
const axios = require('axios')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
/**
* 可配置脚本余额查询执行器
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
*/
class BalanceScriptService {
/**
* 执行脚本:返回标准余额结构 + 原始响应
* @param {object} options
* - scriptBody: string
* - variables: Record<string,string>
* - timeoutSeconds: number
*/
async execute(options = {}) {
if (!isBalanceScriptEnabled()) {
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
error.code = 'BALANCE_SCRIPT_DISABLED'
throw error
}
const scriptBody = options.scriptBody?.trim()
if (!scriptBody) {
throw new Error('脚本内容为空')
}
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
const sandbox = {
console,
Math,
Date
}
let scriptResult
try {
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
const script = new vm.Script(wrapped)
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
} catch (error) {
throw new Error(`脚本解析失败: ${error.message}`)
}
if (!scriptResult || typeof scriptResult !== 'object') {
throw new Error('脚本返回格式无效(需返回 { request, extractor }')
}
const variables = options.variables || {}
const request = this.applyTemplates(scriptResult.request || {}, variables)
const { extractor } = scriptResult
if (!request?.url || typeof request.url !== 'string') {
throw new Error('脚本 request.url 不能为空')
}
if (typeof extractor !== 'function') {
throw new Error('脚本 extractor 必须是函数')
}
const axiosConfig = {
url: request.url,
method: (request.method || 'GET').toUpperCase(),
headers: request.headers || {},
timeout: timeoutMs
}
if (request.params) {
axiosConfig.params = request.params
}
if (request.body || request.data) {
axiosConfig.data = request.body || request.data
}
let httpResponse
try {
httpResponse = await axios(axiosConfig)
} catch (error) {
const { response } = error || {}
const { status, data } = response || {}
throw new Error(
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
)
}
const responseData = httpResponse?.data
let extracted = {}
try {
extracted = extractor(responseData) || {}
} catch (error) {
throw new Error(`extractor 执行失败: ${error.message}`)
}
const mapped = this.mapExtractorResult(extracted, responseData)
return {
mapped,
extracted,
response: {
status: httpResponse?.status,
headers: httpResponse?.headers,
data: responseData
}
}
}
applyTemplates(value, variables) {
if (typeof value === 'string') {
return value.replace(/{{(\w+)}}/g, (_, key) => {
const trimmed = key.trim()
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
})
}
if (Array.isArray(value)) {
return value.map((item) => this.applyTemplates(item, variables))
}
if (value && typeof value === 'object') {
const result = {}
Object.keys(value).forEach((k) => {
result[k] = this.applyTemplates(value[k], variables)
})
return result
}
return value
}
mapExtractorResult(result = {}, responseData) {
const isValid = result.isValid !== false
const remaining = Number(result.remaining)
const total = Number(result.total)
const used = Number(result.used)
const currency = result.unit || 'USD'
const quota =
Number.isFinite(total) || Number.isFinite(used)
? {
total: Number.isFinite(total) ? total : null,
used: Number.isFinite(used) ? used : null,
remaining: Number.isFinite(remaining) ? remaining : null,
percentage:
Number.isFinite(total) && total > 0 && Number.isFinite(used)
? (used / total) * 100
: null
}
: null
return {
status: isValid ? 'success' : 'error',
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
balance: Number.isFinite(remaining) ? remaining : null,
currency,
quota,
planName: result.planName || null,
extra: result.extra || null,
rawData: responseData || result.raw
}
}
}
module.exports = new BalanceScriptService()

View File

@@ -91,7 +91,9 @@ class ClaudeAccountService {
useUnifiedClientId = false, // 是否使用统一的客户端标识 useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '', // 统一的客户端标识 unifiedClientId = '', // 统一的客户端标识
expiresAt = null, // 账户订阅到期时间 expiresAt = null, // 账户订阅到期时间
extInfo = null // 额外扩展信息 extInfo = null, // 额外扩展信息
maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -136,7 +138,11 @@ class ClaudeAccountService {
// 账户订阅到期时间 // 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '', subscriptionExpiresAt: expiresAt || '',
// 扩展信息 // 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '' extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
} }
} else { } else {
// 兼容旧格式 // 兼容旧格式
@@ -168,7 +174,11 @@ class ClaudeAccountService {
// 账户订阅到期时间 // 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '', subscriptionExpiresAt: expiresAt || '',
// 扩展信息 // 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '' extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
} }
} }
@@ -216,7 +226,8 @@ class ClaudeAccountService {
useUnifiedUserAgent, useUnifiedUserAgent,
useUnifiedClientId, useUnifiedClientId,
unifiedClientId, unifiedClientId,
extInfo: normalizedExtInfo extInfo: normalizedExtInfo,
interceptWarmup
} }
} }
@@ -574,7 +585,11 @@ class ClaudeAccountService {
// 添加停止原因 // 添加停止原因
stoppedReason: account.stoppedReason || null, stoppedReason: account.stoppedReason || null,
// 扩展信息 // 扩展信息
extInfo: parsedExtInfo extInfo: parsedExtInfo,
// 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
} }
}) })
) )
@@ -666,7 +681,9 @@ class ClaudeAccountService {
'useUnifiedClientId', 'useUnifiedClientId',
'unifiedClientId', 'unifiedClientId',
'subscriptionExpiresAt', 'subscriptionExpiresAt',
'extInfo' 'extInfo',
'maxConcurrency',
'interceptWarmup'
] ]
const updatedData = { ...accountData } const updatedData = { ...accountData }
let shouldClearAutoStopFields = false let shouldClearAutoStopFields = false
@@ -681,7 +698,7 @@ class ClaudeAccountService {
updatedData[field] = this._encryptSensitiveData(value) updatedData[field] = this._encryptSensitiveData(value)
} else if (field === 'proxy') { } else if (field === 'proxy') {
updatedData[field] = value ? JSON.stringify(value) : '' updatedData[field] = value ? JSON.stringify(value) : ''
} else if (field === 'priority') { } else if (field === 'priority' || field === 'maxConcurrency') {
updatedData[field] = value.toString() updatedData[field] = value.toString()
} else if (field === 'subscriptionInfo') { } else if (field === 'subscriptionInfo') {
// 处理订阅信息更新 // 处理订阅信息更新

View File

@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制 maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用) disableAutoProtection = false, // 是否关闭自动防护429/401/400/529 不自动禁用)
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
quotaResetTime, // 额度重置时间 quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间 quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制 maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护 disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
quotaStoppedAt: null, quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置 maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关 disableAutoProtection, // 新增:返回自动防护开关
interceptWarmup, // 新增:返回预热请求拦截开关
activeTaskCount: 0 // 新增新建账户当前并发数为0 activeTaskCount: 0 // 新增新建账户当前并发数为0
} }
} }
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
// 并发控制相关 // 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0, maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount, activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true' disableAutoProtection: accountData.disableAutoProtection === 'true',
// 拦截预热请求
interceptWarmup: accountData.interceptWarmup === 'true'
}) })
} }
} }
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
if (updates.disableAutoProtection !== undefined) { if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString() updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
} }
if (updates.interceptWarmup !== undefined) {
updatedData.interceptWarmup = updates.interceptWarmup.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供 // ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段 // Claude Console 没有 token 刷新逻辑,不会覆盖此字段

View File

@@ -210,7 +210,17 @@ class ClaudeRelayService {
logger.error('❌ accountId missing for queue lock in relayRequest') logger.error('❌ accountId missing for queue lock in relayRequest')
throw new Error('accountId missing for queue lock') throw new Error('accountId missing for queue lock')
} }
const queueResult = await userMessageQueueService.acquireQueueLock(accountId) // 获取账户信息以检查账户级串行队列配置
const accountForQueue = await claudeAccountService.getAccount(accountId)
const accountConfig = accountForQueue
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
: null
const queueResult = await userMessageQueueService.acquireQueueLock(
accountId,
null,
null,
accountConfig
)
if (!queueResult.acquired && !queueResult.skipped) { if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时 // 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error' const isBackendError = queueResult.error === 'queue_backend_error'
@@ -323,7 +333,14 @@ class ClaudeRelayService {
} }
// 发送请求到Claude API传入回调以获取请求对象 // 发送请求到Claude API传入回调以获取请求对象
const response = await this._makeClaudeRequest( // 🔄 403 重试机制:仅对 claude-official 类型账户OAuth 或 Setup Token
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
let retryCount = 0
let response
let shouldRetry = false
do {
response = await this._makeClaudeRequest(
processedBody, processedBody,
accessToken, accessToken,
proxyAgent, proxyAgent,
@@ -335,6 +352,28 @@ class ClaudeRelayService {
options options
) )
// 检查是否需要重试 403
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
if (shouldRetry) {
retryCount++
logger.warn(
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
)
await this._sleep(2000)
}
} while (shouldRetry)
// 如果进行了重试,记录最终结果
if (retryCount > 0) {
if (response.statusCode === 403) {
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
} else {
logger.info(
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
)
}
}
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻 // 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && selectedAccountId) { if (queueLockAcquired && queueRequestId && selectedAccountId) {
@@ -398,9 +437,10 @@ class ClaudeRelayService {
} }
} }
// 检查是否为403状态码禁止访问 // 检查是否为403状态码禁止访问
// 注意如果进行了重试retryCount > 0这里的 403 是重试后最终的结果
else if (response.statusCode === 403) { else if (response.statusCode === 403) {
logger.error( logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} }
@@ -1314,7 +1354,17 @@ class ClaudeRelayService {
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture') logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
throw new Error('accountId missing for queue lock') throw new Error('accountId missing for queue lock')
} }
const queueResult = await userMessageQueueService.acquireQueueLock(accountId) // 获取账户信息以检查账户级串行队列配置
const accountForQueue = await claudeAccountService.getAccount(accountId)
const accountConfig = accountForQueue
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
: null
const queueResult = await userMessageQueueService.acquireQueueLock(
accountId,
null,
null,
accountConfig
)
if (!queueResult.acquired && !queueResult.skipped) { if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时 // 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error' const isBackendError = queueResult.error === 'queue_backend_error'
@@ -1497,8 +1547,10 @@ class ClaudeRelayService {
streamTransformer = null, streamTransformer = null,
requestOptions = {}, requestOptions = {},
isDedicatedOfficialAccount = false, isDedicatedOfficialAccount = false,
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁 onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
retryCount = 0 // 🔄 403 重试计数器
) { ) {
const maxRetries = 2 // 最大重试次数
// 获取账户信息用于统一 User-Agent // 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
@@ -1611,6 +1663,51 @@ class ClaudeRelayService {
} }
} }
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
if (res.statusCode === 403) {
const canRetry =
this._shouldRetryOn403(accountType) &&
retryCount < maxRetries &&
!responseStream.headersSent
if (canRetry) {
logger.warn(
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
)
// 消费当前响应并销毁请求
res.resume()
req.destroy()
// 等待 2 秒后递归重试
await this._sleep(2000)
try {
// 递归调用自身进行重试
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
body,
accessToken,
proxyAgent,
clientHeaders,
responseStream,
usageCallback,
accountId,
accountType,
sessionHash,
streamTransformer,
requestOptions,
isDedicatedOfficialAccount,
onResponseStart,
retryCount + 1
)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
return // 重要:提前返回,不设置后续的错误处理器
}
}
// 将错误处理逻辑封装在一个异步函数中 // 将错误处理逻辑封装在一个异步函数中
const handleErrorResponse = async () => { const handleErrorResponse = async () => {
if (res.statusCode === 401) { if (res.statusCode === 401) {
@@ -1634,8 +1731,10 @@ class ClaudeRelayService {
) )
} }
} else if (res.statusCode === 403) { } else if (res.statusCode === 403) {
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
logger.error( logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} else if (res.statusCode === 529) { } else if (res.statusCode === 529) {
@@ -2456,19 +2555,14 @@ class ClaudeRelayService {
} }
} }
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture // 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用
async testAccountConnection(accountId, responseStream) { async _prepareAccountForTest(accountId) {
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
try {
// 获取账户信息 // 获取账户信息
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
if (!account) { if (!account) {
throw new Error('Account not found') throw new Error('Account not found')
} }
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
if (!accessToken) { if (!accessToken) {
@@ -2478,6 +2572,18 @@ class ClaudeRelayService {
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
return { account, accessToken, proxyAgent }
}
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload(model, { stream: true })
try {
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
// 设置响应头 // 设置响应头
if (!responseStream.headersSent) { if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader const existingConnection = responseStream.getHeader
@@ -2526,6 +2632,125 @@ class ClaudeRelayService {
} }
} }
// 🧪 非流式测试账号连接(供定时任务使用)
// 复用流式请求方法,收集结果后返回
async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload(model, { stream: true })
const startTime = Date.now()
try {
// 使用公共方法准备测试所需的账户信息、token 和代理
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`)
// 创建一个收集器来捕获流式响应
let responseText = ''
let capturedUsage = null
let capturedModel = model
let hasError = false
let errorMessage = ''
// 创建模拟的响应流对象
const mockResponseStream = {
headersSent: true, // 跳过设置响应头
write: (data) => {
// 解析 SSE 数据
if (typeof data === 'string' && data.startsWith('data: ')) {
try {
const jsonStr = data.replace('data: ', '').trim()
if (jsonStr && jsonStr !== '[DONE]') {
const parsed = JSON.parse(jsonStr)
// 提取文本内容
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
responseText += parsed.delta.text
}
// 提取 usage 信息
if (parsed.type === 'message_delta' && parsed.usage) {
capturedUsage = parsed.usage
}
// 提取模型信息
if (parsed.type === 'message_start' && parsed.message?.model) {
capturedModel = parsed.message.model
}
// 检测错误
if (parsed.type === 'error') {
hasError = true
errorMessage = parsed.error?.message || 'Unknown error'
}
}
} catch {
// 忽略解析错误
}
}
return true
},
end: () => {},
on: () => {},
once: () => {},
emit: () => {},
writable: true
}
// 复用流式请求方法
await this._makeClaudeStreamRequestWithUsageCapture(
testRequestBody,
accessToken,
proxyAgent,
{}, // clientHeaders - 测试不需要客户端headers
mockResponseStream,
null, // usageCallback - 测试不需要统计
accountId,
'claude-official', // accountType
null, // sessionHash - 测试不需要会话
null, // streamTransformer - 不需要转换,直接解析原始格式
{}, // requestOptions
false // isDedicatedOfficialAccount
)
const latencyMs = Date.now() - startTime
if (hasError) {
logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`)
return {
success: false,
error: errorMessage,
latencyMs,
timestamp: new Date().toISOString()
}
}
logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`)
return {
success: true,
message: responseText.substring(0, 200), // 截取前200字符
latencyMs,
model: capturedModel,
usage: capturedUsage,
timestamp: new Date().toISOString()
}
} catch (error) {
const latencyMs = Date.now() - startTime
logger.error(`❌ Test account connection (sync) failed:`, error.message)
// 提取错误详情
let errorMessage = error.message
if (error.response) {
errorMessage =
error.response.data?.error?.message || error.response.statusText || error.message
}
return {
success: false,
error: errorMessage,
statusCode: error.response?.status,
latencyMs,
timestamp: new Date().toISOString()
}
}
}
// 🎯 健康检查 // 🎯 健康检查
async healthCheck() { async healthCheck() {
try { try {
@@ -2547,6 +2772,17 @@ class ClaudeRelayService {
} }
} }
} }
// 🔄 判断账户是否应该在 403 错误时进行重试
// 仅 claude-official 类型账户OAuth 或 Setup Token 授权)需要重试
_shouldRetryOn403(accountType) {
return accountType === 'claude-official'
}
// ⏱️ 等待指定毫秒数
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
} }
module.exports = new ClaudeRelayService() module.exports = new ClaudeRelayService()

View File

@@ -36,15 +36,28 @@ class OpenAIToClaudeConverter {
// 如果 OpenAI 请求中包含系统消息,提取并检查 // 如果 OpenAI 请求中包含系统消息,提取并检查
const systemMessage = this._extractSystemMessage(openaiRequest.messages) const systemMessage = this._extractSystemMessage(openaiRequest.messages)
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
// Xcode 系统提示词 const passThroughSystemPrompt =
String(process.env.CRS_PASSTHROUGH_SYSTEM_PROMPT || '').toLowerCase() === 'true'
if (
systemMessage &&
(passThroughSystemPrompt || systemMessage.includes('You are currently in Xcode'))
) {
claudeRequest.system = systemMessage claudeRequest.system = systemMessage
if (systemMessage.includes('You are currently in Xcode')) {
logger.info( logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` `🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
) )
} else {
logger.info(
`🧩 Using caller-provided system prompt (${systemMessage.length} chars) because CRS_PASSTHROUGH_SYSTEM_PROMPT=true`
)
}
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
} else { } else {
// 使用 Claude Code 默认系统提示词 // 默认行为:兼容 Claude Code(忽略外部 system
claudeRequest.system = claudeCodeSystemMessage claudeRequest.system = claudeCodeSystemMessage
logger.debug( logger.debug(
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`

View File

@@ -121,12 +121,23 @@ class UserMessageQueueService {
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID可选会自动生成 * @param {string} requestId - 请求ID可选会自动生成
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值) * @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用=0使用全局配置
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>} * @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
*/ */
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) { async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
const cfg = await this.getConfig() const cfg = await this.getConfig()
if (!cfg.enabled) { // 账户级配置优先maxConcurrency > 0 时强制启用,忽略全局开关
let queueEnabled = cfg.enabled
if (accountConfig && accountConfig.maxConcurrency > 0) {
queueEnabled = true
logger.debug(
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
)
}
if (!queueEnabled) {
return { acquired: true, requestId: requestId || uuidv4(), skipped: true } return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
} }

44
src/utils/featureFlags.js Normal file
View File

@@ -0,0 +1,44 @@
let config = {}
try {
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js
// 为保证可运行,这里做容错处理
// eslint-disable-next-line global-require
config = require('../../config/config')
} catch (error) {
config = {}
}
const parseBooleanEnv = (value) => {
if (typeof value === 'boolean') {
return value
}
if (typeof value !== 'string') {
return false
}
const normalized = value.trim().toLowerCase()
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
}
/**
* 是否允许执行“余额脚本”(安全开关)
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false环境变量优先
*/
const isBalanceScriptEnabled = () => {
if (
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
process.env.BALANCE_SCRIPT_ENABLED !== ''
) {
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
}
const fromConfig =
config?.accountBalance?.enableBalanceScript ??
config?.features?.balanceScriptEnabled ??
config?.security?.enableBalanceScript
return typeof fromConfig === 'boolean' ? fromConfig : true
}
module.exports = {
isBalanceScriptEnabled
}

View File

@@ -0,0 +1,202 @@
'use strict'
const { v4: uuidv4 } = require('uuid')
/**
* 预热请求拦截器
* 检测并拦截低价值请求标题生成、Warmup等直接返回模拟响应
*/
/**
* 检测是否为预热请求
* @param {Object} body - 请求体
* @returns {boolean}
*/
function isWarmupRequest(body) {
if (!body) {
return false
}
// 检查 messages
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
// 处理 content 为数组的情况
if (Array.isArray(msg.content)) {
for (const content of msg.content) {
if (content.type === 'text' && typeof content.text === 'string') {
if (isTitleOrWarmupText(content.text)) {
return true
}
}
}
}
// 处理 content 为字符串的情况
if (typeof msg.content === 'string') {
if (isTitleOrWarmupText(msg.content)) {
return true
}
}
}
}
// 检查 system prompt
if (body.system) {
const systemText = extractSystemText(body.system)
if (isTitleExtractionSystemPrompt(systemText)) {
return true
}
}
return false
}
/**
* 检查文本是否为标题生成或Warmup请求
*/
function isTitleOrWarmupText(text) {
if (!text) {
return false
}
return (
text.includes('Please write a 5-10 word title for the following conversation:') ||
text === 'Warmup'
)
}
/**
* 检查system prompt是否为标题提取类型
*/
function isTitleExtractionSystemPrompt(systemText) {
if (!systemText) {
return false
}
return systemText.includes(
'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title'
)
}
/**
* 从system字段提取文本
*/
function extractSystemText(system) {
if (typeof system === 'string') {
return system
}
if (Array.isArray(system)) {
return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('')
}
return ''
}
/**
* 生成模拟的非流式响应
* @param {string} model - 模型名称
* @returns {Object}
*/
function buildMockWarmupResponse(model) {
return {
id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'New Conversation' }],
model: model || 'claude-3-5-sonnet-20241022',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 2
}
}
}
/**
* 发送模拟的流式响应
* @param {Object} res - Express response对象
* @param {string} model - 模型名称
*/
function sendMockWarmupStream(res, model) {
const effectiveModel = model || 'claude-3-5-sonnet-20241022'
const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`
const events = [
{
event: 'message_start',
data: {
message: {
content: [],
id: messageId,
model: effectiveModel,
role: 'assistant',
stop_reason: null,
stop_sequence: null,
type: 'message',
usage: { input_tokens: 10, output_tokens: 0 }
},
type: 'message_start'
}
},
{
event: 'content_block_start',
data: {
content_block: { text: '', type: 'text' },
index: 0,
type: 'content_block_start'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: 'New', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: ' Conversation', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_stop',
data: { index: 0, type: 'content_block_stop' }
},
{
event: 'message_delta',
data: {
delta: { stop_reason: 'end_turn', stop_sequence: null },
type: 'message_delta',
usage: { input_tokens: 10, output_tokens: 2 }
}
},
{
event: 'message_stop',
data: { type: 'message_stop' }
}
]
let index = 0
const sendNext = () => {
if (index >= events.length) {
res.end()
return
}
const { event, data } = events[index]
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
index++
// 模拟网络延迟
setTimeout(sendNext, 20)
}
sendNext()
}
module.exports = {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
}

View File

@@ -0,0 +1,218 @@
// Mock logger避免测试输出污染控制台
jest.mock('../src/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}))
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
const { AccountBalanceService } = accountBalanceServiceModule
describe('AccountBalanceService', () => {
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
afterEach(() => {
if (originalBalanceScriptEnabled === undefined) {
delete process.env.BALANCE_SCRIPT_ENABLED
} else {
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
}
})
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}
const buildMockRedis = () => ({
getLocalBalance: jest.fn().mockResolvedValue(null),
setLocalBalance: jest.fn().mockResolvedValue(undefined),
getAccountBalance: jest.fn().mockResolvedValue(null),
setAccountBalance: jest.fn().mockResolvedValue(undefined),
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
getAccountUsageStats: jest.fn().mockResolvedValue({
total: { requests: 10 },
daily: { requests: 2, cost: 20 },
monthly: { requests: 5 }
}),
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
})
it('should normalize platform aliases', () => {
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
expect(service.normalizePlatform('claude-official')).toBe('claude')
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
})
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
queryApi: false,
useCache: true
})
expect(result.success).toBe(true)
expect(result.data.source).toBe('local')
expect(result.data.balance.amount).toBeCloseTo(80, 6)
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
})
it('should use cached balance when account has no dailyQuota', async () => {
const mockRedis = buildMockRedis()
mockRedis.getAccountBalance.mockResolvedValue({
status: 'success',
balance: 12.34,
currency: 'USD',
quota: null,
errorMessage: '',
lastRefreshAt: '2025-01-01T00:00:00Z',
ttlSeconds: 120
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const account = { id: 'acct-2', name: 'B' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: false,
useCache: true
})
expect(result.data.source).toBe('cache')
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
service.registerProvider('openai', {
queryBalance: () => {
throw new Error('boom')
}
})
const account = { id: 'acct-3', name: 'C' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('local')
expect(result.data.status).toBe('error')
expect(result.data.error).toBe('boom')
})
it('should ignore script config when balance script is disabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'false'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
service.registerProvider('openai', provider)
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
const account = { id: 'acct-script-off', name: 'S' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).toHaveBeenCalled()
expect(scriptSpy).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
})
it('should prefer script when configured and enabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'true'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
service.registerProvider('openai', provider)
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
status: 'success',
balance: 3,
currency: 'USD',
quota: null,
queryMethod: 'script',
rawData: { ok: true },
lastRefreshAt: '2025-01-01T00:00:00Z',
errorMessage: ''
})
const account = { id: 'acct-script-on', name: 'T' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
expect(result.data.balance.amount).toBeCloseTo(3, 6)
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should count low balance once per account in summary', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service.getSupportedPlatforms = () => ['claude-console']
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
service._getAccountBalanceForAccount = async () => ({
success: true,
data: {
accountId: 'acct-4',
platform: 'claude-console',
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
quota: { percentage: 95 },
statistics: { totalCost: 1 },
source: 'local',
lastRefreshAt: '2025-01-01T00:00:00Z',
cacheExpiresAt: null,
status: 'success',
error: null
}
})
const summary = await service.getBalanceSummary()
expect(summary.lowBalanceCount).toBe(1)
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
})
})

View File

@@ -15,6 +15,7 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0" "xlsx-js-style": "^1.2.0"
@@ -5131,6 +5132,16 @@
} }
} }
}, },
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-demi": { "node_modules/vue-demi": {
"version": "0.14.10", "version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",

View File

@@ -18,6 +18,7 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0" "xlsx-js-style": "^1.2.0"

View File

@@ -0,0 +1,302 @@
<template>
<el-dialog
:append-to-body="true"
class="balance-script-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:title="`配置余额脚本 - ${account?.name || ''}`"
top="5vh"
width="720px"
@close="emitClose"
>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>请求地址baseUrl</label
>
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token可选</label>
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra / userId)</label
>
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间()</label>
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>自动查询间隔(分钟)</label
>
<input
v-model.number="form.autoIntervalMinutes"
class="input-text"
min="0"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{ '}'
}}{{ '}' }}
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
<button
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
@click="applyPreset"
>
使用示例
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
spellcheck="false"
></textarea>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
extractor 可返回isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
<div class="flex items-center justify-between">
<span class="font-semibold">测试结果</span>
<span
:class="[
'rounded px-2 py-0.5 text-xs',
testResult.mapped?.status === 'success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
]"
>
{{ testResult.mapped?.status || 'unknown' }}
</span>
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
错误: {{ testResult.mapped.errorMessage }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.extracted)
}}</pre>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.response)
}}</pre>
</details>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
<el-button @click="emitClose">取消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: { type: Boolean, default: false },
account: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'saved'])
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer {{apiKey}}",
"New-Api-User": "{{extra}}"
}
},
extractor: function (response) {
if (response && response.success && response.data) {
const quota = response.data.quota || 0;
const used = response.data.used_quota || 0;
return {
planName: response.data.group || "默认套餐",
remaining: quota / 500000,
used: used / 500000,
total: (quota + used) / 500000,
unit: "USD"
};
}
return {
isValid: false,
invalidMessage: (response && response.message) || "查询失败"
};
}
})`
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const buildDefaultForm = () => ({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
scriptBody: presetScript
})
const emitClose = () => emit('close')
const resetForm = () => {
Object.assign(form, buildDefaultForm())
testResult.value = null
saving.value = false
testing.value = false
}
const loadConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
try {
const res = await apiClient.get(
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
)
if (res?.success && res.data) {
Object.assign(form, res.data)
}
} catch (error) {
showToast('加载脚本配置失败', 'error')
}
}
const saveConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
saving.value = true
try {
await apiClient.put(
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
{ ...form }
)
showToast('已保存', 'success')
emit('saved')
} catch (error) {
showToast(error.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
const testScript = async () => {
if (!props.account?.id || !props.account?.platform) return
testing.value = true
testResult.value = null
try {
const res = await apiClient.post(
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
{ ...form }
)
if (res?.success) {
testResult.value = res.data
showToast('测试完成', 'success')
} else {
showToast(res?.error || '测试失败', 'error')
}
} catch (error) {
showToast(error.message || '测试失败', 'error')
} finally {
testing.value = false
}
}
const applyPreset = () => {
form.scriptBody = presetScript
}
const displayAmount = (val) => {
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
return Number(val).toFixed(2)
}
const formatJson = (data) => {
try {
return JSON.stringify(data, null, 2)
} catch (error) {
return String(data)
}
}
watch(
() => props.show,
(val) => {
if (val) {
resetForm()
loadConfig()
}
}
)
</script>
<style scoped>
:deep(.balance-script-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
}
:deep(.balance-script-dialog .el-dialog__body) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
:deep(.balance-script-dialog .el-dialog__footer) {
border-top: 1px solid rgba(229, 231, 235, 0.7);
}
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}
</style>

View File

@@ -1662,6 +1662,47 @@
</label> </label>
</div> </div>
<!-- Claude 账户级串行队列开关 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console -->
<div
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
class="mt-4"
>
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置 --> <!-- Claude User-Agent 版本配置 -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -2647,6 +2688,44 @@
</label> </label>
</div> </div>
<!-- Claude 账户级串行队列开关(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console 编辑模式) -->
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置(编辑模式) --> <!-- Claude User-Agent 版本配置(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -3982,6 +4061,9 @@ const form = ref({
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本 useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识 useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识 unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
interceptWarmup:
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
groupId: '', groupId: '',
groupIds: [], groupIds: [],
projectId: props.account?.projectId || '', projectId: props.account?.projectId || '',
@@ -4572,9 +4654,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
claudeAiOauth: claudeOauthPayload, claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50, priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false, autoStopOnWarning: form.value.autoStopOnWarning || false,
interceptWarmup: form.value.interceptWarmup || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false, useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false, useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId, unifiedClientId: clientId,
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
subscriptionInfo: { subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max', hasClaudeMax: form.value.subscriptionType === 'claude_max',
@@ -4712,6 +4796,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息 // 添加订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -5040,6 +5125,7 @@ const createAccount = async () => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息 // 添加订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -5131,6 +5217,7 @@ const createAccount = async () => {
// 上游错误处理(仅 Claude Console // 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') { if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
} }
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
@@ -5431,9 +5518,11 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50 data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false data.autoStopOnWarning = form.value.autoStopOnWarning || false
data.interceptWarmup = form.value.interceptWarmup || false
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 更新订阅类型信息 // 更新订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -5466,6 +5555,8 @@ const updateAccount = async () => {
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理 // 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00' data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -6034,9 +6125,12 @@ watch(
accountType: newAccount.accountType || 'shared', accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType, subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false, autoStopOnWarning: newAccount.autoStopOnWarning || false,
interceptWarmup:
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false, useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
useUnifiedClientId: newAccount.useUnifiedClientId || false, useUnifiedClientId: newAccount.useUnifiedClientId || false,
unifiedClientId: newAccount.unifiedClientId || '', unifiedClientId: newAccount.unifiedClientId || '',
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
groupId: groupId, groupId: groupId,
groupIds: [], groupIds: [],
projectId: newAccount.projectId || '', projectId: newAccount.projectId || '',

View File

@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
>
<i class="fas fa-clock" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ account?.name || '未知账户' }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 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"
:disabled="saving"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="px-5 py-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
</div>
<template v-else>
<!-- 启用开关 -->
<div class="mb-5 flex items-center justify-between">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
</div>
<button
:class="[
'relative h-6 w-11 rounded-full transition-colors duration-200',
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
]"
@click="config.enabled = !config.enabled"
>
<span
:class="[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
config.enabled ? 'left-5' : 'left-0.5'
]"
/>
</button>
</div>
<!-- Cron 表达式配置 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Cron 表达式
</label>
<input
v-model="config.cronExpression"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="0 8 * * *"
type="text"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
格式: (: "0 8 * * *" = 每天8:00)
</p>
</div>
<!-- 快捷选项 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
快捷设置
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in cronPresets"
:key="preset.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.cronExpression === preset.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.cronExpression = preset.value"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- 测试模型选择 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
测试模型
</label>
<input
v-model="config.model"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="claude-sonnet-4-5-20250929"
type="text"
/>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="modelOption in modelOptions"
:key="modelOption.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.model === modelOption.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.model = modelOption.value"
>
{{ modelOption.label }}
</button>
</div>
</div>
<!-- 测试历史 -->
<div v-if="testHistory.length > 0" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
最近测试记录
</label>
<div
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
v-for="(record, index) in testHistory"
:key="index"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<i
:class="[
'fas',
record.success
? 'fa-check-circle text-green-500'
: 'fa-times-circle text-red-500'
]"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ formatTimestamp(record.timestamp) }}
</span>
</div>
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
{{ record.latencyMs }}ms
</span>
<span
v-else-if="record.error"
class="max-w-[150px] truncate text-red-500"
:title="record.error"
>
{{ record.error }}
</span>
</div>
</div>
</div>
<!-- 无历史记录 -->
<div
v-else
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
<p>暂无测试记录</p>
</div>
</template>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="saving"
@click="handleClose"
>
取消
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
saving
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="saving || loading"
@click="saveConfig"
>
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import { API_PREFIX } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'saved'])
// 状态
const loading = ref(false)
const saving = ref(false)
const config = ref({
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
})
const testHistory = ref([])
// Cron 预设选项
const cronPresets = [
{ label: '每天 8:00', value: '0 8 * * *' },
{ label: '每天 12:00', value: '0 12 * * *' },
{ label: '每天 18:00', value: '0 18 * * *' },
{ label: '每6小时', value: '0 */6 * * *' },
{ label: '每12小时', value: '0 */12 * * *' },
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
]
// 模型选项
const modelOptions = [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
]
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '未知'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 加载配置
async function loadConfig() {
if (!props.account) return
loading.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
// 根据平台获取配置端点
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
// 其他平台暂不支持
loading.value = false
return
}
// 获取配置
const configRes = await fetch(endpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (configRes.ok) {
const data = await configRes.json()
if (data.success && data.data?.config) {
config.value = {
enabled: data.data.config.enabled || false,
cronExpression: data.data.config.cronExpression || '0 8 * * *',
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
}
}
}
// 获取测试历史
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
const historyRes = await fetch(historyEndpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (historyRes.ok) {
const historyData = await historyRes.json()
if (historyData.success && historyData.data?.history) {
testHistory.value = historyData.data.history
}
}
} catch (err) {
showToast('加载配置失败: ' + err.message, 'error')
} finally {
loading.value = false
}
}
// 保存配置
async function saveConfig() {
if (!props.account) return
saving.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
saving.value = false
return
}
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
enabled: config.value.enabled,
cronExpression: config.value.cronExpression,
model: config.value.model
})
})
if (res.ok) {
showToast('配置已保存', 'success')
emit('saved')
handleClose()
} else {
const errorData = await res.json().catch(() => ({}))
showToast(errorData.message || '保存失败', 'error')
}
} catch (err) {
showToast('保存失败: ' + err.message, 'error')
} finally {
saving.value = false
}
}
// 关闭模态框
function handleClose() {
if (saving.value) return
emit('close')
}
// 监听 show 变化,加载配置
watch(
() => props.show,
(newVal) => {
if (newVal) {
config.value = {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
testHistory.value = []
loadConfig()
}
}
)
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="min-w-[200px] space-y-1">
<div v-if="loading" class="flex items-center gap-2">
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
</div>
<div v-else-if="requestError" class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-500"></i>
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
<button
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
:disabled="refreshing"
@click="reload"
>
重试
</button>
</div>
<div v-else-if="balanceData" class="space-y-1">
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
{{ balanceData.error }}
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i
class="fas"
:class="
balanceData.balance
? 'fa-wallet text-green-600 dark:text-green-400'
: 'fa-chart-line text-gray-500 dark:text-gray-400'
"
></i>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ primaryText }}
</span>
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
{{ sourceLabel }}
</span>
</div>
<button
v-if="!hideRefresh"
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing || !canRefresh"
:title="refreshTitle"
@click="refresh"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
</button>
</div>
<!-- 配额如适用 -->
<div v-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-1.5 rounded-full transition-all"
:class="quotaBarClass"
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
></div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
</span>
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
重置: {{ formatResetTime(quotaInfo.resetAt) }}
</span>
</div>
</div>
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
</div>
<div
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
class="text-xs text-gray-400 dark:text-gray-500"
>
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { apiClient } from '@/config/api'
const props = defineProps({
accountId: { type: String, required: true },
platform: { type: String, required: true },
initialBalance: { type: Object, default: null },
hideRefresh: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true }
})
const emit = defineEmits(['refreshed', 'error'])
const balanceData = ref(props.initialBalance)
const loading = ref(false)
const refreshing = ref(false)
const requestError = ref(null)
const sourceClass = computed(() => {
const source = balanceData.value?.source
return {
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
}
})
const sourceLabel = computed(() => {
const source = balanceData.value?.source
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
})
const quotaInfo = computed(() => {
const quota = balanceData.value?.quota
if (!quota || quota.unlimited) return null
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
return {
used: quota.used ?? 0,
remaining: quota.remaining ?? 0,
percentage: quota.percentage,
resetAt: quota.resetAt || null
}
})
const quotaBarClass = computed(() => {
const percentage = quotaInfo.value?.percentage || 0
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
return 'bg-green-500 dark:bg-green-600'
})
const canRefresh = computed(() => {
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
const data = balanceData.value
if (!data) return false
if (data.scriptEnabled === false) return false
return !!data.scriptConfigured
})
const refreshTitle = computed(() => {
if (refreshing.value) return '刷新中...'
if (!canRefresh.value) {
if (balanceData.value?.scriptEnabled === false) {
return '余额脚本功能已禁用'
}
return '请先配置余额脚本'
}
return '刷新余额(调用脚本配置的余额 API'
})
const primaryText = computed(() => {
if (balanceData.value?.balance?.formattedAmount) {
return balanceData.value.balance.formattedAmount
}
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
return `今日成本 ${formatCurrency(dailyCost)}`
})
const load = async () => {
if (!props.autoLoad) return
if (!props.accountId || !props.platform) return
loading.value = true
requestError.value = null
try {
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
params: { platform: props.platform, queryApi: false }
})
if (response?.success) {
balanceData.value = response.data
} else {
requestError.value = response?.error || '加载失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
loading.value = false
}
}
const refresh = async () => {
if (!props.accountId || !props.platform) return
if (refreshing.value) return
if (!canRefresh.value) return
refreshing.value = true
requestError.value = null
try {
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
platform: props.platform
})
if (response?.success) {
balanceData.value = response.data
emit('refreshed', response.data)
} else {
requestError.value = response?.error || '刷新失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
refreshing.value = false
}
}
const reload = async () => {
await load()
}
const formatNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const formatCurrency = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatResetTime = (isoString) => {
const date = new Date(isoString)
const now = new Date()
const diff = date.getTime() - now.getTime()
if (!Number.isFinite(diff)) return '未知'
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const remainMinutes = minutes % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
return `${days}天后`
}
return `${hours}小时${remainMinutes}分钟`
}
const formatCacheExpiry = (isoString) => {
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
watch(
() => props.initialBalance,
(newVal) => {
if (newVal) {
balanceData.value = newVal
}
}
)
onMounted(() => {
if (!props.initialBalance) {
load()
}
})
defineExpose({ refresh, reload })
</script>

View File

@@ -579,55 +579,46 @@
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label >服务权限</label
> >
<div class="flex gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="claude" value="claude"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span> <span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="gemini" value="gemini"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span> <span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="openai" value="openai"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span> <span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="droid" value="droid"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span> <span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务 不选择任何服务表示允许访问全部服务
</p> </p>
</div> </div>
@@ -662,7 +653,7 @@
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'" :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups" :groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号" placeholder="请选择Claude账号"
platform="claude" platform="claude"
@@ -676,7 +667,7 @@
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" :disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups" :groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号" placeholder="请选择Gemini账号"
platform="gemini" platform="gemini"
@@ -690,7 +681,7 @@
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups" :groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号" placeholder="请选择OpenAI账号"
platform="openai" platform="openai"
@@ -704,7 +695,7 @@
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]" :groups="[]"
placeholder="请选择Bedrock账号" placeholder="请选择Bedrock账号"
platform="bedrock" platform="bedrock"
@@ -718,7 +709,7 @@
v-model="form.droidAccountId" v-model="form.droidAccountId"
:accounts="localAccounts.droid" :accounts="localAccounts.droid"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'" :disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups" :groups="localAccounts.droidGroups"
placeholder="请选择Droid账号" placeholder="请选择Droid账号"
platform="droid" platform="droid"
@@ -966,7 +957,7 @@ const form = reactive({
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活) expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数 activationDays: 30, // 激活后有效天数
activationUnit: 'days', // 激活时间单位hours 或 days activationUnit: 'days', // 激活时间单位hours 或 days
permissions: 'all', permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '', claudeAccountId: '',
geminiAccountId: '', geminiAccountId: '',
openaiAccountId: '', openaiAccountId: '',

View File

@@ -412,55 +412,46 @@
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label >服务权限</label
> >
<div class="flex gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="claude" value="claude"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span> <span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="gemini" value="gemini"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span> <span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="openai" value="openai"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span> <span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.permissions" v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio" type="checkbox"
value="droid" value="droid"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span> <span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务 不选择任何服务表示允许访问全部服务
</p> </p>
</div> </div>
@@ -495,7 +486,7 @@
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'" :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups" :groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号" placeholder="请选择Claude账号"
platform="claude" platform="claude"
@@ -509,7 +500,7 @@
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" :disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups" :groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号" placeholder="请选择Gemini账号"
platform="gemini" platform="gemini"
@@ -523,7 +514,7 @@
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups" :groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号" placeholder="请选择OpenAI账号"
platform="openai" platform="openai"
@@ -537,7 +528,7 @@
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]" :groups="[]"
placeholder="请选择Bedrock账号" placeholder="请选择Bedrock账号"
platform="bedrock" platform="bedrock"
@@ -551,7 +542,7 @@
v-model="form.droidAccountId" v-model="form.droidAccountId"
:accounts="localAccounts.droid" :accounts="localAccounts.droid"
default-option-text="使用共享账号池" default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'" :disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups" :groups="localAccounts.droidGroups"
placeholder="请选择Droid账号" placeholder="请选择Droid账号"
platform="droid" platform="droid"
@@ -800,7 +791,7 @@ const form = reactive({
dailyCostLimit: '', dailyCostLimit: '',
totalCostLimit: '', totalCostLimit: '',
weeklyOpusCostLimit: '', weeklyOpusCostLimit: '',
permissions: 'all', permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '', claudeAccountId: '',
geminiAccountId: '', geminiAccountId: '',
openaiAccountId: '', openaiAccountId: '',
@@ -1241,7 +1232,17 @@ onMounted(async () => {
form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalCostLimit = props.apiKey.totalCostLimit || '' form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all' // 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions
if (Array.isArray(perms)) {
form.permissions = perms
} else if (perms === 'all' || !perms) {
form.permissions = []
} else if (typeof perms === 'string') {
form.permissions = [perms]
} else {
form.permissions = []
}
// 处理 Claude 账号(区分 OAuth 和 Console // 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) { if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}` form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`

View File

@@ -0,0 +1,750 @@
<template>
<div v-if="authStore.publicStats" class="public-stats-overview">
<!-- 顶部状态栏服务状态 + 平台可用性 -->
<div class="header-section">
<div class="flex items-center gap-2">
<div
class="status-badge"
:class="{
'status-healthy': authStore.publicStats.serviceStatus === 'healthy',
'status-degraded': authStore.publicStats.serviceStatus === 'degraded'
}"
>
<span class="status-dot"></span>
<span class="status-text">{{
authStore.publicStats.serviceStatus === 'healthy' ? '服务正常' : '服务降级'
}}</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
运行 {{ formatUptime(authStore.publicStats.uptime) }}
</span>
</div>
<div class="flex flex-wrap justify-center gap-2 md:justify-end">
<div
v-for="(available, platform) in authStore.publicStats.platforms"
:key="platform"
class="platform-badge"
:class="{ available: available, unavailable: !available }"
>
<i class="mr-1" :class="getPlatformIcon(platform)"></i>
<span>{{ getPlatformName(platform) }}</span>
</div>
</div>
</div>
<!-- 主内容区今日统计 + 模型分布 -->
<div class="main-content">
<!-- 左侧今日统计 -->
<div class="stats-section">
<div class="section-title-left">今日统计</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">
{{ formatNumber(authStore.publicStats.todayStats.requests) }}
</div>
<div class="stat-label">请求数</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.tokens) }}
</div>
<div class="stat-label">Tokens</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.inputTokens) }}
</div>
<div class="stat-label">输入</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.outputTokens) }}
</div>
<div class="stat-label">输出</div>
</div>
</div>
</div>
<!-- 右侧模型使用分布 -->
<div
v-if="
authStore.publicStats.showOptions?.modelDistribution &&
authStore.publicStats.modelDistribution?.length > 0
"
class="model-section"
>
<div class="section-title-left">
模型使用分布
<span class="period-label">{{
formatPeriodLabel(authStore.publicStats.modelDistributionPeriod)
}}</span>
</div>
<div class="model-chart-container">
<Doughnut :data="modelChartData" :options="modelChartOptions" />
</div>
</div>
</div>
<!-- 趋势图表三合一双Y轴折线图 -->
<div v-if="hasAnyTrendData" class="chart-section">
<div class="section-title-left">使用趋势近7天</div>
<div class="chart-container">
<Line :data="chartData" :options="chartOptions" />
</div>
<!-- 图例 -->
<div class="chart-legend">
<div v-if="authStore.publicStats.showOptions?.tokenTrends" class="legend-item">
<span class="legend-dot legend-tokens"></span>
<span class="legend-text">Tokens</span>
</div>
<div v-if="authStore.publicStats.showOptions?.apiKeysTrends" class="legend-item">
<span class="legend-dot legend-keys"></span>
<span class="legend-text">活跃 Keys</span>
</div>
<div v-if="authStore.publicStats.showOptions?.accountTrends" class="legend-item">
<span class="legend-dot legend-accounts"></span>
<span class="legend-text">活跃账号</span>
</div>
</div>
</div>
<!-- 暂无趋势数据 -->
<div v-else-if="hasTrendOptionsEnabled" class="empty-state">
<i class="fas fa-chart-line empty-icon"></i>
<p class="empty-text">暂无趋势数据</p>
<p class="empty-hint">数据将在有请求后自动更新</p>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="authStore.publicStatsLoading" class="public-stats-loading">
<div class="loading-spinner"></div>
</div>
<!-- 无数据状态 -->
<div v-else class="public-stats-empty">
<i class="fas fa-chart-pie empty-icon"></i>
<p class="empty-text">暂无统计数据</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Line, Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
)
const authStore = useAuthStore()
// 检查是否有任何趋势选项启用
const hasTrendOptionsEnabled = computed(() => {
const opts = authStore.publicStats?.showOptions
return opts?.tokenTrends || opts?.apiKeysTrends || opts?.accountTrends
})
// 检查是否有实际趋势数据
const hasAnyTrendData = computed(() => {
const stats = authStore.publicStats
if (!stats) return false
const opts = stats.showOptions || {}
const hasTokens = opts.tokenTrends && stats.tokenTrends?.length > 0
const hasKeys = opts.apiKeysTrends && stats.apiKeysTrends?.length > 0
const hasAccounts = opts.accountTrends && stats.accountTrends?.length > 0
return hasTokens || hasKeys || hasAccounts
})
// 模型分布颜色
const modelColors = [
'rgb(99, 102, 241)', // indigo
'rgb(59, 130, 246)', // blue
'rgb(16, 185, 129)', // emerald
'rgb(245, 158, 11)', // amber
'rgb(239, 68, 68)', // red
'rgb(139, 92, 246)', // violet
'rgb(236, 72, 153)', // pink
'rgb(20, 184, 166)' // teal
]
// 模型分布环形图数据
const modelChartData = computed(() => {
const stats = authStore.publicStats
if (!stats?.modelDistribution?.length) {
return { labels: [], datasets: [] }
}
const models = stats.modelDistribution
return {
labels: models.map((m) => formatModelName(m.model)),
datasets: [
{
data: models.map((m) => m.percentage),
backgroundColor: models.map((_, i) => modelColors[i % modelColors.length]),
borderColor: 'transparent',
borderWidth: 0,
hoverOffset: 4
}
]
}
})
// 模型分布环形图选项
const modelChartOptions = computed(() => {
const isDark = document.documentElement.classList.contains('dark')
const textColor = isDark ? 'rgb(156, 163, 175)' : 'rgb(107, 114, 128)'
return {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
color: textColor,
padding: 12,
usePointStyle: true,
pointStyle: 'circle',
font: {
size: 11
},
generateLabels: (chart) => {
const data = chart.data
if (data.labels.length && data.datasets.length) {
return data.labels.map((label, i) => ({
text: `${label} ${data.datasets[0].data[i]}%`,
fillStyle: data.datasets[0].backgroundColor[i],
strokeStyle: 'transparent',
lineWidth: 0,
pointStyle: 'circle',
hidden: false,
index: i
}))
}
return []
}
}
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark ? 'rgb(243, 244, 246)' : 'rgb(17, 24, 39)',
bodyColor: isDark ? 'rgb(209, 213, 219)' : 'rgb(75, 85, 99)',
borderColor: isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(209, 213, 219, 0.5)',
borderWidth: 1,
padding: 10,
cornerRadius: 8,
callbacks: {
label: (context) => {
return ` ${context.label}: ${context.parsed}%`
}
}
}
}
}
})
// 趋势图表数据
const chartData = computed(() => {
const stats = authStore.publicStats
if (!stats) return { labels: [], datasets: [] }
const opts = stats.showOptions || {}
// 获取日期标签(优先使用 tokenTrends
const labels =
stats.tokenTrends?.map((t) => formatDateShort(t.date)) ||
stats.apiKeysTrends?.map((t) => formatDateShort(t.date)) ||
stats.accountTrends?.map((t) => formatDateShort(t.date)) ||
[]
const datasets = []
// Token 趋势左Y轴
if (opts.tokenTrends && stats.tokenTrends?.length > 0) {
datasets.push({
label: 'Tokens',
data: stats.tokenTrends.map((t) => t.tokens),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y',
tension: 0.3,
fill: true,
pointRadius: 3,
pointHoverRadius: 5
})
}
// API Keys 趋势右Y轴
if (opts.apiKeysTrends && stats.apiKeysTrends?.length > 0) {
datasets.push({
label: '活跃 Keys',
data: stats.apiKeysTrends.map((t) => t.activeKeys),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
// 账号趋势右Y轴
if (opts.accountTrends && stats.accountTrends?.length > 0) {
datasets.push({
label: '活跃账号',
data: stats.accountTrends.map((t) => t.activeAccounts),
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
return { labels, datasets }
})
// 图表配置
const chartOptions = computed(() => {
const isDark = document.documentElement.classList.contains('dark')
const textColor = isDark ? 'rgba(156, 163, 175, 1)' : 'rgba(107, 114, 128, 1)'
const gridColor = isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)'
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: isDark ? '#e5e7eb' : '#1f2937',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? 'rgba(75, 85, 99, 0.5)' : 'rgba(229, 231, 235, 1)',
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: function (context) {
let label = context.dataset.label || ''
if (label) {
label += ': '
}
if (context.dataset.yAxisID === 'y') {
label += formatTokens(context.parsed.y)
} else {
label += context.parsed.y
}
return label
}
}
}
},
scales: {
x: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
}
}
},
y: {
type: 'linear',
display: true,
position: 'left',
min: 0,
beginAtZero: true,
title: {
display: true,
text: 'Tokens',
color: 'rgb(59, 130, 246)',
font: {
size: 10
}
},
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
},
callback: function (value) {
return formatTokensShort(value)
}
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
min: 0,
beginAtZero: true,
title: {
display: true,
text: '数量',
color: 'rgb(34, 197, 94)',
font: {
size: 10
}
},
grid: {
drawOnChartArea: false
},
ticks: {
color: textColor,
font: {
size: 10
},
stepSize: 1
}
}
}
}
})
// 格式化运行时间
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
// 格式化 tokens
function formatTokens(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(2) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(2) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(1) + 'K'
}
return tokens.toString()
}
// 格式化 tokens简短版用于Y轴
function formatTokensShort(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(0) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(0) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(0) + 'K'
}
return tokens.toString()
}
// 格式化时间范围标签
function formatPeriodLabel(period) {
const labels = {
today: '今天',
'24h': '过去24小时',
'7d': '过去7天',
'30d': '过去30天',
all: '全部'
}
return labels[period] || labels['today']
}
// 获取平台图标
function getPlatformIcon(platform) {
const icons = {
claude: 'fas fa-robot',
gemini: 'fas fa-gem',
bedrock: 'fab fa-aws',
droid: 'fas fa-microchip'
}
return icons[platform] || 'fas fa-server'
}
// 获取平台名称
function getPlatformName(platform) {
const names = {
claude: 'Claude',
gemini: 'Gemini',
bedrock: 'Bedrock',
droid: 'Droid'
}
return names[platform] || platform
}
// 格式化模型名称
function formatModelName(model) {
if (!model) return 'Unknown'
// 简化长模型名称
const parts = model.split('-')
if (parts.length > 2) {
return parts.slice(0, 2).join('-')
}
return model
}
// 格式化日期(短格式)
function formatDateShort(dateStr) {
if (!dateStr) return ''
const parts = dateStr.split('-')
if (parts.length === 3) {
return `${parts[1]}/${parts[2]}`
}
return dateStr
}
</script>
<style scoped>
.public-stats-overview {
@apply rounded-xl border border-gray-200/50 bg-white/80 p-4 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80 md:p-6;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 顶部状态栏 */
.header-section {
@apply mb-4 flex flex-col items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:flex-row md:pb-6;
}
/* 主内容区 */
.main-content {
@apply grid gap-4 md:grid-cols-2 md:gap-6;
}
/* 统计区块 */
.stats-section {
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
}
/* 模型区块 */
.model-section {
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
}
/* 图表区块 */
.chart-section {
@apply mt-4 rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30 md:mt-6;
}
/* 章节标题(居中) */
.section-title {
@apply mb-2 text-center text-xs text-gray-600 dark:text-gray-400;
}
/* 章节标题(左对齐) */
.section-title-left {
@apply mb-3 text-sm font-medium text-gray-700 dark:text-gray-300;
}
/* 时间范围标签 */
.period-label {
@apply ml-1 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-normal text-gray-500 dark:bg-gray-600 dark:text-gray-400;
}
/* 状态徽章 */
.status-badge {
@apply inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium;
}
.status-healthy {
@apply bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400;
}
.status-degraded {
@apply bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.status-dot {
@apply inline-block h-2 w-2 rounded-full;
}
.status-healthy .status-dot {
@apply bg-green-500;
animation: pulse 2s infinite;
}
.status-degraded .status-dot {
@apply bg-yellow-500;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 平台徽章 */
.platform-badge {
@apply inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium transition-all;
}
.platform-badge.available {
@apply bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400;
}
.platform-badge.unavailable {
@apply bg-gray-100 text-gray-400 line-through dark:bg-gray-800 dark:text-gray-600;
}
/* 统计网格 */
.stats-grid {
@apply grid grid-cols-2 gap-3;
}
.stat-item {
@apply rounded-lg bg-white p-3 text-center shadow-sm dark:bg-gray-800/50;
}
.stat-value {
@apply text-lg font-bold text-gray-900 dark:text-gray-100 md:text-xl;
}
.stat-label {
@apply text-xs text-gray-500 dark:text-gray-400;
}
/* 模型分布环形图容器 */
.model-chart-container {
height: 160px;
}
@media (min-width: 768px) {
.model-chart-container {
height: 180px;
}
}
/* 趋势图表容器 */
.chart-container {
@apply rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50;
height: 180px;
}
/* 图例 */
.chart-legend {
@apply mt-2 flex flex-wrap items-center justify-center gap-4;
}
.legend-item {
@apply flex items-center gap-1.5;
}
.legend-dot {
@apply inline-block h-2.5 w-2.5 rounded-full;
}
.legend-tokens {
@apply bg-blue-500;
}
.legend-keys {
@apply bg-green-500;
}
.legend-accounts {
@apply bg-purple-500;
}
.legend-text {
@apply text-xs text-gray-600 dark:text-gray-400;
}
/* 空状态 */
.empty-state {
@apply flex flex-col items-center justify-center rounded-lg bg-gray-50 py-6 dark:bg-gray-700/50;
}
.empty-icon {
@apply mb-2 text-2xl text-gray-400 dark:text-gray-500;
}
.empty-text {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.empty-hint {
@apply mt-1 text-xs text-gray-400 dark:text-gray-500;
}
/* 加载状态 */
.public-stats-loading {
@apply flex items-center justify-center py-8;
}
.public-stats-empty {
@apply flex flex-col items-center justify-center rounded-xl border border-gray-200/50 bg-white/80 py-8 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80;
}
.loading-spinner {
@apply h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent;
}
</style>

View File

@@ -14,10 +14,15 @@ export const useAuthStore = defineStore('auth', () => {
siteName: 'Claude Relay Service', siteName: 'Claude Relay Service',
siteIcon: '', siteIcon: '',
siteIconData: '', siteIconData: '',
faviconData: '' faviconData: '',
publicStatsEnabled: false
}) })
const oemLoading = ref(true) const oemLoading = ref(true)
// 公开统计数据
const publicStats = ref(null)
const publicStatsLoading = ref(false)
// 计算属性 // 计算属性
const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value) const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value)
const token = computed(() => authToken.value) const token = computed(() => authToken.value)
@@ -104,6 +109,11 @@ export const useAuthStore = defineStore('auth', () => {
if (result.data.siteName) { if (result.data.siteName) {
document.title = `${result.data.siteName} - 管理后台` document.title = `${result.data.siteName} - 管理后台`
} }
// 如果公开统计已启用,加载统计数据
if (result.data.publicStatsEnabled) {
loadPublicStats()
}
} }
} catch (error) { } catch (error) {
console.error('加载OEM设置失败:', error) console.error('加载OEM设置失败:', error)
@@ -112,6 +122,23 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
async function loadPublicStats() {
publicStatsLoading.value = true
try {
const result = await apiClient.get('/admin/public-stats')
if (result.success && result.enabled && result.data) {
publicStats.value = result.data
} else {
publicStats.value = null
}
} catch (error) {
console.error('加载公开统计失败:', error)
publicStats.value = null
} finally {
publicStatsLoading.value = false
}
}
return { return {
// 状态 // 状态
isLoggedIn, isLoggedIn,
@@ -121,6 +148,8 @@ export const useAuthStore = defineStore('auth', () => {
loginLoading, loginLoading,
oemSettings, oemSettings,
oemLoading, oemLoading,
publicStats,
publicStatsLoading,
// 计算属性 // 计算属性
isAuthenticated, isAuthenticated,
@@ -131,6 +160,7 @@ export const useAuthStore = defineStore('auth', () => {
login, login,
logout, logout,
checkAuth, checkAuth,
loadOemSettings loadOemSettings,
loadPublicStats
} }
}) })

View File

@@ -9,6 +9,12 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '', siteIcon: '',
siteIconData: '', siteIconData: '',
showAdminButton: true, // 控制管理后台按钮的显示 showAdminButton: true, // 控制管理后台按钮的显示
publicStatsEnabled: false, // 是否在首页显示公开统计概览
publicStatsShowModelDistribution: true,
publicStatsModelDistributionPeriod: 'today', // 时间范围: today, 24h, 7d, 30d, all
publicStatsShowTokenTrends: false,
publicStatsShowApiKeysTrends: false,
publicStatsShowAccountTrends: false,
updatedAt: null updatedAt: null
}) })
@@ -66,6 +72,7 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '', siteIcon: '',
siteIconData: '', siteIconData: '',
showAdminButton: true, showAdminButton: true,
publicStatsEnabled: false,
updatedAt: null updatedAt: null
} }

View File

@@ -141,6 +141,28 @@
</el-tooltip> </el-tooltip>
</div> </div>
<!-- 刷新余额按钮 -->
<div class="relative">
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
@click="refreshVisibleBalances"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-blue-500',
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
]"
/>
<span class="relative">刷新余额</span>
</button>
</el-tooltip>
</div>
<!-- 选择/取消选择按钮 --> <!-- 选择/取消选择按钮 -->
<button <button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@@ -263,6 +285,11 @@
> >
今日使用 今日使用
</th> </th>
<th
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
余额/配额
</th>
<th <th
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
@@ -765,6 +792,23 @@
</div> </div>
<div v-else class="text-xs text-gray-400">暂无数据</div> <div v-else class="text-xs text-gray-400">暂无数据</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4">
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4"> <td class="whitespace-nowrap px-3 py-4">
<div v-if="account.platform === 'claude'" class="space-y-2"> <div v-if="account.platform === 'claude'" class="space-y-2">
<!-- OAuth 账户:显示三窗口 OAuth usage --> <!-- OAuth 账户:显示三窗口 OAuth usage -->
@@ -1238,6 +1282,15 @@
<i class="fas fa-vial" /> <i class="fas fa-vial" />
<span class="ml-1">测试</span> <span class="ml-1">测试</span>
</button> </button>
<button
v-if="canTestAccount(account)"
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
title="定时测试配置"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
<span class="ml-1">定时</span>
</button>
<button <button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200" class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
title="编辑账户" title="编辑账户"
@@ -1416,6 +1469,26 @@
</div> </div>
</div> </div>
<!-- 余额/配额 -->
<div class="mb-3">
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</div>
<!-- 状态信息 --> <!-- 状态信息 -->
<div class="mb-3 space-y-2"> <div class="mb-3 space-y-2">
<!-- 会话窗口 --> <!-- 会话窗口 -->
@@ -1707,6 +1780,15 @@
测试 测试
</button> </button>
<button
v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
定时
</button>
<button <button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100" class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
@click="editAccount(account)" @click="editAccount(account)"
@@ -1880,6 +1962,21 @@
@close="closeAccountTestModal" @close="closeAccountTestModal"
/> />
<!-- 定时测试配置弹窗 -->
<AccountScheduledTestModal
:account="scheduledTestAccount"
:show="showScheduledTestModal"
@close="closeScheduledTestModal"
@saved="handleScheduledTestSaved"
/>
<AccountBalanceScriptModal
:account="selectedAccountForScript"
:show="showBalanceScriptModal"
@close="closeBalanceScriptModal"
@saved="handleBalanceScriptSaved"
/>
<!-- 账户统计弹窗 --> <!-- 账户统计弹窗 -->
<el-dialog <el-dialog
v-model="showAccountStatsModal" v-model="showAccountStatsModal"
@@ -2032,9 +2129,12 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue' import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue' import ActionDropdown from '@/components/common/ActionDropdown.vue'
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
// 使用确认弹窗 // 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm() const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
@@ -2042,6 +2142,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
// 数据状态 // 数据状态
const accounts = ref([]) const accounts = ref([])
const accountsLoading = ref(false) const accountsLoading = ref(false)
const refreshingBalances = ref(false)
const accountsSortBy = ref('name') const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc') const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息) const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -2099,6 +2200,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false) const showAccountTestModal = ref(false)
const testingAccount = ref(null) const testingAccount = ref(null)
// 定时测试配置弹窗状态
const showScheduledTestModal = ref(false)
const scheduledTestAccount = ref(null)
// 账户统计弹窗状态 // 账户统计弹窗状态
const showAccountStatsModal = ref(false) const showAccountStatsModal = ref(false)
@@ -2365,6 +2470,13 @@ const getAccountActions = (account) => {
color: 'blue', color: 'blue',
handler: () => openAccountTestModal(account) handler: () => openAccountTestModal(account)
}) })
actions.push({
key: 'scheduled-test',
label: '定时测试',
icon: 'fa-clock',
color: 'amber',
handler: () => openScheduledTestModal(account)
})
} }
// 删除 // 删除
@@ -2441,6 +2553,61 @@ const closeAccountTestModal = () => {
testingAccount.value = null testingAccount.value = null
} }
// 定时测试配置相关函数
const openScheduledTestModal = (account) => {
if (!canTestAccount(account)) {
showToast('该账户类型暂不支持定时测试', 'warning')
return
}
scheduledTestAccount.value = account
showScheduledTestModal.value = true
}
const closeScheduledTestModal = () => {
showScheduledTestModal.value = false
scheduledTestAccount.value = null
}
const handleScheduledTestSaved = () => {
showToast('定时测试配置已保存', 'success')
}
// 余额脚本配置
const showBalanceScriptModal = ref(false)
const selectedAccountForScript = ref(null)
const openBalanceScriptModal = (account) => {
selectedAccountForScript.value = account
showBalanceScriptModal.value = true
}
const closeBalanceScriptModal = () => {
showBalanceScriptModal.value = false
selectedAccountForScript.value = null
}
const handleBalanceScriptSaved = async () => {
showToast('余额脚本已保存', 'success')
const account = selectedAccountForScript.value
closeBalanceScriptModal()
if (!account?.id || !account?.platform) {
return
}
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
try {
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
params: { platform: account.platform, queryApi: false }
})
if (res?.success && res.data) {
handleBalanceRefreshed(account.id, res.data)
}
} catch (error) {
console.debug('Failed to reload balance after saving script:', error)
}
}
// 计算排序后的账户列表 // 计算排序后的账户列表
const sortedAccounts = computed(() => { const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value let sourceAccounts = accounts.value
@@ -2711,6 +2878,104 @@ const paginatedAccounts = computed(() => {
return sortedAccounts.value.slice(start, end) return sortedAccounts.value.slice(start, end)
}) })
const canRefreshVisibleBalances = computed(() => {
const targets = paginatedAccounts.value
if (!Array.isArray(targets) || targets.length === 0) {
return false
}
return targets.some((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
})
const refreshBalanceTooltip = computed(() => {
if (accountsLoading.value) return '正在加载账户...'
if (refreshingBalances.value) return '刷新中...'
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
})
// 余额刷新成功回调
const handleBalanceRefreshed = (accountId, balanceInfo) => {
accounts.value = accounts.value.map((account) => {
if (account.id !== accountId) return account
return { ...account, balanceInfo }
})
}
// 余额请求错误回调(仅提示,不中断页面)
const handleBalanceError = (_accountId, error) => {
const message = error?.message || '余额查询失败'
showToast(message, 'error')
}
// 批量刷新当前页余额(触发查询)
const refreshVisibleBalances = async () => {
if (refreshingBalances.value) return
const targets = paginatedAccounts.value
if (!targets || targets.length === 0) {
return
}
const eligibleTargets = targets.filter((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
if (eligibleTargets.length === 0) {
showToast('当前页没有配置余额脚本的账户', 'warning')
return
}
const skippedCount = targets.length - eligibleTargets.length
refreshingBalances.value = true
try {
const results = await Promise.all(
eligibleTargets.map(async (account) => {
try {
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
platform: account.platform
})
return { id: account.id, success: !!response?.success, data: response?.data || null }
} catch (error) {
return { id: account.id, success: false, error: error?.message || '刷新失败' }
}
})
)
const updatedMap = results.reduce((map, item) => {
if (item.success && item.data) {
map[item.id] = item.data
}
return map
}, {})
const successCount = results.filter((r) => r.success).length
const failCount = results.length - successCount
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
if (Object.keys(updatedMap).length > 0) {
accounts.value = accounts.value.map((account) => {
const balanceInfo = updatedMap[account.id]
if (!balanceInfo) return account
return { ...account, balanceInfo }
})
}
if (failCount === 0) {
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
} else {
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
}
} finally {
refreshingBalances.value = false
}
}
const updateSelectAllState = () => { const updateSelectAllState = () => {
const currentIds = paginatedAccounts.value.map((account) => account.id) const currentIds = paginatedAccounts.value.map((account) => account.id)
const selectedInCurrentPage = currentIds.filter((id) => const selectedInCurrentPage = currentIds.filter((id) =>
@@ -2761,6 +3026,54 @@ const cleanupSelectedAccounts = () => {
updateSelectAllState() updateSelectAllState()
} }
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
const loadBalanceCacheForAccounts = async () => {
const current = accounts.value
if (!Array.isArray(current) || current.length === 0) {
return
}
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
if (platforms.length === 0) {
return
}
const responses = await Promise.all(
platforms.map(async (platform) => {
try {
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
params: { queryApi: false }
})
return { platform, success: !!res?.success, data: res?.data || [] }
} catch (error) {
console.debug(`Failed to load balance cache for ${platform}:`, error)
return { platform, success: false, data: [] }
}
})
)
const balanceMap = responses.reduce((map, item) => {
if (!item.success) return map
const list = Array.isArray(item.data) ? item.data : []
list.forEach((entry) => {
const accountId = entry?.data?.accountId
if (accountId) {
map[accountId] = entry.data
}
})
return map
}, {})
if (Object.keys(balanceMap).length === 0) {
return
}
accounts.value = accounts.value.map((account) => ({
...account,
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
}))
}
// 加载账户列表 // 加载账户列表
const loadAccounts = async (forceReload = false) => { const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true accountsLoading.value = true
@@ -2953,6 +3266,11 @@ const loadAccounts = async (forceReload = false) => {
console.debug('Claude usage loading failed:', err) console.debug('Claude usage loading failed:', err)
}) })
} }
// 异步加载余额缓存(按平台批量)
loadBalanceCacheForAccounts().catch((err) => {
console.debug('Balance cache loading failed:', err)
})
} catch (error) { } catch (error) {
showToast('加载账户失败', 'error') showToast('加载账户失败', 'error')
} finally { } finally {

View File

@@ -6,7 +6,13 @@
<LogoTitle <LogoTitle
:loading="oemLoading" :loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon" :logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'" :subtitle="
currentTab === 'stats'
? 'API Key 使用统计'
: currentTab === 'overview'
? '服务状态概览'
: '使用教程'
"
:title="oemSettings.siteName" :title="oemSettings.siteName"
/> />
<div class="flex items-center gap-2 md:gap-4"> <div class="flex items-center gap-2 md:gap-4">
@@ -49,6 +55,13 @@
<div <div
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto" class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
> >
<button
:class="['tab-pill-button', currentTab === 'overview' ? 'active' : '']"
@click="switchToOverview"
>
<i class="fas fa-tachometer-alt mr-1 md:mr-2" />
<span class="text-sm md:text-base">状态概览</span>
</button>
<button <button
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']" :class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
@click="currentTab = 'stats'" @click="currentTab = 'stats'"
@@ -67,6 +80,11 @@
</div> </div>
</div> </div>
<!-- 状态概览内容 -->
<div v-if="currentTab === 'overview'" class="tab-content">
<PublicStatsOverview />
</div>
<!-- 统计内容 --> <!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content"> <div v-if="currentTab === 'stats'" class="tab-content">
<!-- API Key 输入区域 --> <!-- API Key 输入区域 -->
@@ -174,6 +192,7 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useAuthStore } from '@/stores/auth'
import LogoTitle from '@/components/common/LogoTitle.vue' import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue' import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
@@ -184,13 +203,15 @@ import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialView from './TutorialView.vue' import TutorialView from './TutorialView.vue'
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue' import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
import PublicStatsOverview from '@/components/common/PublicStatsOverview.vue'
const route = useRoute() const route = useRoute()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const authStore = useAuthStore()
// 当前标签页 // 当前标签页 - 默认显示状态概览
const currentTab = ref('stats') const currentTab = ref('overview')
// 主题相关 // 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode) const isDarkMode = computed(() => themeStore.isDarkMode)
@@ -223,6 +244,12 @@ const closeTestModal = () => {
showTestModal.value = false showTestModal.value = false
} }
// 切换到状态概览并加载数据
const switchToOverview = () => {
currentTab.value = 'overview'
authStore.loadPublicStats()
}
// 处理键盘快捷键 // 处理键盘快捷键
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
// Ctrl/Cmd + Enter 查询 // Ctrl/Cmd + Enter 查询
@@ -249,6 +276,9 @@ onMounted(() => {
// 加载 OEM 设置 // 加载 OEM 设置
loadOemSettings() loadOemSettings()
// 默认加载公开统计数据
authStore.loadPublicStats()
// 检查 URL 参数 // 检查 URL 参数
const urlApiId = route.query.apiId const urlApiId = route.query.apiId
const urlApiKey = route.query.apiKey const urlApiKey = route.query.apiKey

View File

@@ -0,0 +1,312 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
使用自定义脚本 + 模板变量适配任意余额接口
</div>
</div>
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="loadConfig"
>
重新加载
</button>
<button
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
:disabled="saving"
@click="saveConfig"
>
<span v-if="saving">保存中...</span>
<span v-else>保存配置</span>
</button>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
请求地址baseUrl
</label>
<input
v-model="form.baseUrl"
class="input-text"
placeholder="https://api.example.com"
type="text"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>Token可选</label
>
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>超时时间()</label
>
<input
v-model.number="form.timeoutSeconds"
class="input-text"
min="1"
type="number"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
自动查询间隔(分钟)
</label>
<input
v-model.number="form.autoIntervalMinutes"
class="input-text"
min="0"
type="number"
/>
</div>
</div>
<div class="md:col-span-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{
'}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{
'}'
}}{{ '}' }}
</p>
</div>
</div>
</div>
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
填入账号上下文可选调试 extractor 输出
</div>
</div>
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
:disabled="testing"
@click="testScript"
>
<span v-if="testing">测试中...</span>
<span v-else>测试脚本</span>
</button>
</div>
<div class="grid gap-3">
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra)</label
>
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
</div>
</div>
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
<span
:class="[
'rounded px-2 py-0.5 text-xs',
testResult.mapped?.status === 'success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
]"
>
{{ testResult.mapped?.status || 'unknown' }}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
错误: {{ testResult.mapped.errorMessage }}
</div>
<div v-if="testResult.mapped?.quota">
配额: {{ JSON.stringify(testResult.mapped.quota) }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.extracted) }}
</pre
>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.response) }}
</pre
>
</details>
</div>
</div>
</div>
<div class="glass-strong rounded-2xl p-4 shadow-lg">
<div class="mb-2 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
返回对象需包含 requestextractor支持模板变量替换
</div>
</div>
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="applyPreset"
>
使用示例模板
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
spellcheck="false"
></textarea>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
extractor
返回字段可选isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const testForm = reactive({
platform: '',
accountId: '',
extra: ''
})
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD",
planName: response.plan || "默认套餐"
};
}
})`
const loadConfig = async () => {
try {
const res = await apiClient.get('/admin/balance-scripts/default')
if (res?.success && res.data) {
Object.assign(form, res.data)
}
} catch (error) {
showToast('加载配置失败', 'error')
}
}
const saveConfig = async () => {
saving.value = true
try {
const payload = { ...form }
await apiClient.put('/admin/balance-scripts/default', payload)
showToast('配置已保存', 'success')
} catch (error) {
showToast(error.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
const testScript = async () => {
testing.value = true
testResult.value = null
try {
const payload = {
...form,
...testForm,
scriptBody: form.scriptBody
}
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
if (res?.success) {
testResult.value = res.data
showToast('测试完成', 'success')
} else {
showToast(res?.error || '测试失败', 'error')
}
} catch (error) {
showToast(error.message || '测试失败', 'error')
} finally {
testing.value = false
}
}
const applyPreset = () => {
form.scriptBody = presetScript
}
const displayAmount = (val) => {
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
return Number(val).toFixed(2)
}
const formatJson = (data) => {
try {
return JSON.stringify(data, null, 2)
} catch (error) {
return String(data)
}
}
onMounted(() => {
applyPreset()
loadConfig()
})
</script>
<style scoped>
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}
</style>

View File

@@ -196,6 +196,105 @@
</div> </div>
</div> </div>
<!-- 账户余额/配额汇总 -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
账户余额/配额
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
<i class="fas fa-wallet" />
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
</p>
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
:disabled="loadingBalanceSummary"
@click="loadBalanceSummary"
>
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
刷新
</button>
</div>
</div>
<div class="card p-4 sm:p-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ lowBalanceAccounts.length }} 个
</span>
</div>
<div
v-if="loadingBalanceSummary"
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
正在加载...
</div>
<div
v-else-if="lowBalanceAccounts.length === 0"
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
>
全部正常
</div>
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="account in lowBalanceAccounts"
:key="account.accountId"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
>
<div class="flex items-center justify-between gap-2">
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account.name || account.accountId }}
</div>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{{ getBalancePlatformLabel(account.platform) }}
</span>
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
<span v-else
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
>
</div>
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
<div
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
>
<span>配额使用</span>
<span class="text-red-600 dark:text-red-400">
{{ account.quota.percentage.toFixed(1) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-red-500"
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token统计和性能指标 --> <!-- Token统计和性能指标 -->
<div <div
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4" class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
@@ -732,6 +833,97 @@ const accountGroupOptions = [
const accountTrendUpdating = ref(false) const accountTrendUpdating = ref(false)
// 余额/配额汇总
const balanceSummary = ref({
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
})
const loadingBalanceSummary = ref(false)
const balanceSummaryUpdatedAt = ref(null)
const getBalancePlatformLabel = (platform) => {
const map = {
claude: 'Claude',
'claude-console': 'Claude Console',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
azure_openai: 'Azure OpenAI',
bedrock: 'Bedrock',
droid: 'Droid',
ccr: 'CCR'
}
return map[platform] || platform
}
const lowBalanceAccounts = computed(() => {
const result = []
const platforms = balanceSummary.value?.platforms || {}
Object.entries(platforms).forEach(([platform, data]) => {
const list = Array.isArray(data?.accounts) ? data.accounts : []
list.forEach((entry) => {
const accountData = entry?.data
if (!accountData) return
const amount = accountData.balance?.amount
const percentage = accountData.quota?.percentage
const isLowBalance = typeof amount === 'number' && amount < 10
const isHighUsage = typeof percentage === 'number' && percentage > 90
if (isLowBalance || isHighUsage) {
result.push({
...accountData,
name: entry?.name || accountData.accountId,
platform: accountData.platform || platform
})
}
})
})
return result
})
const formatCurrencyUsd = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatLastUpdate = (isoString) => {
if (!isoString) return '未知'
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const loadBalanceSummary = async () => {
loadingBalanceSummary.value = true
try {
const response = await apiClient.get('/admin/accounts/balance/summary')
if (response?.success) {
balanceSummary.value = response.data || {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
balanceSummaryUpdatedAt.value = new Date().toISOString()
}
} catch (error) {
console.debug('加载余额汇总失败:', error)
showToast('加载余额汇总失败', 'error')
} finally {
loadingBalanceSummary.value = false
}
}
// 自动刷新相关 // 自动刷新相关
const autoRefreshEnabled = ref(false) const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒 const autoRefreshInterval = ref(30) // 秒
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
isRefreshing.value = true isRefreshing.value = true
try { try {
await Promise.all([loadDashboardData(), refreshChartsData()]) await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
} finally { } finally {
isRefreshing.value = false isRefreshing.value = false
} }

View File

@@ -5,6 +5,7 @@
<ThemeToggle mode="dropdown" /> <ThemeToggle mode="dropdown" />
</div> </div>
<!-- 登录卡片 -->
<div <div
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10" class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
> >

View File

@@ -48,6 +48,18 @@
<i class="fas fa-robot mr-2"></i> <i class="fas fa-robot mr-2"></i>
Claude 转发 Claude 转发
</button> </button>
<button
:class="[
'border-b-2 pb-2 text-sm font-medium transition-colors',
activeSection === 'publicStats'
? '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 = 'publicStats'"
>
<i class="fas fa-chart-line mr-2"></i>
公开统计
</button>
</nav> </nav>
</div> </div>
@@ -1025,6 +1037,158 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 公开统计设置部分 -->
<div v-show="activeSection === 'publicStats'">
<div class="rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-md"
>
<i class="fas fa-chart-line"></i>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
公开统计概览
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
配置未登录用户可见的统计数据
</p>
</div>
</div>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="oemSettings.publicStatsEnabled"
class="peer sr-only"
type="checkbox"
/>
<div
class="peer relative 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-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-green-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
oemSettings.publicStatsEnabled ? '已启用' : '已禁用'
}}</span>
</label>
</div>
<!-- 数据显示选项 -->
<div
v-if="oemSettings.publicStatsEnabled"
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-eye mr-2 text-gray-400"></i>
选择要公开显示的数据
</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="rounded-lg border border-gray-200 bg-white p-3 transition-colors dark:border-gray-600 dark:bg-gray-800"
>
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="oemSettings.publicStatsShowModelDistribution"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>模型使用分布</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示各模型的使用占比</p>
</div>
</label>
<div v-if="oemSettings.publicStatsShowModelDistribution" class="mt-3 pl-7">
<div class="mb-1.5 text-xs text-gray-500 dark:text-gray-400">时间范围</div>
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700/50">
<button
v-for="option in modelDistributionPeriodOptions"
:key="option.value"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
:class="
oemSettings.publicStatsModelDistributionPeriod === option.value
? 'bg-white text-green-600 shadow-sm dark:bg-gray-600 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
"
type="button"
@click="oemSettings.publicStatsModelDistributionPeriod = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowTokenTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Token 使用趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的Token使用量</p>
</div>
</label>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowApiKeysTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>API Keys 活跃趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">
显示近7天的活跃API Key数量
</p>
</div>
</label>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowAccountTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>账号活跃趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的活跃账号数量</p>
</div>
</label>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex items-center justify-between">
<div class="flex gap-3">
<button
class="btn btn-primary px-6 py-3"
:class="{ 'cursor-not-allowed opacity-50': saving }"
:disabled="saving"
@click="saveOemSettings"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2" />
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-clock mr-1" />
最后更新{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1622,6 +1786,15 @@ defineOptions({
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const { loading, saving, oemSettings } = storeToRefs(settingsStore) const { loading, saving, oemSettings } = storeToRefs(settingsStore)
// 模型使用分布时间范围选项
const modelDistributionPeriodOptions = [
{ value: 'today', label: '今天' },
{ value: '24h', label: '24小时' },
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: 'all', label: '全部' }
]
// 组件refs // 组件refs
const iconFileInput = ref() const iconFileInput = ref()
@@ -2467,7 +2640,14 @@ const saveOemSettings = async () => {
siteName: oemSettings.value.siteName, siteName: oemSettings.value.siteName,
siteIcon: oemSettings.value.siteIcon, siteIcon: oemSettings.value.siteIcon,
siteIconData: oemSettings.value.siteIconData, siteIconData: oemSettings.value.siteIconData,
showAdminButton: oemSettings.value.showAdminButton showAdminButton: oemSettings.value.showAdminButton,
publicStatsEnabled: oemSettings.value.publicStatsEnabled,
publicStatsShowModelDistribution: oemSettings.value.publicStatsShowModelDistribution,
publicStatsModelDistributionPeriod:
oemSettings.value.publicStatsModelDistributionPeriod || 'today',
publicStatsShowTokenTrends: oemSettings.value.publicStatsShowTokenTrends,
publicStatsShowApiKeysTrends: oemSettings.value.publicStatsShowApiKeysTrends,
publicStatsShowAccountTrends: oemSettings.value.publicStatsShowAccountTrends
} }
const result = await settingsStore.saveOemSettings(settings) const result = await settingsStore.saveOemSettings(settings)
if (result && result.success) { if (result && result.success) {