mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2180c42b84 | ||
|
|
0883bb6b39 | ||
|
|
ea6d1f1b36 | ||
|
|
4367fa47da | ||
|
|
55c876fad5 | ||
|
|
f9df276d0c | ||
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
83cbaf7c3e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
24f825f60d | ||
|
|
ac7d28f9ce | ||
|
|
1027a2e3e2 | ||
|
|
cb935ea0f0 | ||
|
|
73a241df1a | ||
|
|
029bdf3719 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
76ecbe18a5 | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
b9c088ce58 | ||
|
|
2ff74c21d2 | ||
|
|
8a4dadbbc0 | ||
|
|
adf2890f65 | ||
|
|
7d892a69f1 | ||
|
|
a749ddfede | ||
|
|
dbd4fb19cf | ||
|
|
39ba345a43 | ||
|
|
2693fd77b7 | ||
|
|
3cc3219a90 | ||
|
|
1b834ffcdb | ||
|
|
12fd5e1cb4 | ||
|
|
f5e982632d | ||
|
|
90023d1551 | ||
|
|
74e71d0afc | ||
|
|
41999f56b4 | ||
|
|
b81c2b946f | ||
|
|
0a59a0f9d4 | ||
|
|
d8a33f9aa7 | ||
|
|
666b0120b7 | ||
|
|
fba18000e5 | ||
|
|
b4233033a6 | ||
|
|
584fa8c9c1 | ||
|
|
c4448db6ab | ||
|
|
c67d2bce9d | ||
|
|
a345812cd7 | ||
|
|
a0cbafd759 | ||
|
|
3c64038fa7 | ||
|
|
45b81bd478 | ||
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
3f98267738 | ||
|
|
e187b8946a | ||
|
|
8917019a78 | ||
|
|
9960f237b8 | ||
|
|
b6da77cabe | ||
|
|
e561387e81 | ||
|
|
982cca1020 | ||
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
18a493e805 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
534fbf6ac2 | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
b6f3459522 | ||
|
|
e56d797d87 | ||
|
|
4c6879a9c2 | ||
|
|
1c8084a3b1 | ||
|
|
f6f4b5cfec | ||
|
|
26ca696b91 | ||
|
|
ce496ed9e6 | ||
|
|
f6ed420401 | ||
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
33ea26f2ac | ||
|
|
ba93ae55a9 | ||
|
|
53cda0fd18 | ||
|
|
151cb7536c | ||
|
|
0994eb346f | ||
|
|
4863a37328 | ||
|
|
052e236a93 | ||
|
|
c79ea19aa1 | ||
|
|
79f2cebdb8 | ||
|
|
bd7b8884ab | ||
|
|
38e0adb499 | ||
|
|
7698f5ce11 | ||
|
|
ce13e5ddb1 | ||
|
|
baafebbf7b | ||
|
|
87426133a2 | ||
|
|
60f5cbe780 | ||
|
|
86d8ed52d7 | ||
|
|
07633ddbf8 | ||
|
|
dd90c426e4 | ||
|
|
059357f834 | ||
|
|
ceee3a9295 | ||
|
|
403f609f69 | ||
|
|
304c8dda4e | ||
|
|
c4d923c46f | ||
|
|
fa9f9146a2 | ||
|
|
dc9409a5a6 | ||
|
|
51aa8dc381 | ||
|
|
5061f4d9fd | ||
|
|
4337af06d4 | ||
|
|
d226d57325 | ||
|
|
9f92c58640 | ||
|
|
8901994644 | ||
|
|
e3ca555df7 | ||
|
|
3b9c96dff8 | ||
|
|
cb94a4260e | ||
|
|
ac9499aa6d | ||
|
|
fc25840f95 | ||
|
|
b409adf9d8 | ||
|
|
b76776d7b0 | ||
|
|
8499992abd | ||
|
|
dc96447d72 | ||
|
|
f5d1c25295 | ||
|
|
95870883a1 | ||
|
|
aa71c58400 | ||
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 |
68
.env.example
68
.env.example
@@ -33,6 +33,59 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
|||||||
CLAUDE_API_VERSION=2023-06-01
|
CLAUDE_API_VERSION=2023-06-01
|
||||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||||
|
|
||||||
|
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||||
|
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||||
|
# GEMINI_OAUTH_CLIENT_ID=
|
||||||
|
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||||
|
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||||
|
# GEMINI_OAUTH_REDIRECT_URI=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||||
|
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||||
|
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||||
|
# Antigravity 上游地址(可选,默认 sandbox)
|
||||||
|
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||||
|
# Antigravity User-Agent(可选)
|
||||||
|
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||||
|
|
||||||
|
# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量):
|
||||||
|
# - /api -> Claude 账号池(默认)
|
||||||
|
# - /antigravity/api -> Antigravity OAuth
|
||||||
|
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🐛 调试 Dump 配置(可选)
|
||||||
|
# ============================================================================
|
||||||
|
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||||
|
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||||
|
#
|
||||||
|
# 📄 输出文件列表:
|
||||||
|
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||||
|
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||||
|
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||||
|
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||||
|
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||||
|
#
|
||||||
|
# 📌 开关配置:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||||
|
#
|
||||||
|
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
#
|
||||||
|
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||||
|
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||||
|
#
|
||||||
|
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||||
|
# (仅 /antigravity/api 分流生效)
|
||||||
|
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||||
|
|
||||||
|
|
||||||
# 🚫 529错误处理配置
|
# 🚫 529错误处理配置
|
||||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||||
@@ -61,6 +114,19 @@ PROXY_USE_IPV4=true
|
|||||||
# ⏱️ 请求超时配置
|
# ⏱️ 请求超时配置
|
||||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||||
|
|
||||||
|
# 🔗 HTTP 连接池配置(keep-alive)
|
||||||
|
# 流式请求最大连接数(默认65535)
|
||||||
|
# HTTPS_MAX_SOCKETS_STREAM=65535
|
||||||
|
# 非流式请求最大连接数(默认16384)
|
||||||
|
# HTTPS_MAX_SOCKETS_NON_STREAM=16384
|
||||||
|
# 空闲连接数(默认2048)
|
||||||
|
# HTTPS_MAX_FREE_SOCKETS=2048
|
||||||
|
# 空闲连接超时(毫秒,默认30000)
|
||||||
|
# HTTPS_FREE_SOCKET_TIMEOUT=30000
|
||||||
|
|
||||||
|
# 🔧 请求体大小配置
|
||||||
|
REQUEST_MAX_SIZE_MB=60
|
||||||
|
|
||||||
# 📈 使用限制
|
# 📈 使用限制
|
||||||
DEFAULT_TOKEN_LIMIT=1000000
|
DEFAULT_TOKEN_LIMIT=1000000
|
||||||
|
|
||||||
@@ -75,6 +141,8 @@ TOKEN_USAGE_RETENTION=2592000000
|
|||||||
HEALTH_CHECK_INTERVAL=60000
|
HEALTH_CHECK_INTERVAL=60000
|
||||||
TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
|
TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
|
||||||
METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
||||||
|
# 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||||
|
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
|
||||||
|
|
||||||
# 🎨 Web 界面配置
|
# 🎨 Web 界面配置
|
||||||
WEB_TITLE=Claude Relay Service
|
WEB_TITLE=Claude Relay Service
|
||||||
|
|||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
53
CLAUDE.md
53
CLAUDE.md
@@ -22,6 +22,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
|||||||
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
||||||
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
||||||
- **模型黑名单**: 支持API Key级别的模型访问限制
|
- **模型黑名单**: 支持API Key级别的模型访问限制
|
||||||
|
- **并发请求排队**: 当API Key并发数超限时,请求进入队列等待而非立即返回429,支持配置最大排队数、超时时间,适用于Claude Code Agent并行工具调用场景
|
||||||
|
|
||||||
### 主要服务组件
|
### 主要服务组件
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
|||||||
|
|
||||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||||
|
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||||
- **costInitService.js**: 成本数据初始化服务
|
- **costInitService.js**: 成本数据初始化服务
|
||||||
- **webhookService.js**: Webhook通知服务
|
- **webhookService.js**: Webhook通知服务
|
||||||
@@ -185,12 +187,17 @@ npm run service:stop # 停止服务
|
|||||||
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
||||||
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
||||||
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
||||||
|
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认false)
|
||||||
|
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔(毫秒,默认200)
|
||||||
|
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认5000,锁持有时间短无需长等待)
|
||||||
|
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL(毫秒,默认5000,请求发送后立即释放无需长TTL)
|
||||||
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
||||||
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
||||||
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
||||||
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
|
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
|
||||||
- `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
|
- `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
|
||||||
- `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
|
- `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
|
||||||
|
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||||
|
|
||||||
#### AWS Bedrock配置(可选)
|
#### AWS Bedrock配置(可选)
|
||||||
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
|
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
|
||||||
@@ -337,6 +344,35 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
||||||
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
||||||
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
||||||
|
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`)
|
||||||
|
15. **并发请求排队问题**:
|
||||||
|
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理(默认10秒)
|
||||||
|
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
|
||||||
|
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
|
||||||
|
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
|
||||||
|
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
|
||||||
|
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
|
||||||
|
|
||||||
|
### 代理配置要求(并发请求排队)
|
||||||
|
|
||||||
|
使用并发请求排队功能时,需要正确配置代理(如 Nginx)的超时参数:
|
||||||
|
|
||||||
|
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
|
||||||
|
- 当前默认排队超时 10 秒,Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
|
||||||
|
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
|
||||||
|
- **Nginx 配置示例**:
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
# ...其他配置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **企业防火墙环境**:
|
||||||
|
- 某些企业防火墙可能静默关闭长时间无数据的连接(20-40 秒)
|
||||||
|
- 如遇此问题,联系网络管理员调整空闲连接超时策略
|
||||||
|
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
|
||||||
|
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
|
||||||
|
|
||||||
### 调试工具
|
### 调试工具
|
||||||
|
|
||||||
@@ -449,6 +485,15 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
- **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
|
- **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
|
||||||
- **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
|
- **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
|
||||||
- **并发控制**: Redis Sorted Set实现的并发计数,支持自动过期清理
|
- **并发控制**: Redis Sorted Set实现的并发计数,支持自动过期清理
|
||||||
|
- **并发请求排队**: 当API Key并发超限时,请求进入队列等待而非直接返回429
|
||||||
|
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
|
||||||
|
- **指数退避**: 初始200ms,指数增长至最大2秒,带±20%抖动防惊群效应
|
||||||
|
- **智能清理**: 排队计数有TTL保护(超时+30秒),进程崩溃也能自动清理
|
||||||
|
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证,避免HTTP Keep-Alive连接复用导致的身份混淆
|
||||||
|
- **健康检查**: P90等待时间超过阈值时快速失败(返回429),避免新请求在过载时继续排队
|
||||||
|
- **配置参数**: `concurrentRequestQueueEnabled`(默认false)、`concurrentRequestQueueMaxSize`(默认3)、`concurrentRequestQueueMaxSizeMultiplier`(默认0)、`concurrentRequestQueueTimeoutMs`(默认10秒)、`concurrentRequestQueueMaxRedisFailCount`(默认5)、`concurrentRequestQueueHealthCheckEnabled`(默认true)、`concurrentRequestQueueHealthThreshold`(默认0.8)
|
||||||
|
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
|
||||||
|
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
|
||||||
- **客户端识别**: 基于User-Agent的客户端限制,支持预定义客户端(ClaudeCode、Gemini-CLI等)
|
- **客户端识别**: 基于User-Agent的客户端限制,支持预定义客户端(ClaudeCode、Gemini-CLI等)
|
||||||
- **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
|
- **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
|
||||||
|
|
||||||
@@ -508,8 +553,16 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
- `overload:{accountId}` - 账户过载状态(529错误)
|
- `overload:{accountId}` - 账户过载状态(529错误)
|
||||||
- **并发控制**:
|
- **并发控制**:
|
||||||
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
||||||
|
- **并发请求排队**:
|
||||||
|
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器(TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定)
|
||||||
|
- `concurrency:queue:stats:{apiKeyId}` - 排队统计(entered/success/timeout/cancelled)
|
||||||
|
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录(用于P50/P90/P99计算)
|
||||||
|
- `concurrency:queue:wait_times:global` - 全局等待时间记录
|
||||||
- **Webhook配置**:
|
- **Webhook配置**:
|
||||||
- `webhook_config:{id}` - Webhook配置
|
- `webhook_config:{id}` - Webhook配置
|
||||||
|
- **用户消息队列**:
|
||||||
|
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁(当前持有者requestId)
|
||||||
|
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
|
||||||
- **系统信息**:
|
- **系统信息**:
|
||||||
- `system_info` - 系统状态缓存
|
- `system_info` - 系统状态缓存
|
||||||
- `model_pricing` - 模型价格数据(pricingService)
|
- `model_pricing` - 模型价格数据(pricingService)
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||||
|
>
|
||||||
|
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
@@ -389,6 +394,9 @@ docker-compose.yml 已包含:
|
|||||||
|
|
||||||
**Claude Code 设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
|
|
||||||
|
|
||||||
|
**使用标准 Claude 账号池**
|
||||||
|
|
||||||
默认使用标准 Claude 账号池:
|
默认使用标准 Claude 账号池:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -396,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
|
|||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**使用 Antigravity 账户池**
|
||||||
|
|
||||||
|
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||||
|
|
||||||
|
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
|
|
||||||
|
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||||
|
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||||
|
|
||||||
|
# 4. 启动
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
**VSCode Claude 插件配置:**
|
**VSCode Claude 插件配置:**
|
||||||
|
|
||||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||||
@@ -408,6 +434,8 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
|||||||
|
|
||||||
如果该文件不存在,请手动创建。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 方式访问**
|
||||||
@@ -597,8 +625,9 @@ gpt-5 # Codex使用固定模型ID
|
|||||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||||
- 根据不同的路由前缀自动识别账号类型
|
- 根据不同的路由前缀自动识别账号类型
|
||||||
- `/claude/` - 使用Claude账号池
|
- `/claude/` - 使用Claude账号池
|
||||||
|
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||||
- `/gemini/` - 使用Gemini账号池
|
- `/gemini/` - 使用Gemini账号池
|
||||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||||
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
|
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
|
||||||
- 支持所有标准API端点(messages、models等)
|
- 支持所有标准API端点(messages、models等)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||||
|
>
|
||||||
|
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|||||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Use this section to tell people about which versions of your project are
|
||||||
|
currently being supported with security updates.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 5.1.x | :white_check_mark: |
|
||||||
|
| 5.0.x | :x: |
|
||||||
|
| 4.0.x | :white_check_mark: |
|
||||||
|
| < 4.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Use this section to tell people how to report a vulnerability.
|
||||||
|
|
||||||
|
Tell them where to go, how often they can expect to get an update on a
|
||||||
|
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||||
|
declined, etc.
|
||||||
16
cli/index.js
16
cli/index.js
@@ -103,7 +103,7 @@ program
|
|||||||
try {
|
try {
|
||||||
const [, apiKeys, accounts] = await Promise.all([
|
const [, apiKeys, accounts] = await Promise.all([
|
||||||
redis.getSystemStats(),
|
redis.getSystemStats(),
|
||||||
apiKeyService.getAllApiKeys(),
|
apiKeyService.getAllApiKeysFast(),
|
||||||
claudeAccountService.getAllAccounts()
|
claudeAccountService.getAllAccounts()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ async function listApiKeys() {
|
|||||||
const spinner = ora('正在获取 API Keys...').start()
|
const spinner = ora('正在获取 API Keys...').start()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
|
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
|
||||||
|
|
||||||
if (apiKeys.length === 0) {
|
if (apiKeys.length === 0) {
|
||||||
@@ -314,7 +314,7 @@ async function listApiKeys() {
|
|||||||
|
|
||||||
tableData.push([
|
tableData.push([
|
||||||
key.name,
|
key.name,
|
||||||
key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
|
key.maskedKey || '-',
|
||||||
key.isActive ? '🟢 活跃' : '🔴 停用',
|
key.isActive ? '🟢 活跃' : '🔴 停用',
|
||||||
expiryStatus,
|
expiryStatus,
|
||||||
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
|
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
|
||||||
@@ -333,7 +333,7 @@ async function listApiKeys() {
|
|||||||
async function updateApiKeyExpiry() {
|
async function updateApiKeyExpiry() {
|
||||||
try {
|
try {
|
||||||
// 获取所有 API Keys
|
// 获取所有 API Keys
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
|
||||||
if (apiKeys.length === 0) {
|
if (apiKeys.length === 0) {
|
||||||
console.log(styles.warning('没有找到任何 API Keys'))
|
console.log(styles.warning('没有找到任何 API Keys'))
|
||||||
@@ -347,7 +347,7 @@ async function updateApiKeyExpiry() {
|
|||||||
name: 'selectedKey',
|
name: 'selectedKey',
|
||||||
message: '选择要修改的 API Key:',
|
message: '选择要修改的 API Key:',
|
||||||
choices: apiKeys.map((key) => ({
|
choices: apiKeys.map((key) => ({
|
||||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||||
value: key
|
value: key
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ async function renewApiKeys() {
|
|||||||
const spinner = ora('正在查找即将过期的 API Keys...').start()
|
const spinner = ora('正在查找即将过期的 API Keys...').start()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
@@ -562,7 +562,7 @@ async function renewApiKeys() {
|
|||||||
|
|
||||||
async function deleteApiKey() {
|
async function deleteApiKey() {
|
||||||
try {
|
try {
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
|
||||||
if (apiKeys.length === 0) {
|
if (apiKeys.length === 0) {
|
||||||
console.log(styles.warning('没有找到任何 API Keys'))
|
console.log(styles.warning('没有找到任何 API Keys'))
|
||||||
@@ -575,7 +575,7 @@ async function deleteApiKey() {
|
|||||||
name: 'selectedKeys',
|
name: 'selectedKeys',
|
||||||
message: '选择要删除的 API Keys (空格选择,回车确认):',
|
message: '选择要删除的 API Keys (空格选择,回车确认):',
|
||||||
choices: apiKeys.map((key) => ({
|
choices: apiKeys.map((key) => ({
|
||||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
|
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)})`,
|
||||||
value: key.id
|
value: key.id
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ const config = {
|
|||||||
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
||||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
||||||
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
||||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8
|
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8
|
||||||
|
metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 🎨 Web界面配置
|
// 🎨 Web界面配置
|
||||||
@@ -203,6 +204,30 @@ const config = {
|
|||||||
development: {
|
development: {
|
||||||
debug: process.env.DEBUG === 'true',
|
debug: process.env.DEBUG === 'true',
|
||||||
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 限流基于请求发送时刻计算
|
||||||
|
userMessageQueue: {
|
||||||
|
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
|
||||||
|
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
|
||||||
|
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
|
||||||
|
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送
|
||||||
|
},
|
||||||
|
|
||||||
|
// 🎫 额度卡兑换上限配置(防盗刷)
|
||||||
|
quotaCardLimits: {
|
||||||
|
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
|
||||||
|
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
|
||||||
|
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
config/models.js
Normal file
64
config/models.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 模型列表配置
|
||||||
|
* 用于前端展示和测试功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CLAUDE_MODELS = [
|
||||||
|
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||||
|
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' },
|
||||||
|
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
|
||||||
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||||
|
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const GEMINI_MODELS = [
|
||||||
|
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||||
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
|
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||||
|
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPENAI_MODELS = [
|
||||||
|
{ value: 'gpt-5', label: 'GPT-5' },
|
||||||
|
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||||
|
{ value: 'gpt-5-nano', label: 'GPT-5 Nano' },
|
||||||
|
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||||
|
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||||
|
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||||
|
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||||
|
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||||
|
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||||
|
{ value: 'codex-mini', label: 'Codex Mini' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 其他模型(用于账户编辑的模型映射)
|
||||||
|
const OTHER_MODELS = [
|
||||||
|
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||||
|
{ value: 'Qwen', label: 'Qwen' },
|
||||||
|
{ value: 'Kimi', label: 'Kimi' },
|
||||||
|
{ value: 'GLM', label: 'GLM' }
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CLAUDE_MODELS,
|
||||||
|
GEMINI_MODELS,
|
||||||
|
OPENAI_MODELS,
|
||||||
|
OTHER_MODELS,
|
||||||
|
// 按服务分组
|
||||||
|
getModelsByService: (service) => {
|
||||||
|
switch (service) {
|
||||||
|
case 'claude':
|
||||||
|
return CLAUDE_MODELS
|
||||||
|
case 'gemini':
|
||||||
|
return GEMINI_MODELS
|
||||||
|
case 'openai':
|
||||||
|
return OPENAI_MODELS
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 获取所有模型(用于账户编辑)
|
||||||
|
getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS]
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ const repository =
|
|||||||
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
|
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
|
||||||
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
|
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
|
||||||
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
|
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
|
||||||
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
const hashFileName =
|
||||||
|
process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||||
|
|
||||||
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||||
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
|
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
|
||||||
@@ -11,7 +12,6 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
pricingFileName,
|
pricingFileName,
|
||||||
hashFileName,
|
hashFileName,
|
||||||
pricingUrl:
|
pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||||
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
|
||||||
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
|
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
|
||||||
|
# 🔧 请求体大小配置
|
||||||
|
- REQUEST_MAX_SIZE_MB=60
|
||||||
|
|
||||||
# 🔐 安全配置(必填)
|
# 🔐 安全配置(必填)
|
||||||
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -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",
|
||||||
@@ -891,7 +892,6 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -3000,7 +3000,6 @@
|
|||||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3082,7 +3081,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3538,7 +3536,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001737",
|
"caniuse-lite": "^1.0.30001737",
|
||||||
"electron-to-chromium": "^1.5.211",
|
"electron-to-chromium": "^1.5.211",
|
||||||
@@ -4426,7 +4423,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4483,7 +4479,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -7034,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",
|
||||||
@@ -7582,7 +7586,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -9101,7 +9104,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.2",
|
"@dabh/diagnostics": "^2.0.2",
|
||||||
|
|||||||
@@ -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
71
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -152,62 +152,110 @@ async function exportUsageStats(keyId) {
|
|||||||
daily: {},
|
daily: {},
|
||||||
monthly: {},
|
monthly: {},
|
||||||
hourly: {},
|
hourly: {},
|
||||||
models: {}
|
models: {},
|
||||||
|
// 费用统计(String 类型)
|
||||||
|
costTotal: null,
|
||||||
|
costDaily: {},
|
||||||
|
costMonthly: {},
|
||||||
|
costHourly: {},
|
||||||
|
opusTotal: null,
|
||||||
|
opusWeekly: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出总统计
|
// 导出总统计(Hash)
|
||||||
const totalKey = `usage:${keyId}`
|
const totalData = await redis.client.hgetall(`usage:${keyId}`)
|
||||||
const totalData = await redis.client.hgetall(totalKey)
|
|
||||||
if (totalData && Object.keys(totalData).length > 0) {
|
if (totalData && Object.keys(totalData).length > 0) {
|
||||||
stats.total = totalData
|
stats.total = totalData
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出每日统计(最近30天)
|
// 导出费用总统计(String)
|
||||||
const today = new Date()
|
const costTotal = await redis.client.get(`usage:cost:total:${keyId}`)
|
||||||
for (let i = 0; i < 30; i++) {
|
if (costTotal) {
|
||||||
const date = new Date(today)
|
stats.costTotal = costTotal
|
||||||
date.setDate(date.getDate() - i)
|
}
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
|
||||||
const dailyKey = `usage:daily:${keyId}:${dateStr}`
|
|
||||||
|
|
||||||
const dailyData = await redis.client.hgetall(dailyKey)
|
// 导出 Opus 费用总统计(String)
|
||||||
if (dailyData && Object.keys(dailyData).length > 0) {
|
const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`)
|
||||||
stats.daily[dateStr] = dailyData
|
if (opusTotal) {
|
||||||
|
stats.opusTotal = opusTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出每日统计(扫描现有 key,避免时区问题)
|
||||||
|
const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`)
|
||||||
|
for (const key of dailyKeys) {
|
||||||
|
const date = key.split(':').pop()
|
||||||
|
const data = await redis.client.hgetall(key)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
stats.daily[date] = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出每月统计(最近12个月)
|
// 导出每日费用(扫描现有 key)
|
||||||
for (let i = 0; i < 12; i++) {
|
const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`)
|
||||||
const date = new Date(today)
|
for (const key of costDailyKeys) {
|
||||||
date.setMonth(date.getMonth() - i)
|
const date = key.split(':').pop()
|
||||||
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
const value = await redis.client.get(key)
|
||||||
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
|
if (value) {
|
||||||
|
stats.costDaily[date] = value
|
||||||
const monthlyData = await redis.client.hgetall(monthlyKey)
|
|
||||||
if (monthlyData && Object.keys(monthlyData).length > 0) {
|
|
||||||
stats.monthly[monthStr] = monthlyData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出小时统计(最近24小时)
|
// 导出每月统计(扫描现有 key)
|
||||||
for (let i = 0; i < 24; i++) {
|
const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`)
|
||||||
const date = new Date(today)
|
for (const key of monthlyKeys) {
|
||||||
date.setHours(date.getHours() - i)
|
const month = key.split(':').pop()
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
const data = await redis.client.hgetall(key)
|
||||||
const hour = String(date.getHours()).padStart(2, '0')
|
if (data && Object.keys(data).length > 0) {
|
||||||
const hourKey = `${dateStr}:${hour}`
|
stats.monthly[month] = data
|
||||||
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
|
|
||||||
|
|
||||||
const hourlyData = await redis.client.hgetall(hourlyKey)
|
|
||||||
if (hourlyData && Object.keys(hourlyData).length > 0) {
|
|
||||||
stats.hourly[hourKey] = hourlyData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出模型统计
|
// 导出每月费用(扫描现有 key)
|
||||||
// 每日模型统计
|
const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`)
|
||||||
const modelDailyPattern = `usage:${keyId}:model:daily:*`
|
for (const key of costMonthlyKeys) {
|
||||||
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
const month = key.split(':').pop()
|
||||||
|
const value = await redis.client.get(key)
|
||||||
|
if (value) {
|
||||||
|
stats.costMonthly[month] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 Opus 周费用(扫描现有 key)
|
||||||
|
const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`)
|
||||||
|
for (const key of opusWeeklyKeys) {
|
||||||
|
const week = key.split(':').pop()
|
||||||
|
const value = await redis.client.get(key)
|
||||||
|
if (value) {
|
||||||
|
stats.opusWeekly[week] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出小时统计(扫描现有 key)
|
||||||
|
// key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||||
|
const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`)
|
||||||
|
for (const key of hourlyKeys) {
|
||||||
|
const parts = key.split(':')
|
||||||
|
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||||
|
const data = await redis.client.hgetall(key)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
stats.hourly[hourKey] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出小时费用(扫描现有 key)
|
||||||
|
// key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||||
|
const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`)
|
||||||
|
for (const key of costHourlyKeys) {
|
||||||
|
const parts = key.split(':')
|
||||||
|
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||||
|
const value = await redis.client.get(key)
|
||||||
|
if (value) {
|
||||||
|
stats.costHourly[hourKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出模型统计(每日)
|
||||||
|
const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`)
|
||||||
for (const key of modelDailyKeys) {
|
for (const key of modelDailyKeys) {
|
||||||
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -223,9 +271,8 @@ async function exportUsageStats(keyId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每月模型统计
|
// 导出模型统计(每月)
|
||||||
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`
|
const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`)
|
||||||
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
|
||||||
for (const key of modelMonthlyKeys) {
|
for (const key of modelMonthlyKeys) {
|
||||||
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) {
|
|||||||
const pipeline = redis.client.pipeline()
|
const pipeline = redis.client.pipeline()
|
||||||
let importCount = 0
|
let importCount = 0
|
||||||
|
|
||||||
// 导入总统计
|
// 导入总统计(Hash)
|
||||||
if (stats.total && Object.keys(stats.total).length > 0) {
|
if (stats.total && Object.keys(stats.total).length > 0) {
|
||||||
for (const [field, value] of Object.entries(stats.total)) {
|
for (const [field, value] of Object.entries(stats.total)) {
|
||||||
pipeline.hset(`usage:${keyId}`, field, value)
|
pipeline.hset(`usage:${keyId}`, field, value)
|
||||||
@@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) {
|
|||||||
importCount++
|
importCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入每日统计
|
// 导入费用总统计(String)
|
||||||
|
if (stats.costTotal) {
|
||||||
|
pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入 Opus 费用总统计(String)
|
||||||
|
if (stats.opusTotal) {
|
||||||
|
pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入每日统计(Hash)
|
||||||
if (stats.daily) {
|
if (stats.daily) {
|
||||||
for (const [date, data] of Object.entries(stats.daily)) {
|
for (const [date, data] of Object.entries(stats.daily)) {
|
||||||
for (const [field, value] of Object.entries(data)) {
|
for (const [field, value] of Object.entries(data)) {
|
||||||
@@ -276,7 +335,15 @@ async function importUsageStats(keyId, stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入每月统计
|
// 导入每日费用(String)
|
||||||
|
if (stats.costDaily) {
|
||||||
|
for (const [date, value] of Object.entries(stats.costDaily)) {
|
||||||
|
pipeline.set(`usage:cost:daily:${keyId}:${date}`, value)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入每月统计(Hash)
|
||||||
if (stats.monthly) {
|
if (stats.monthly) {
|
||||||
for (const [month, data] of Object.entries(stats.monthly)) {
|
for (const [month, data] of Object.entries(stats.monthly)) {
|
||||||
for (const [field, value] of Object.entries(data)) {
|
for (const [field, value] of Object.entries(data)) {
|
||||||
@@ -286,7 +353,23 @@ async function importUsageStats(keyId, stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入小时统计
|
// 导入每月费用(String)
|
||||||
|
if (stats.costMonthly) {
|
||||||
|
for (const [month, value] of Object.entries(stats.costMonthly)) {
|
||||||
|
pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入 Opus 周费用(String,不加 TTL 保留历史全量)
|
||||||
|
if (stats.opusWeekly) {
|
||||||
|
for (const [week, value] of Object.entries(stats.opusWeekly)) {
|
||||||
|
pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入小时统计(Hash)
|
||||||
if (stats.hourly) {
|
if (stats.hourly) {
|
||||||
for (const [hour, data] of Object.entries(stats.hourly)) {
|
for (const [hour, data] of Object.entries(stats.hourly)) {
|
||||||
for (const [field, value] of Object.entries(data)) {
|
for (const [field, value] of Object.entries(data)) {
|
||||||
@@ -296,10 +379,17 @@ async function importUsageStats(keyId, stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入模型统计
|
// 导入小时费用(String)
|
||||||
|
if (stats.costHourly) {
|
||||||
|
for (const [hour, value] of Object.entries(stats.costHourly)) {
|
||||||
|
pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value)
|
||||||
|
importCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入模型统计(Hash)
|
||||||
if (stats.models) {
|
if (stats.models) {
|
||||||
for (const [model, modelStats] of Object.entries(stats.models)) {
|
for (const [model, modelStats] of Object.entries(stats.models)) {
|
||||||
// 每日模型统计
|
|
||||||
if (modelStats.daily) {
|
if (modelStats.daily) {
|
||||||
for (const [date, data] of Object.entries(modelStats.daily)) {
|
for (const [date, data] of Object.entries(modelStats.daily)) {
|
||||||
for (const [field, value] of Object.entries(data)) {
|
for (const [field, value] of Object.entries(data)) {
|
||||||
@@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每月模型统计
|
|
||||||
if (modelStats.monthly) {
|
if (modelStats.monthly) {
|
||||||
for (const [month, data] of Object.entries(modelStats.monthly)) {
|
for (const [month, data] of Object.entries(modelStats.monthly)) {
|
||||||
for (const [field, value] of Object.entries(data)) {
|
for (const [field, value] of Object.entries(data)) {
|
||||||
@@ -547,13 +636,54 @@ async function exportData() {
|
|||||||
const globalStats = {
|
const globalStats = {
|
||||||
daily: {},
|
daily: {},
|
||||||
monthly: {},
|
monthly: {},
|
||||||
hourly: {}
|
hourly: {},
|
||||||
|
// 新增:索引和全局统计
|
||||||
|
monthlyMonths: [], // usage:model:monthly:months Set
|
||||||
|
globalTotal: null, // usage:global:total Hash
|
||||||
|
globalDaily: {}, // usage:global:daily:* Hash
|
||||||
|
globalMonthly: {} // usage:global:monthly:* Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出全局每日模型统计
|
// 导出月份索引
|
||||||
const globalDailyPattern = 'usage:model:daily:*'
|
const monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
|
||||||
const globalDailyKeys = await redis.client.keys(globalDailyPattern)
|
if (monthlyMonths && monthlyMonths.length > 0) {
|
||||||
|
globalStats.monthlyMonths = monthlyMonths
|
||||||
|
logger.info(`📤 Found ${monthlyMonths.length} months in index`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出全局统计
|
||||||
|
const globalTotal = await redis.client.hgetall('usage:global:total')
|
||||||
|
if (globalTotal && Object.keys(globalTotal).length > 0) {
|
||||||
|
globalStats.globalTotal = globalTotal
|
||||||
|
logger.info('📤 Found global total stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出全局每日统计
|
||||||
|
const globalDailyKeys = await redis.client.keys('usage:global:daily:*')
|
||||||
for (const key of globalDailyKeys) {
|
for (const key of globalDailyKeys) {
|
||||||
|
const date = key.replace('usage:global:daily:', '')
|
||||||
|
const data = await redis.client.hgetall(key)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
globalStats.globalDaily[date] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`)
|
||||||
|
|
||||||
|
// 导出全局每月统计
|
||||||
|
const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*')
|
||||||
|
for (const key of globalMonthlyKeys) {
|
||||||
|
const month = key.replace('usage:global:monthly:', '')
|
||||||
|
const data = await redis.client.hgetall(key)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
globalStats.globalMonthly[month] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`)
|
||||||
|
|
||||||
|
// 导出全局每日模型统计
|
||||||
|
const modelDailyPattern = 'usage:model:daily:*'
|
||||||
|
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
||||||
|
for (const key of modelDailyKeys) {
|
||||||
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
const model = match[1]
|
const model = match[1]
|
||||||
@@ -569,9 +699,9 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出全局每月模型统计
|
// 导出全局每月模型统计
|
||||||
const globalMonthlyPattern = 'usage:model:monthly:*'
|
const modelMonthlyPattern = 'usage:model:monthly:*'
|
||||||
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
|
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
||||||
for (const key of globalMonthlyKeys) {
|
for (const key of modelMonthlyKeys) {
|
||||||
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
|
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
const model = match[1]
|
const model = match[1]
|
||||||
@@ -1040,6 +1170,46 @@ async function importData() {
|
|||||||
const pipeline = redis.client.pipeline()
|
const pipeline = redis.client.pipeline()
|
||||||
let globalStatCount = 0
|
let globalStatCount = 0
|
||||||
|
|
||||||
|
// 导入月份索引
|
||||||
|
if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) {
|
||||||
|
for (const month of globalStats.monthlyMonths) {
|
||||||
|
pipeline.sadd('usage:model:monthly:months', month)
|
||||||
|
}
|
||||||
|
logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入全局统计
|
||||||
|
if (globalStats.globalTotal) {
|
||||||
|
for (const [field, value] of Object.entries(globalStats.globalTotal)) {
|
||||||
|
pipeline.hset('usage:global:total', field, value)
|
||||||
|
}
|
||||||
|
logger.info('📥 Importing global total stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入全局每日统计
|
||||||
|
if (globalStats.globalDaily) {
|
||||||
|
for (const [date, data] of Object.entries(globalStats.globalDaily)) {
|
||||||
|
for (const [field, value] of Object.entries(data)) {
|
||||||
|
pipeline.hset(`usage:global:daily:${date}`, field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入全局每月统计
|
||||||
|
if (globalStats.globalMonthly) {
|
||||||
|
for (const [month, data] of Object.entries(globalStats.globalMonthly)) {
|
||||||
|
for (const [field, value] of Object.entries(data)) {
|
||||||
|
pipeline.hset(`usage:global:monthly:${month}`, field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 导入每日统计
|
// 导入每日统计
|
||||||
if (globalStats.daily) {
|
if (globalStats.daily) {
|
||||||
for (const [date, models] of Object.entries(globalStats.daily)) {
|
for (const [date, models] of Object.entries(globalStats.daily)) {
|
||||||
@@ -1061,6 +1231,8 @@ async function importData() {
|
|||||||
}
|
}
|
||||||
globalStatCount++
|
globalStatCount++
|
||||||
}
|
}
|
||||||
|
// 同时更新月份索引(兼容旧格式导出文件)
|
||||||
|
pipeline.sadd('usage:model:monthly:months', month)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ async function cleanTestData() {
|
|||||||
logger.info('🧹 Cleaning test data...')
|
logger.info('🧹 Cleaning test data...')
|
||||||
|
|
||||||
// 获取所有API Keys
|
// 获取所有API Keys
|
||||||
const allKeys = await apiKeyService.getAllApiKeys()
|
const allKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
|
||||||
// 找出所有测试 API Keys
|
// 找出所有测试 API Keys
|
||||||
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
|
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const redis = require('../src/models/redis')
|
const redis = require('../src/models/redis')
|
||||||
|
const apiKeyService = require('../src/services/apiKeyService')
|
||||||
const logger = require('../src/utils/logger')
|
const logger = require('../src/utils/logger')
|
||||||
const readline = require('readline')
|
const readline = require('readline')
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ async function migrateApiKeys() {
|
|||||||
logger.success('✅ Connected to Redis')
|
logger.success('✅ Connected to Redis')
|
||||||
|
|
||||||
// 获取所有 API Keys
|
// 获取所有 API Keys
|
||||||
const apiKeys = await redis.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
|
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
|
||||||
|
|
||||||
// 统计信息
|
// 统计信息
|
||||||
|
|||||||
138
scripts/migrate-usage-index.js
Normal file
138
scripts/migrate-usage-index.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 历史数据索引迁移脚本
|
||||||
|
* 为现有的 usage 数据建立索引,加速查询
|
||||||
|
*/
|
||||||
|
const Redis = require('ioredis')
|
||||||
|
const config = require('../config/config')
|
||||||
|
|
||||||
|
const redis = new Redis({
|
||||||
|
host: config.redis.host,
|
||||||
|
port: config.redis.port,
|
||||||
|
password: config.redis.password,
|
||||||
|
db: config.redis.db || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('开始迁移历史数据索引...')
|
||||||
|
console.log('Redis DB:', config.redis.db || 0)
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
dailyIndex: 0,
|
||||||
|
hourlyIndex: 0,
|
||||||
|
modelDailyIndex: 0,
|
||||||
|
modelHourlyIndex: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 迁移 usage:daily:{keyId}:{date} 索引
|
||||||
|
console.log('\n1. 迁移 usage:daily 索引...')
|
||||||
|
let cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
// usage:daily:{keyId}:{date}
|
||||||
|
const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
|
if (match) {
|
||||||
|
const [, keyId, date] = match
|
||||||
|
pipeline.sadd(`usage:daily:index:${date}`, keyId)
|
||||||
|
pipeline.expire(`usage:daily:index:${date}`, 86400 * 32)
|
||||||
|
stats.dailyIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
console.log(` 已处理 ${stats.dailyIndex} 条`)
|
||||||
|
|
||||||
|
// 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引
|
||||||
|
console.log('\n2. 迁移 usage:hourly 索引...')
|
||||||
|
cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
// usage:hourly:{keyId}:{date}:{hour}
|
||||||
|
const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||||
|
if (match) {
|
||||||
|
const [, keyId, hourKey] = match
|
||||||
|
pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId)
|
||||||
|
pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7)
|
||||||
|
stats.hourlyIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
console.log(` 已处理 ${stats.hourlyIndex} 条`)
|
||||||
|
|
||||||
|
// 3. 迁移 usage:model:daily:{model}:{date} 索引
|
||||||
|
console.log('\n3. 迁移 usage:model:daily 索引...')
|
||||||
|
cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
// usage:model:daily:{model}:{date}
|
||||||
|
const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
|
if (match) {
|
||||||
|
const [, model, date] = match
|
||||||
|
pipeline.sadd(`usage:model:daily:index:${date}`, model)
|
||||||
|
pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32)
|
||||||
|
stats.modelDailyIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
console.log(` 已处理 ${stats.modelDailyIndex} 条`)
|
||||||
|
|
||||||
|
// 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引
|
||||||
|
console.log('\n4. 迁移 usage:model:hourly 索引...')
|
||||||
|
cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await redis.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
'usage:model:hourly:*',
|
||||||
|
'COUNT',
|
||||||
|
500
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
// usage:model:hourly:{model}:{date}:{hour}
|
||||||
|
const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||||
|
if (match) {
|
||||||
|
const [, model, hourKey] = match
|
||||||
|
pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model)
|
||||||
|
pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7)
|
||||||
|
stats.modelHourlyIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
console.log(` 已处理 ${stats.modelHourlyIndex} 条`)
|
||||||
|
|
||||||
|
console.log('\n迁移完成!')
|
||||||
|
console.log('统计:', stats)
|
||||||
|
|
||||||
|
redis.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('迁移失败:', err)
|
||||||
|
redis.disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
249
src/app.js
249
src/app.js
@@ -11,6 +11,7 @@ const logger = require('./utils/logger')
|
|||||||
const redis = require('./models/redis')
|
const redis = require('./models/redis')
|
||||||
const pricingService = require('./services/pricingService')
|
const pricingService = require('./services/pricingService')
|
||||||
const cacheMonitor = require('./utils/cacheMonitor')
|
const cacheMonitor = require('./utils/cacheMonitor')
|
||||||
|
const { getSafeMessage } = require('./utils/errorSanitizer')
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const apiRoutes = require('./routes/api')
|
const apiRoutes = require('./routes/api')
|
||||||
@@ -50,7 +51,48 @@ class Application {
|
|||||||
// 🔗 连接Redis
|
// 🔗 连接Redis
|
||||||
logger.info('🔄 Connecting to Redis...')
|
logger.info('🔄 Connecting to Redis...')
|
||||||
await redis.connect()
|
await redis.connect()
|
||||||
logger.success('✅ Redis connected successfully')
|
logger.success('Redis connected successfully')
|
||||||
|
|
||||||
|
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
|
||||||
|
const { getAppVersion, versionGt } = require('./utils/commonHelper')
|
||||||
|
const currentVersion = getAppVersion()
|
||||||
|
const migratedVersion = await redis.getMigratedVersion()
|
||||||
|
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
|
||||||
|
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
|
||||||
|
try {
|
||||||
|
if (await redis.needsGlobalStatsMigration()) {
|
||||||
|
await redis.migrateGlobalStats()
|
||||||
|
}
|
||||||
|
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
|
||||||
|
}
|
||||||
|
await redis.setMigratedVersion(currentVersion)
|
||||||
|
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📅 后台检查月份索引完整性(不阻塞启动)
|
||||||
|
redis.ensureMonthlyMonthsIndex().catch((err) => {
|
||||||
|
logger.error('📅 月份索引检查失败:', err.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
||||||
|
redis.migrateUsageIndex().catch((err) => {
|
||||||
|
logger.error('📊 Background usage index migration failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
|
||||||
|
await redis.migrateAlltimeModelStats()
|
||||||
|
|
||||||
|
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||||
|
try {
|
||||||
|
const accountBalanceService = require('./services/accountBalanceService')
|
||||||
|
const { registerAllProviders } = require('./services/balanceProviders')
|
||||||
|
registerAllProviders(accountBalanceService)
|
||||||
|
logger.info('✅ 账户余额查询服务已初始化')
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 💰 初始化价格服务
|
// 💰 初始化价格服务
|
||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
@@ -68,6 +110,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')
|
||||||
@@ -80,6 +126,15 @@ class Application {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||||
|
try {
|
||||||
|
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||||
|
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||||
|
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 🕐 初始化Claude账户会话窗口
|
// 🕐 初始化Claude账户会话窗口
|
||||||
logger.info('🕐 Initializing Claude account session windows...')
|
logger.info('🕐 Initializing Claude account session windows...')
|
||||||
const claudeAccountService = require('./services/claudeAccountService')
|
const claudeAccountService = require('./services/claudeAccountService')
|
||||||
@@ -90,6 +145,18 @@ class Application {
|
|||||||
const costRankService = require('./services/costRankService')
|
const costRankService = require('./services/costRankService')
|
||||||
await costRankService.initialize()
|
await costRankService.initialize()
|
||||||
|
|
||||||
|
// 🔍 初始化 API Key 索引服务(用于分页查询优化)
|
||||||
|
logger.info('🔍 Initializing API Key index service...')
|
||||||
|
const apiKeyIndexService = require('./services/apiKeyIndexService')
|
||||||
|
apiKeyIndexService.init(redis)
|
||||||
|
await apiKeyIndexService.checkAndRebuild()
|
||||||
|
|
||||||
|
// 📁 确保账户分组反向索引存在(后台执行,不阻塞启动)
|
||||||
|
const accountGroupService = require('./services/accountGroupService')
|
||||||
|
accountGroupService.ensureReverseIndexes().catch((err) => {
|
||||||
|
logger.error('📁 Account group reverse index migration failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||||
this.app.use((req, res, next) => {
|
this.app.use((req, res, next) => {
|
||||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||||
@@ -165,7 +232,7 @@ class Application {
|
|||||||
// 🔧 基础中间件
|
// 🔧 基础中间件
|
||||||
this.app.use(
|
this.app.use(
|
||||||
express.json({
|
express.json({
|
||||||
limit: '10mb',
|
limit: '100mb',
|
||||||
verify: (req, res, buf, encoding) => {
|
verify: (req, res, buf, encoding) => {
|
||||||
// 验证JSON格式
|
// 验证JSON格式
|
||||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||||
@@ -174,7 +241,7 @@ class Application {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
|
||||||
this.app.use(securityMiddleware)
|
this.app.use(securityMiddleware)
|
||||||
|
|
||||||
// 🎯 信任代理
|
// 🎯 信任代理
|
||||||
@@ -264,6 +331,25 @@ class Application {
|
|||||||
this.app.use('/api', apiRoutes)
|
this.app.use('/api', apiRoutes)
|
||||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||||
|
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
|
||||||
|
// - /antigravity/api/v1/messages -> Antigravity OAuth
|
||||||
|
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
|
||||||
|
this.app.use(
|
||||||
|
'/antigravity/api',
|
||||||
|
(req, res, next) => {
|
||||||
|
req._anthropicVendor = 'antigravity'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
apiRoutes
|
||||||
|
)
|
||||||
|
this.app.use(
|
||||||
|
'/gemini-cli/api',
|
||||||
|
(req, res, next) => {
|
||||||
|
req._anthropicVendor = 'gemini-cli'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
apiRoutes
|
||||||
|
)
|
||||||
this.app.use('/admin', adminRoutes)
|
this.app.use('/admin', adminRoutes)
|
||||||
this.app.use('/users', userRoutes)
|
this.app.use('/users', userRoutes)
|
||||||
// 使用 web 路由(包含 auth 和页面重定向)
|
// 使用 web 路由(包含 auth 和页面重定向)
|
||||||
@@ -344,7 +430,7 @@ class Application {
|
|||||||
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
|
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
error: error.message,
|
error: getSafeMessage(error),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -380,7 +466,7 @@ class Application {
|
|||||||
// 🚨 错误处理
|
// 🚨 错误处理
|
||||||
this.app.use(errorHandler)
|
this.app.use(errorHandler)
|
||||||
|
|
||||||
logger.success('✅ Application initialized successfully')
|
logger.success('Application initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('💥 Application initialization failed:', error)
|
logger.error('💥 Application initialization failed:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -415,7 +501,7 @@ class Application {
|
|||||||
|
|
||||||
await redis.setSession('admin_credentials', adminCredentials)
|
await redis.setSession('admin_credentials', adminCredentials)
|
||||||
|
|
||||||
logger.success('✅ Admin credentials loaded from init.json (single source of truth)')
|
logger.success('Admin credentials loaded from init.json (single source of truth)')
|
||||||
logger.info(`📋 Admin username: ${adminCredentials.username}`)
|
logger.info(`📋 Admin username: ${adminCredentials.username}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to initialize admin credentials:', {
|
logger.error('❌ Failed to initialize admin credentials:', {
|
||||||
@@ -426,6 +512,56 @@ class Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||||
|
async cleanupInvalidSessions() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
|
||||||
|
// 获取所有 session:* 键
|
||||||
|
const sessionKeys = await redis.scanKeys('session:*')
|
||||||
|
const dataList = await redis.batchHgetallChunked(sessionKeys)
|
||||||
|
|
||||||
|
let validCount = 0
|
||||||
|
let invalidCount = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionKeys.length; i++) {
|
||||||
|
const key = sessionKeys[i]
|
||||||
|
// 跳过 admin_credentials(系统凭据)
|
||||||
|
if (key === 'session:admin_credentials') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = dataList[i]
|
||||||
|
|
||||||
|
// 检查会话完整性:必须有 username 和 loginTime
|
||||||
|
const hasUsername = !!sessionData?.username
|
||||||
|
const hasLoginTime = !!sessionData?.loginTime
|
||||||
|
|
||||||
|
if (!hasUsername || !hasLoginTime) {
|
||||||
|
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||||
|
invalidCount++
|
||||||
|
logger.security(
|
||||||
|
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||||
|
)
|
||||||
|
await client.del(key)
|
||||||
|
} else {
|
||||||
|
validCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// 清理失败不应阻止服务启动
|
||||||
|
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔍 Redis健康检查
|
// 🔍 Redis健康检查
|
||||||
async checkRedisHealth() {
|
async checkRedisHealth() {
|
||||||
try {
|
try {
|
||||||
@@ -468,9 +604,7 @@ class Application {
|
|||||||
await this.initialize()
|
await this.initialize()
|
||||||
|
|
||||||
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
||||||
logger.start(
|
logger.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`)
|
||||||
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
|
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
|
||||||
)
|
)
|
||||||
@@ -525,7 +659,7 @@ class Application {
|
|||||||
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
|
||||||
logger.success('✅ Cache monitoring initialized')
|
logger.success('Cache monitoring initialized')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to initialize cache monitoring:', error)
|
logger.error('❌ Failed to initialize cache monitoring:', error)
|
||||||
// 不阻止应用启动
|
// 不阻止应用启动
|
||||||
@@ -574,22 +708,47 @@ class Application {
|
|||||||
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await redis.keys('concurrency:*')
|
const keys = await redis.scanKeys('concurrency:*')
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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 类型的键(这些键有各自的清理逻辑)
|
||||||
|
// - concurrency:queue:stats:* 是 Hash 类型
|
||||||
|
// - concurrency:queue:wait_times:* 是 List 类型
|
||||||
|
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||||
|
if (
|
||||||
|
key.startsWith('concurrency:queue:stats:') ||
|
||||||
|
key.startsWith('concurrency:queue:wait_times:') ||
|
||||||
|
(key.startsWith('concurrency:queue:') &&
|
||||||
|
!key.includes(':stats:') &&
|
||||||
|
!key.includes(':wait_times:'))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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)
|
||||||
|
|
||||||
@@ -608,8 +767,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)
|
||||||
@@ -619,12 +780,50 @@ 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)
|
||||||
}
|
}
|
||||||
}, 60000) // 每分钟执行一次
|
}, 60000) // 每分钟执行一次
|
||||||
|
|
||||||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||||||
|
|
||||||
|
// 📬 启动用户消息队列服务
|
||||||
|
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||||
|
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
|
||||||
|
userMessageQueueService.cleanupStaleLocks().then(() => {
|
||||||
|
// 然后启动定时清理任务
|
||||||
|
userMessageQueueService.startCleanupTask()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🚦 清理服务重启后残留的并发排队计数器
|
||||||
|
// 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数
|
||||||
|
// 可通过 DELETE /admin/concurrency/queue 接口手动清理
|
||||||
|
const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false'
|
||||||
|
if (clearQueuesOnStartup) {
|
||||||
|
redis.clearAllConcurrencyQueues().catch((error) => {
|
||||||
|
logger.error('❌ Error clearing concurrency queues on startup:', error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧪 启动账户定时测试调度器
|
||||||
|
// 根据配置定期测试账户连通性并保存测试历史
|
||||||
|
const accountTestSchedulerEnabled =
|
||||||
|
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
|
||||||
|
config.accountTestScheduler?.enabled !== false
|
||||||
|
if (accountTestSchedulerEnabled) {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.start()
|
||||||
|
logger.info('🧪 Account test scheduler service started')
|
||||||
|
} else {
|
||||||
|
logger.info('🧪 Account test scheduler service disabled')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGracefulShutdown() {
|
setupGracefulShutdown() {
|
||||||
@@ -661,6 +860,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止用户消息队列清理服务
|
||||||
|
try {
|
||||||
|
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||||
|
userMessageQueueService.stopCleanupTask()
|
||||||
|
logger.info('📬 User message queue service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping user message queue service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 停止费用排序索引服务
|
// 停止费用排序索引服务
|
||||||
try {
|
try {
|
||||||
const costRankService = require('./services/costRankService')
|
const costRankService = require('./services/costRankService')
|
||||||
@@ -670,12 +878,21 @@ 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...')
|
||||||
const keys = await redis.keys('concurrency:*')
|
const keys = await redis.scanKeys('concurrency:*')
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await redis.client.del(...keys)
|
await redis.batchDelChunked(keys)
|
||||||
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
||||||
} else {
|
} else {
|
||||||
logger.info('✅ No concurrency keys to clean')
|
logger.info('✅ No concurrency keys to clean')
|
||||||
@@ -692,7 +909,7 @@ class Application {
|
|||||||
logger.error('❌ Error disconnecting Redis:', error)
|
logger.error('❌ Error disconnecting Redis:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success('✅ Graceful shutdown completed')
|
logger.success('Graceful shutdown completed')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ const logger = require('../utils/logger')
|
|||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||||
|
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
const redis = require('../models/redis')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const { parseSSELine } = require('../utils/sseParser')
|
const { parseSSELine } = require('../utils/sseParser')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
|
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -86,8 +89,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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
|||||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||||
req.rateLimitInfo,
|
req.rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model
|
model,
|
||||||
|
req.apiKey?.id,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (totalTokens > 0) {
|
if (totalTokens > 0) {
|
||||||
@@ -353,7 +357,7 @@ async function handleMessages(req, res) {
|
|||||||
logger.error('Failed to select Gemini account:', error)
|
logger.error('Failed to select Gemini account:', error)
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'No available Gemini accounts',
|
message: getSafeMessage(error) || 'No available Gemini accounts',
|
||||||
type: 'service_unavailable'
|
type: 'service_unavailable'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -492,7 +496,8 @@ async function handleMessages(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,20 +513,37 @@ async function handleMessages(req, res) {
|
|||||||
// OAuth 账户:使用现有的 sendGeminiRequest
|
// OAuth 账户:使用现有的 sendGeminiRequest
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
geminiResponse = await sendGeminiRequest({
|
if (oauthProvider === 'antigravity') {
|
||||||
messages,
|
geminiResponse = await sendAntigravityRequest({
|
||||||
model,
|
messages,
|
||||||
temperature,
|
model,
|
||||||
maxTokens: max_tokens,
|
temperature,
|
||||||
stream,
|
maxTokens: max_tokens,
|
||||||
accessToken: account.accessToken,
|
stream,
|
||||||
proxy: account.proxy,
|
accessToken: account.accessToken,
|
||||||
apiKeyId: apiKeyData.id,
|
proxy: account.proxy,
|
||||||
signal: abortController.signal,
|
apiKeyId: apiKeyData.id,
|
||||||
projectId: effectiveProjectId,
|
signal: abortController.signal,
|
||||||
accountId: account.id
|
projectId: effectiveProjectId,
|
||||||
})
|
accountId: account.id
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
geminiResponse = await sendGeminiRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
maxTokens: max_tokens,
|
||||||
|
stream,
|
||||||
|
accessToken: account.accessToken,
|
||||||
|
proxy: account.proxy,
|
||||||
|
apiKeyId: apiKeyData.id,
|
||||||
|
signal: abortController.signal,
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
accountId: account.id
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
@@ -579,7 +601,8 @@ async function handleMessages(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -597,7 +620,7 @@ async function handleMessages(req, res) {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: getSafeMessage(error) || 'Stream error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -645,7 +668,7 @@ async function handleMessages(req, res) {
|
|||||||
const status = errorStatus || 500
|
const status = errorStatus || 500
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
error: error.error || {
|
error: error.error || {
|
||||||
message: error.message || 'Internal server error',
|
message: getSafeMessage(error) || 'Internal server error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,8 +777,16 @@ async function handleModels(req, res) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OAuth 账户:使用 OAuth token 获取模型列表
|
// OAuth 账户:根据 OAuth provider 选择上游
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
models =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
account.proxy,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
: await getAvailableModels(account.accessToken, account.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -805,16 +836,18 @@ function handleModelDetails(req, res) {
|
|||||||
*/
|
*/
|
||||||
async function handleUsage(req, res) {
|
async function handleUsage(req, res) {
|
||||||
try {
|
try {
|
||||||
const { usage } = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
object: 'usage',
|
object: 'usage',
|
||||||
total_tokens: usage.total.tokens,
|
total_tokens: usage?.total?.tokens || 0,
|
||||||
total_requests: usage.total.requests,
|
total_requests: usage?.total?.requests || 0,
|
||||||
daily_tokens: usage.daily.tokens,
|
daily_tokens: usage?.daily?.tokens || 0,
|
||||||
daily_requests: usage.daily.requests,
|
daily_requests: usage?.daily?.requests || 0,
|
||||||
monthly_tokens: usage.monthly.tokens,
|
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||||
monthly_requests: usage.monthly.requests
|
monthly_requests: usage?.monthly?.requests || 0
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get usage stats:', error)
|
logger.error('Failed to get usage stats:', error)
|
||||||
@@ -833,17 +866,18 @@ async function handleUsage(req, res) {
|
|||||||
async function handleKeyInfo(req, res) {
|
async function handleKeyInfo(req, res) {
|
||||||
try {
|
try {
|
||||||
const keyData = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
const tokensUsed = usage?.total?.tokens || 0
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: tokensUsed,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
keyData.tokenLimit > 0
|
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
|
||||||
: null,
|
|
||||||
rate_limit: {
|
rate_limit: {
|
||||||
window: keyData.rateLimitWindow,
|
window: keyData.rateLimitWindow,
|
||||||
requests: keyData.rateLimitRequests
|
requests: keyData.rateLimitRequests
|
||||||
@@ -927,7 +961,8 @@ function handleSimpleEndpoint(apiMethod) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 直接转发请求体,不做特殊处理
|
// 直接转发请求体,不做特殊处理
|
||||||
@@ -1006,7 +1041,12 @@ async function handleLoadCodeAssist(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1104,7 +1144,12 @@ async function handleOnboardUser(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1152,6 +1197,110 @@ async function handleOnboardUser(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 retrieveUserQuota 请求
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
*
|
||||||
|
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||||
|
* 请求体:{ "project": "项目ID" }
|
||||||
|
* 响应:{ "buckets": [...] }
|
||||||
|
*/
|
||||||
|
async function handleRetrieveUserQuota(req, res) {
|
||||||
|
try {
|
||||||
|
// 1. 权限检查
|
||||||
|
if (!ensureGeminiPermission(req, res)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 3. 账户选择
|
||||||
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
|
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
sessionHash,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
const { accountId, accountType } = schedulerResult
|
||||||
|
|
||||||
|
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||||
|
if (accountType === 'gemini-api') {
|
||||||
|
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||||
|
type: 'invalid_account_type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取账户
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Gemini account not found',
|
||||||
|
type: 'account_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { accessToken, refreshToken, projectId } = account
|
||||||
|
|
||||||
|
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||||
|
const requestProject = req.body.project
|
||||||
|
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||||
|
requestedProject: requestProject || null,
|
||||||
|
accountProject: projectId || null,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 解析账户的代理配置
|
||||||
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
|
// 8. 获取OAuth客户端
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||||
|
|
||||||
|
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||||
|
const effectiveProject = projectId || requestProject || null
|
||||||
|
|
||||||
|
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||||
|
accountProjectId: projectId,
|
||||||
|
requestProject,
|
||||||
|
effectiveProject,
|
||||||
|
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. 构建请求体(注入 effectiveProject)
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if (effectiveProject) {
|
||||||
|
requestBody.project = effectiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 调用底层服务转发请求
|
||||||
|
const response = await geminiAccountService.forwardToCodeAssist(
|
||||||
|
client,
|
||||||
|
'retrieveUserQuota',
|
||||||
|
requestBody,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 countTokens 请求
|
* 处理 countTokens 请求
|
||||||
*/
|
*/
|
||||||
@@ -1256,7 +1405,8 @@ async function handleCountTokens(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||||
}
|
}
|
||||||
@@ -1267,7 +1417,7 @@ async function handleCountTokens(req, res) {
|
|||||||
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
|
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Internal server error',
|
message: getSafeMessage(error) || 'Internal server error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1366,13 +1516,20 @@ async function handleGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1388,6 +1545,12 @@ async function handleGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1410,14 +1573,24 @@ async function handleGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
client,
|
oauthProvider === 'antigravity'
|
||||||
{ model, request: actualRequestData },
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
user_prompt_id,
|
client,
|
||||||
effectiveProjectId,
|
{ model, request: actualRequestData },
|
||||||
req.apiKey?.id,
|
user_prompt_id,
|
||||||
proxyConfig
|
effectiveProjectId,
|
||||||
)
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContent(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
user_prompt_id,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (response?.response?.usageMetadata) {
|
if (response?.response?.usageMetadata) {
|
||||||
@@ -1430,7 +1603,8 @@ async function handleGenerateContent(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
account.id
|
account.id,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||||
@@ -1466,7 +1640,7 @@ async function handleGenerateContent(req, res) {
|
|||||||
})
|
})
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Internal server error',
|
message: getSafeMessage(error) || 'Internal server error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1578,13 +1752,20 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1600,6 +1781,11 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1622,15 +1808,26 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
const streamResponse =
|
||||||
client,
|
oauthProvider === 'antigravity'
|
||||||
{ model, request: actualRequestData },
|
? await geminiAccountService.generateContentStreamAntigravity(
|
||||||
user_prompt_id,
|
client,
|
||||||
effectiveProjectId,
|
{ model, request: actualRequestData },
|
||||||
req.apiKey?.id,
|
user_prompt_id,
|
||||||
abortController.signal,
|
effectiveProjectId,
|
||||||
proxyConfig
|
req.apiKey?.id,
|
||||||
)
|
abortController.signal,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContentStream(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
user_prompt_id,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
abortController.signal,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
// 设置 SSE 响应头
|
// 设置 SSE 响应头
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
@@ -1727,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
account.id
|
account.id,
|
||||||
|
'gemini'
|
||||||
),
|
),
|
||||||
applyRateLimitTracking(
|
applyRateLimitTracking(
|
||||||
req,
|
req,
|
||||||
@@ -1764,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: getSafeMessage(error) || 'Stream error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1774,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: getSafeMessage(error) || 'Stream error',
|
||||||
type: 'stream_error',
|
type: 'stream_error',
|
||||||
code: error.code
|
code: error.code
|
||||||
}
|
}
|
||||||
@@ -1803,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Internal server error',
|
message: getSafeMessage(error) || 'Internal server error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1978,15 +2176,23 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
// OAuth 账户
|
// OAuth 账户
|
||||||
const { accessToken, refreshToken } = account
|
const { accessToken, refreshToken } = account
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
if (!effectiveProjectId) {
|
if (oauthProvider === 'antigravity') {
|
||||||
|
if (!effectiveProjectId) {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2024,14 +2230,25 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
const userPromptId = `${crypto.randomUUID()}########0`
|
||||||
|
|
||||||
response = await geminiAccountService.generateContent(
|
if (oauthProvider === 'antigravity') {
|
||||||
client,
|
response = await geminiAccountService.generateContentAntigravity(
|
||||||
{ model, request: actualRequestData },
|
client,
|
||||||
userPromptId,
|
{ model, request: actualRequestData },
|
||||||
effectiveProjectId,
|
userPromptId,
|
||||||
req.apiKey?.id,
|
effectiveProjectId,
|
||||||
proxyConfig
|
req.apiKey?.id,
|
||||||
)
|
proxyConfig
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
response = await geminiAccountService.generateContent(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
userPromptId,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
@@ -2045,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||||
@@ -2067,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Internal server error',
|
message: getSafeMessage(error) || 'Internal server error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2263,12 +2481,20 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
if (!effectiveProjectId) {
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
if (!effectiveProjectId) {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2306,15 +2532,27 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
const userPromptId = `${crypto.randomUUID()}########0`
|
||||||
|
|
||||||
streamResponse = await geminiAccountService.generateContentStream(
|
if (oauthProvider === 'antigravity') {
|
||||||
client,
|
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
|
||||||
{ model, request: actualRequestData },
|
client,
|
||||||
userPromptId,
|
{ model, request: actualRequestData },
|
||||||
effectiveProjectId,
|
userPromptId,
|
||||||
req.apiKey?.id,
|
effectiveProjectId,
|
||||||
abortController.signal,
|
req.apiKey?.id,
|
||||||
proxyConfig
|
abortController.signal,
|
||||||
)
|
proxyConfig
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
streamResponse = await geminiAccountService.generateContentStream(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
userPromptId,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
abortController.signal,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 SSE 响应头
|
// 设置 SSE 响应头
|
||||||
@@ -2454,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2482,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: getSafeMessage(error) || 'Stream error',
|
||||||
type: 'api_error'
|
type: 'api_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2492,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: getSafeMessage(error) || 'Stream error',
|
||||||
type: 'stream_error',
|
type: 'stream_error',
|
||||||
code: error.code
|
code: error.code
|
||||||
}
|
}
|
||||||
@@ -2576,6 +2815,7 @@ module.exports = {
|
|||||||
handleSimpleEndpoint,
|
handleSimpleEndpoint,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleGenerateContent,
|
handleGenerateContent,
|
||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2880
src/models/redis.js
2880
src/models/redis.js
File diff suppressed because it is too large
Load Diff
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal 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
|
||||||
@@ -8,6 +8,64 @@ 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(', ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 serviceRates 格式
|
||||||
|
* @param {any} serviceRates - 服务倍率对象
|
||||||
|
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||||
|
*/
|
||||||
|
function validateServiceRates(serviceRates) {
|
||||||
|
if (serviceRates === undefined || serviceRates === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) {
|
||||||
|
return 'Service rates must be an object'
|
||||||
|
}
|
||||||
|
for (const [service, rate] of Object.entries(serviceRates)) {
|
||||||
|
const numRate = Number(rate)
|
||||||
|
if (!Number.isFinite(numRate) || numRate < 0) {
|
||||||
|
return `Invalid rate for service "${service}": must be a non-negative number`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 👥 用户管理 (用于API Key分配)
|
// 👥 用户管理 (用于API Key分配)
|
||||||
|
|
||||||
// 获取所有用户列表(用于API Key分配)
|
// 获取所有用户列表(用于API Key分配)
|
||||||
@@ -79,14 +137,14 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
|||||||
const costStats = await redis.getCostStats(keyId)
|
const costStats = await redis.getCostStats(keyId)
|
||||||
const dailyCost = await redis.getDailyCost(keyId)
|
const dailyCost = await redis.getDailyCost(keyId)
|
||||||
const today = redis.getDateStringInTimezone()
|
const today = redis.getDateStringInTimezone()
|
||||||
const client = redis.getClientSafe()
|
|
||||||
|
|
||||||
// 获取所有相关的Redis键
|
// 获取所有相关的Redis键
|
||||||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`)
|
const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`)
|
||||||
|
const costValues = await redis.batchGetChunked(costKeys)
|
||||||
const keyValues = {}
|
const keyValues = {}
|
||||||
|
|
||||||
for (const key of costKeys) {
|
for (let i = 0; i < costKeys.length; i++) {
|
||||||
keyValues[key] = await client.get(key)
|
keyValues[costKeys[i]] = costValues[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -287,20 +345,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个API Key添加owner的displayName
|
// 为每个API Key添加owner的displayName(批量获取优化)
|
||||||
for (const apiKey of result.items) {
|
const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))]
|
||||||
if (apiKey.userId) {
|
const userMap = new Map()
|
||||||
try {
|
|
||||||
const user = await userService.getUserById(apiKey.userId, false)
|
if (userIdsToFetch.length > 0) {
|
||||||
if (user) {
|
// 批量获取用户信息
|
||||||
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
const users = await Promise.all(
|
||||||
} else {
|
userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null))
|
||||||
apiKey.ownerDisplayName = 'Unknown User'
|
)
|
||||||
}
|
userIdsToFetch.forEach((id, i) => {
|
||||||
} catch (error) {
|
if (users[i]) {
|
||||||
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
|
userMap.set(id, users[i])
|
||||||
apiKey.ownerDisplayName = 'Unknown User'
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const apiKey of result.items) {
|
||||||
|
if (apiKey.userId && userMap.has(apiKey.userId)) {
|
||||||
|
const user = userMap.get(apiKey.userId)
|
||||||
|
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||||||
|
} else if (apiKey.userId) {
|
||||||
|
apiKey.ownerDisplayName = 'Unknown User'
|
||||||
} else {
|
} else {
|
||||||
apiKey.ownerDisplayName =
|
apiKey.ownerDisplayName =
|
||||||
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
||||||
@@ -571,6 +637,56 @@ router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取 API Key 索引状态
|
||||||
|
router.get('/api-keys/index-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||||
|
const status = await apiKeyIndexService.getStatus()
|
||||||
|
return res.json({ success: true, data: status })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get API Key index status:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get index status',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重建 API Key 索引
|
||||||
|
router.post('/api-keys/index-rebuild', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||||
|
const status = await apiKeyIndexService.getStatus()
|
||||||
|
|
||||||
|
if (status.building) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'INDEX_BUILDING',
|
||||||
|
message: '索引正在重建中,请稍后再试',
|
||||||
|
progress: status.progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步重建,不等待完成
|
||||||
|
apiKeyIndexService.rebuildIndexes().catch((err) => {
|
||||||
|
logger.error('❌ Failed to rebuild API Key index:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'API Key 索引重建已开始'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to trigger API Key index rebuild:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to trigger rebuild',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 强制刷新费用排序索引
|
// 强制刷新费用排序索引
|
||||||
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
|
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -636,22 +752,7 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
|||||||
// 获取已存在的标签列表
|
// 获取已存在的标签列表
|
||||||
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const tags = await apiKeyService.getAllTags()
|
||||||
const tagSet = new Set()
|
|
||||||
|
|
||||||
// 收集所有API Keys的标签
|
|
||||||
for (const apiKey of apiKeys) {
|
|
||||||
if (apiKey.tags && Array.isArray(apiKey.tags)) {
|
|
||||||
apiKey.tags.forEach((tag) => {
|
|
||||||
if (tag && tag.trim()) {
|
|
||||||
tagSet.add(tag.trim())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为数组并排序
|
|
||||||
const tags = Array.from(tagSet).sort()
|
|
||||||
|
|
||||||
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
|
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
|
||||||
return res.json({ success: true, data: tags })
|
return res.json({ success: true, data: tags })
|
||||||
@@ -661,6 +762,93 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取标签详情(含使用数量)
|
||||||
|
router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tagDetails = await apiKeyService.getTagsWithCount()
|
||||||
|
logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`)
|
||||||
|
return res.json({ success: true, data: tagDetails })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get tag details:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get tag details', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新标签
|
||||||
|
router.post('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return res.status(400).json({ error: '标签名称不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.createTag(name.trim())
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ error: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🏷️ Created new tag: ${name}`)
|
||||||
|
return res.json({ success: true, message: '标签创建成功' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create tag:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to create tag', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除标签(从所有 API Key 中移除)
|
||||||
|
router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tagName } = req.params
|
||||||
|
if (!tagName) {
|
||||||
|
return res.status(400).json({ error: 'Tag name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedTagName = decodeURIComponent(tagName)
|
||||||
|
const result = await apiKeyService.removeTagFromAllKeys(decodedTagName)
|
||||||
|
|
||||||
|
logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`,
|
||||||
|
affectedCount: result.affectedCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete tag:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete tag', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重命名标签
|
||||||
|
router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tagName } = req.params
|
||||||
|
const { newName } = req.body
|
||||||
|
if (!tagName || !newName || !newName.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Tag name and new name are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedTagName = decodeURIComponent(tagName)
|
||||||
|
const trimmedNewName = newName.trim()
|
||||||
|
const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(400).json({ error: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`
|
||||||
|
)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Tag renamed in ${result.affectedCount} API keys`,
|
||||||
|
affectedCount: result.affectedCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to rename tag:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to rename tag', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取账户绑定的 API Key 数量统计
|
* 获取账户绑定的 API Key 数量统计
|
||||||
* GET /admin/accounts/binding-counts
|
* GET /admin/accounts/binding-counts
|
||||||
@@ -919,6 +1107,86 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
// 去重(避免日数据和月数据重复计算)
|
// 去重(避免日数据和月数据重复计算)
|
||||||
const uniqueKeys = [...new Set(allKeys)]
|
const uniqueKeys = [...new Set(allKeys)]
|
||||||
|
|
||||||
|
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||||||
|
let dailyCost = 0
|
||||||
|
let currentWindowCost = 0
|
||||||
|
let windowRemainingSeconds = null
|
||||||
|
let windowStartTime = null
|
||||||
|
let windowEndTime = null
|
||||||
|
let allTimeCost = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||||||
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
|
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||||
|
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||||||
|
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||||||
|
|
||||||
|
// 只在启用了每日费用限制时查询
|
||||||
|
if (dailyCostLimit > 0) {
|
||||||
|
dailyCost = await redis.getDailyCost(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了总费用限制时查询
|
||||||
|
if (totalCostLimit > 0) {
|
||||||
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
|
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost
|
||||||
|
// 因为 usage:*:model:daily:* 键有 30 天 TTL,旧数据已经过期
|
||||||
|
if (timeRange === 'all' && allTimeCost > 0) {
|
||||||
|
logger.debug(`📊 使用 allTimeCost 计算 timeRange='all': ${allTimeCost}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: 0, // 旧数据详情不可用
|
||||||
|
tokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: allTimeCost,
|
||||||
|
formattedCost: CostCalculator.formatCost(allTimeCost),
|
||||||
|
// 实时限制数据(始终返回,不受时间范围影响)
|
||||||
|
dailyCost,
|
||||||
|
currentWindowCost,
|
||||||
|
windowRemainingSeconds,
|
||||||
|
windowStartTime,
|
||||||
|
windowEndTime,
|
||||||
|
allTimeCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了窗口限制时查询窗口数据
|
||||||
|
if (rateLimitWindow > 0) {
|
||||||
|
const costCountKey = `rate_limit:cost:${keyId}`
|
||||||
|
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||||
|
|
||||||
|
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||||
|
|
||||||
|
// 获取窗口开始时间和计算剩余时间
|
||||||
|
const windowStart = await client.get(windowStartKey)
|
||||||
|
if (windowStart) {
|
||||||
|
const now = Date.now()
|
||||||
|
windowStartTime = parseInt(windowStart)
|
||||||
|
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||||
|
windowEndTime = windowStartTime + windowDuration
|
||||||
|
|
||||||
|
// 如果窗口还有效
|
||||||
|
if (now < windowEndTime) {
|
||||||
|
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||||
|
} else {
|
||||||
|
// 窗口已过期
|
||||||
|
windowRemainingSeconds = 0
|
||||||
|
currentWindowCost = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有使用数据,返回零值但包含窗口数据
|
||||||
if (uniqueKeys.length === 0) {
|
if (uniqueKeys.length === 0) {
|
||||||
return {
|
return {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -928,7 +1196,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
formattedCost: '$0.00'
|
formattedCost: '$0.00',
|
||||||
|
// 实时限制数据(始终返回,不受时间范围影响)
|
||||||
|
dailyCost,
|
||||||
|
currentWindowCost,
|
||||||
|
windowRemainingSeconds,
|
||||||
|
windowStartTime,
|
||||||
|
windowEndTime,
|
||||||
|
allTimeCost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,12 +1218,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
const modelStatsMap = new Map()
|
const modelStatsMap = new Map()
|
||||||
let totalRequests = 0
|
let totalRequests = 0
|
||||||
|
|
||||||
// 用于去重:只统计日数据,避免与月数据重复
|
// 用于去重:先统计月数据,避免与日数据重复
|
||||||
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
// 检查是否有日数据
|
|
||||||
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const [err, data] = results[i]
|
const [err, data] = results[i]
|
||||||
@@ -975,8 +1248,12 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有日数据,则跳过月数据以避免重复
|
// 跳过当前月的月数据
|
||||||
if (hasDailyData && isMonthly) {
|
if (isMonthly && key.includes(`:${currentMonth}`)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 跳过非当前月的日数据
|
||||||
|
if (!isMonthly && !key.includes(`:${currentMonth}-`)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,55 +1306,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
|
||||||
// 获取实时限制数据
|
|
||||||
let dailyCost = 0
|
|
||||||
let currentWindowCost = 0
|
|
||||||
let windowRemainingSeconds = null
|
|
||||||
let windowStartTime = null
|
|
||||||
let windowEndTime = null
|
|
||||||
let allTimeCost = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取当日费用
|
|
||||||
dailyCost = await redis.getDailyCost(keyId)
|
|
||||||
|
|
||||||
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
|
|
||||||
const totalCostKey = `usage:cost:total:${keyId}`
|
|
||||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
|
||||||
|
|
||||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
|
||||||
const apiKey = await redis.getApiKey(keyId)
|
|
||||||
// 显式转换为整数,与 apiStats.js 保持一致,避免字符串比较问题
|
|
||||||
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
|
||||||
|
|
||||||
if (rateLimitWindow > 0) {
|
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
|
||||||
|
|
||||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
|
||||||
|
|
||||||
// 获取窗口开始时间和计算剩余时间
|
|
||||||
const windowStart = await client.get(windowStartKey)
|
|
||||||
if (windowStart) {
|
|
||||||
const now = Date.now()
|
|
||||||
windowStartTime = parseInt(windowStart)
|
|
||||||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
|
||||||
windowEndTime = windowStartTime + windowDuration
|
|
||||||
|
|
||||||
// 如果窗口还有效
|
|
||||||
if (now < windowEndTime) {
|
|
||||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
|
||||||
} else {
|
|
||||||
// 窗口已过期
|
|
||||||
windowRemainingSeconds = 0
|
|
||||||
currentWindowCost = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -1221,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays, // 新增:激活后有效天数
|
activationDays, // 新增:激活后有效天数
|
||||||
activationUnit, // 新增:激活时间单位 (hours/days)
|
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||||
expirationMode, // 新增:过期模式
|
expirationMode, // 新增:过期模式
|
||||||
icon // 新增:图标
|
icon, // 新增:图标
|
||||||
|
serviceRates // API Key 级别服务倍率
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 输入验证
|
// 输入验证
|
||||||
@@ -1342,16 +1571,16 @@ 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({
|
const serviceRatesError = validateServiceRates(serviceRates)
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
if (serviceRatesError) {
|
||||||
})
|
return res.status(400).json({ error: serviceRatesError })
|
||||||
}
|
}
|
||||||
|
|
||||||
const newKey = await apiKeyService.generateApiKey({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
@@ -1381,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success(`🔑 Admin created new API key: ${name}`)
|
logger.success(`🔑 Admin created new API key: ${name}`)
|
||||||
@@ -1423,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 输入验证
|
// 输入验证
|
||||||
@@ -1441,15 +1672,16 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
.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'
|
const batchServiceRatesError = validateServiceRates(serviceRates)
|
||||||
})
|
if (batchServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: batchServiceRatesError })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成批量API Keys
|
// 生成批量API Keys
|
||||||
@@ -1486,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
})
|
})
|
||||||
|
|
||||||
// 保留原始 API Key 供返回
|
// 保留原始 API Key 供返回
|
||||||
@@ -1552,13 +1785,20 @@ 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'
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 验证服务倍率
|
||||||
|
if (updates.serviceRates !== undefined) {
|
||||||
|
const updateServiceRatesError = validateServiceRates(updates.serviceRates)
|
||||||
|
if (updateServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: updateServiceRatesError })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1629,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
if (updates.enabled !== undefined) {
|
if (updates.enabled !== undefined) {
|
||||||
finalUpdates.enabled = updates.enabled
|
finalUpdates.enabled = updates.enabled
|
||||||
}
|
}
|
||||||
|
if (updates.serviceRates !== undefined) {
|
||||||
|
finalUpdates.serviceRates = updates.serviceRates
|
||||||
|
}
|
||||||
|
|
||||||
// 处理账户绑定
|
// 处理账户绑定
|
||||||
if (updates.claudeAccountId !== undefined) {
|
if (updates.claudeAccountId !== undefined) {
|
||||||
@@ -1685,7 +1928,7 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
// 执行更新
|
// 执行更新
|
||||||
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
||||||
results.successCount++
|
results.successCount++
|
||||||
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
|
logger.success(`Batch edit: API key ${keyId} updated successfully`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failedCount++
|
results.failedCount++
|
||||||
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
||||||
@@ -1746,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
totalCostLimit,
|
totalCostLimit,
|
||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
ownerId // 新增:所有者ID字段
|
ownerId, // 新增:所有者ID字段
|
||||||
|
serviceRates // API Key 级别服务倍率
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 只允许更新指定字段
|
// 只允许更新指定字段
|
||||||
@@ -1833,11 +2077,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
|
||||||
}
|
}
|
||||||
@@ -1933,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.tags = tags
|
updates.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理服务倍率
|
||||||
|
if (serviceRates !== undefined) {
|
||||||
|
const singleServiceRatesError = validateServiceRates(serviceRates)
|
||||||
|
if (singleServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: singleServiceRatesError })
|
||||||
|
}
|
||||||
|
updates.serviceRates = serviceRates
|
||||||
|
}
|
||||||
|
|
||||||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
if (typeof isActive !== 'boolean') {
|
if (typeof isActive !== 'boolean') {
|
||||||
@@ -2136,7 +2388,7 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
await apiKeyService.deleteApiKey(keyId)
|
await apiKeyService.deleteApiKey(keyId)
|
||||||
results.successCount++
|
results.successCount++
|
||||||
|
|
||||||
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
|
logger.success(`Batch delete: API key ${keyId} deleted successfully`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failedCount++
|
results.failedCount++
|
||||||
results.errors.push({
|
results.errors.push({
|
||||||
@@ -2191,13 +2443,13 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
// 📋 获取已删除的API Keys
|
// 📋 获取已删除的API Keys
|
||||||
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted
|
const deletedApiKeys = await apiKeyService.getAllApiKeysFast(true) // Include deleted
|
||||||
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true')
|
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === true)
|
||||||
|
|
||||||
// Add additional metadata for deleted keys
|
// Add additional metadata for deleted keys
|
||||||
const enrichedKeys = onlyDeleted.map((key) => ({
|
const enrichedKeys = onlyDeleted.map((key) => ({
|
||||||
...key,
|
...key,
|
||||||
isDeleted: key.isDeleted === 'true',
|
isDeleted: key.isDeleted === true,
|
||||||
deletedAt: key.deletedAt,
|
deletedAt: key.deletedAt,
|
||||||
deletedBy: key.deletedBy,
|
deletedBy: key.deletedBy,
|
||||||
deletedByType: key.deletedByType,
|
deletedByType: key.deletedByType,
|
||||||
@@ -2224,7 +2476,7 @@ router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
|
|||||||
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
|
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`)
|
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'API Key 已成功恢复',
|
message: 'API Key 已成功恢复',
|
||||||
|
|||||||
@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Azure OpenAI 账户连通性
|
||||||
|
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的 API Key
|
||||||
|
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||||
|
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||||
|
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||||
|
const payload = createOpenAITestPayload(deploymentName)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'api-key': apiKey
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.choices?.[0]?.message?.content) {
|
||||||
|
responseText = response.data.choices[0].message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model: deploymentName,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal 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
|
||||||
@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
region,
|
region,
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority,
|
priority,
|
||||||
accountType,
|
accountType,
|
||||||
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description: description || '',
|
description: description || '',
|
||||||
region: region || 'us-east-1',
|
region: region || 'us-east-1',
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority: priority || 50,
|
priority: priority || 50,
|
||||||
accountType: accountType || 'shared',
|
accountType: accountType || 'shared',
|
||||||
credentialType: credentialType || 'default'
|
credentialType: credentialType || 'access_key'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
|||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (
|
if (
|
||||||
mappedUpdates.credentialType &&
|
mappedUpdates.credentialType &&
|
||||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 测试Bedrock账户连接
|
// 测试Bedrock账户连接(SSE 流式)
|
||||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
|
|
||||||
const result = await bedrockAccountService.testAccount(accountId)
|
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
|
||||||
return res.json({ success: true, data: result.data })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to test Bedrock account:', error)
|
logger.error('❌ Failed to test Bedrock account:', error)
|
||||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
// 错误已在服务层处理,这里仅做日志记录
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
|||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
await ccrAccountService.resetDailyUsage(accountId)
|
await ccrAccountService.resetDailyUsage(accountId)
|
||||||
|
|
||||||
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||||
@@ -390,7 +390,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
const result = await ccrAccountService.resetAccountStatus(accountId)
|
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||||
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
logger.success(`Admin reset status for CCR account: ${accountId}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset CCR account status:', error)
|
logger.error('❌ Failed to reset CCR account status:', error)
|
||||||
@@ -403,7 +403,7 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await ccrAccountService.resetAllDailyUsage()
|
await ccrAccountService.resetAllDailyUsage()
|
||||||
|
|
||||||
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
logger.success('Admin manually reset daily usage for all CCR accounts')
|
||||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||||
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 CCR 账户连通性
|
||||||
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的凭据
|
||||||
|
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
|
||||||
|
const apiUrl = `${baseUrl}/v1/messages`
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': credentials.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.content?.[0]?.text) {
|
||||||
|
responseText = response.data.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -35,7 +36,7 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req,
|
|||||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success('🔗 Generated OAuth authorization URL with proxy support')
|
logger.success('Generated OAuth authorization URL with proxy support')
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -151,7 +152,7 @@ router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, asyn
|
|||||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
|
logger.success('Generated Setup Token authorization URL with proxy support')
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -277,7 +278,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
@@ -781,7 +786,7 @@ router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, asy
|
|||||||
|
|
||||||
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
||||||
|
|
||||||
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
|
logger.success(`Updated profile for Claude account: ${accountId}`)
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Account profile updated successfully',
|
message: 'Account profile updated successfully',
|
||||||
@@ -800,7 +805,7 @@ router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (re
|
|||||||
try {
|
try {
|
||||||
const result = await claudeAccountService.updateAllAccountProfiles()
|
const result = await claudeAccountService.updateAllAccountProfiles()
|
||||||
|
|
||||||
logger.success('✅ Batch profile update completed')
|
logger.success('Batch profile update completed')
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Batch profile update completed',
|
message: 'Batch profile update completed',
|
||||||
@@ -836,7 +841,7 @@ router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async
|
|||||||
|
|
||||||
const result = await claudeAccountService.resetAccountStatus(accountId)
|
const result = await claudeAccountService.resetAccountStatus(accountId)
|
||||||
|
|
||||||
logger.success(`✅ Admin reset status for Claude account: ${accountId}`)
|
logger.success(`Admin reset status for Claude account: ${accountId}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset Claude account status:', error)
|
logger.error('❌ Failed to reset Claude account status:', error)
|
||||||
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 账户定时测试相关端点
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 获取账户测试历史
|
||||||
|
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await redis.getAccountTestHistory(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
history
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取账户定时测试配置
|
||||||
|
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: testConfig || {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置账户定时测试配置
|
||||||
|
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { enabled, cronExpression, model } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证 enabled 参数
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'enabled must be a boolean'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 cronExpression 参数
|
||||||
|
if (!cronExpression || typeof cronExpression !== 'string') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'cronExpression is required and must be a string'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 cronExpression 长度防止 DoS
|
||||||
|
const MAX_CRON_LENGTH = 100
|
||||||
|
if (cronExpression.length > MAX_CRON_LENGTH) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 service 的方法验证 cron 表达式
|
||||||
|
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证模型参数
|
||||||
|
const testModel = model || 'claude-sonnet-4-5-20250929'
|
||||||
|
if (typeof testModel !== 'string' || testModel.length > 256) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'model must be a valid string (max 256 characters)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
await redis.saveAccountTestConfig(accountId, 'claude', {
|
||||||
|
enabled,
|
||||||
|
cronExpression,
|
||||||
|
model: testModel
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Test config updated successfully',
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: { enabled, cronExpression, model: testModel }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动触发账户测试(非流式,返回JSON结果)
|
||||||
|
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
|
||||||
|
|
||||||
|
// 保存测试结果到历史
|
||||||
|
await redis.saveAccountTestResult(accountId, 'claude', testResult)
|
||||||
|
await redis.setAccountLastTestTime(accountId, 'claude')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
result: testResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to run test',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量获取多个账户的测试历史
|
||||||
|
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountIds } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(accountIds) || accountIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'accountIds must be a non-empty array'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制批量查询数量
|
||||||
|
const limitedIds = accountIds.slice(0, 100)
|
||||||
|
|
||||||
|
const accounts = limitedIds.map((accountId) => ({
|
||||||
|
accountId,
|
||||||
|
platform: 'claude'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const historyMap = await redis.getAccountsTestHistory(accounts)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: historyMap
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get batch test history:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get batch test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -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 平台分组)
|
||||||
@@ -439,7 +441,7 @@ router.post(
|
|||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
||||||
|
|
||||||
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
||||||
@@ -456,7 +458,7 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||||
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
logger.success(`Admin reset status for Claude Console account: ${accountId}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset Claude Console account status:', error)
|
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||||
@@ -470,7 +472,7 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
|
|||||||
try {
|
try {
|
||||||
await claudeConsoleAccountService.resetAllDailyUsage()
|
await claudeConsoleAccountService.resetAllDailyUsage()
|
||||||
|
|
||||||
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
|
logger.success('Admin manually reset daily usage for all Claude Console accounts')
|
||||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
||||||
|
|||||||
239
src/routes/admin/claudeRelayConfig.js
Normal file
239
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置 API 路由
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config
|
||||||
|
* 获取 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await claudeRelayConfigService.getConfig()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /admin/claude-relay-config
|
||||||
|
* 更新 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays,
|
||||||
|
userMessageQueueEnabled,
|
||||||
|
userMessageQueueDelayMs,
|
||||||
|
userMessageQueueTimeoutMs,
|
||||||
|
concurrentRequestQueueEnabled,
|
||||||
|
concurrentRequestQueueMaxSize,
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier,
|
||||||
|
concurrentRequestQueueTimeoutMs
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalSessionBindingEnabled !== undefined &&
|
||||||
|
typeof globalSessionBindingEnabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage.length > 500) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof sessionBindingTtlDays !== 'number' ||
|
||||||
|
sessionBindingTtlDays < 1 ||
|
||||||
|
sessionBindingTtlDays > 365
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户消息队列配置
|
||||||
|
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessageQueueDelayMs !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof userMessageQueueDelayMs !== 'number' ||
|
||||||
|
userMessageQueueDelayMs < 0 ||
|
||||||
|
userMessageQueueDelayMs > 10000
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessageQueueTimeoutMs !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof userMessageQueueTimeoutMs !== 'number' ||
|
||||||
|
userMessageQueueTimeoutMs < 1000 ||
|
||||||
|
userMessageQueueTimeoutMs > 300000
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证并发请求排队配置
|
||||||
|
if (
|
||||||
|
concurrentRequestQueueEnabled !== undefined &&
|
||||||
|
typeof concurrentRequestQueueEnabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (concurrentRequestQueueMaxSize !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof concurrentRequestQueueMaxSize !== 'number' ||
|
||||||
|
!Number.isInteger(concurrentRequestQueueMaxSize) ||
|
||||||
|
concurrentRequestQueueMaxSize < 1 ||
|
||||||
|
concurrentRequestQueueMaxSize > 100
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
|
||||||
|
// 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型
|
||||||
|
if (
|
||||||
|
!Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) ||
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier < 0 ||
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier > 10
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (concurrentRequestQueueTimeoutMs !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof concurrentRequestQueueTimeoutMs !== 'number' ||
|
||||||
|
!Number.isInteger(concurrentRequestQueueTimeoutMs) ||
|
||||||
|
concurrentRequestQueueTimeoutMs < 5000 ||
|
||||||
|
concurrentRequestQueueTimeoutMs > 300000
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {}
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined) {
|
||||||
|
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||||
|
}
|
||||||
|
if (globalSessionBindingEnabled !== undefined) {
|
||||||
|
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||||
|
}
|
||||||
|
if (userMessageQueueEnabled !== undefined) {
|
||||||
|
updateData.userMessageQueueEnabled = userMessageQueueEnabled
|
||||||
|
}
|
||||||
|
if (userMessageQueueDelayMs !== undefined) {
|
||||||
|
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
|
||||||
|
}
|
||||||
|
if (userMessageQueueTimeoutMs !== undefined) {
|
||||||
|
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
|
||||||
|
}
|
||||||
|
if (concurrentRequestQueueEnabled !== undefined) {
|
||||||
|
updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled
|
||||||
|
}
|
||||||
|
if (concurrentRequestQueueMaxSize !== undefined) {
|
||||||
|
updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize
|
||||||
|
}
|
||||||
|
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
|
||||||
|
updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier
|
||||||
|
}
|
||||||
|
if (concurrentRequestQueueTimeoutMs !== undefined) {
|
||||||
|
updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedConfig = await claudeRelayConfigService.updateConfig(
|
||||||
|
updateData,
|
||||||
|
req.admin?.username || 'unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration updated successfully',
|
||||||
|
config: updatedConfig
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config/session-bindings
|
||||||
|
* 获取会话绑定统计
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get session binding statistics',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -7,26 +7,40 @@ const express = require('express')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /admin/concurrency
|
* GET /admin/concurrency
|
||||||
* 获取所有并发状态
|
* 获取所有并发状态
|
||||||
*/
|
*/
|
||||||
router.get('/concurrency', async (req, res) => {
|
router.get('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const status = await redis.getAllConcurrencyStatus()
|
const status = await redis.getAllConcurrencyStatus()
|
||||||
|
|
||||||
|
// 为每个 API Key 获取排队计数
|
||||||
|
const statusWithQueue = await Promise.all(
|
||||||
|
status.map(async (s) => {
|
||||||
|
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
queueCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// 计算汇总统计
|
// 计算汇总统计
|
||||||
const summary = {
|
const summary = {
|
||||||
totalKeys: status.length,
|
totalKeys: statusWithQueue.length,
|
||||||
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
|
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
|
||||||
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
|
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
|
||||||
|
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
summary,
|
summary,
|
||||||
concurrencyStatus: status
|
concurrencyStatus: statusWithQueue
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get concurrency status:', error)
|
logger.error('❌ Failed to get concurrency status:', error)
|
||||||
@@ -39,17 +53,171 @@ router.get('/concurrency', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /admin/concurrency/:apiKeyId
|
* GET /admin/concurrency-queue/stats
|
||||||
* 获取特定 API Key 的并发状态详情
|
* 获取排队统计信息
|
||||||
*/
|
*/
|
||||||
router.get('/concurrency/:apiKeyId', async (req, res) => {
|
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.params
|
// 获取所有有统计数据的 API Key
|
||||||
const status = await redis.getConcurrencyStatus(apiKeyId)
|
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
|
||||||
|
const queueKeys = await redis.scanConcurrencyQueueKeys()
|
||||||
|
|
||||||
|
// 合并所有相关的 API Key
|
||||||
|
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
|
||||||
|
|
||||||
|
// 获取各 API Key 的详细统计
|
||||||
|
const perKeyStats = await Promise.all(
|
||||||
|
allApiKeyIds.map(async (apiKeyId) => {
|
||||||
|
const [queueCount, stats, waitTimes] = await Promise.all([
|
||||||
|
redis.getConcurrencyQueueCount(apiKeyId),
|
||||||
|
redis.getConcurrencyQueueStats(apiKeyId),
|
||||||
|
redis.getQueueWaitTimes(apiKeyId)
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
currentQueueCount: queueCount,
|
||||||
|
stats,
|
||||||
|
waitTimeStats: calculateWaitTimeStats(waitTimes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取全局等待时间统计
|
||||||
|
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
|
||||||
|
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
|
||||||
|
|
||||||
|
// 计算全局汇总
|
||||||
|
const globalStats = {
|
||||||
|
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
|
||||||
|
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
|
||||||
|
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
|
||||||
|
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
|
||||||
|
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
|
||||||
|
totalRejectedOverload: perKeyStats.reduce(
|
||||||
|
(sum, s) => sum + (s.stats.rejected_overload || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
|
||||||
|
// 队列资源利用率指标
|
||||||
|
peakQueueSize:
|
||||||
|
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
|
||||||
|
avgQueueSize:
|
||||||
|
perKeyStats.length > 0
|
||||||
|
? Math.round(
|
||||||
|
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算成功率
|
||||||
|
if (globalStats.totalEntered > 0) {
|
||||||
|
globalStats.successRate = Math.round(
|
||||||
|
(globalStats.totalSuccess / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
globalStats.timeoutRate = Math.round(
|
||||||
|
(globalStats.totalTimeout / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
globalStats.cancelledRate = Math.round(
|
||||||
|
(globalStats.totalCancelled / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从全局等待时间统计中提取关键指标
|
||||||
|
if (globalWaitTimeStats) {
|
||||||
|
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
|
||||||
|
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
|
||||||
|
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
|
||||||
|
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
|
||||||
|
// 多实例采样策略标记(详见 design.md Decision 9)
|
||||||
|
// 全局 P90 仅用于可视化和监控,不用于系统决策
|
||||||
|
// 健康检查使用 API Key 级别的 P90(每 Key 独立采样)
|
||||||
|
globalWaitTimeStats.globalP90ForVisualizationOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
concurrencyStatus: status
|
globalStats,
|
||||||
|
globalWaitTimeStats,
|
||||||
|
perKeyStats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get queue stats:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get queue stats',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency-queue/:apiKeyId
|
||||||
|
* 清理特定 API Key 的排队计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
await redis.clearConcurrencyQueue(apiKeyId)
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully cleared queue for API key ${apiKeyId}`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear queue',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency-queue
|
||||||
|
* 清理所有排队计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cleared = await redis.clearAllConcurrencyQueues()
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully cleared all queues',
|
||||||
|
cleared
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to clear all queues:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear all queues',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency/:apiKeyId
|
||||||
|
* 获取特定 API Key 的并发状态详情
|
||||||
|
*/
|
||||||
|
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
const status = await redis.getConcurrencyStatus(apiKeyId)
|
||||||
|
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
concurrencyStatus: {
|
||||||
|
...status,
|
||||||
|
queueCount
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
|
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
|
||||||
@@ -65,7 +233,7 @@ router.get('/concurrency/:apiKeyId', async (req, res) => {
|
|||||||
* DELETE /admin/concurrency/:apiKeyId
|
* DELETE /admin/concurrency/:apiKeyId
|
||||||
* 强制清理特定 API Key 的并发计数
|
* 强制清理特定 API Key 的并发计数
|
||||||
*/
|
*/
|
||||||
router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.params
|
const { apiKeyId } = req.params
|
||||||
const result = await redis.forceClearConcurrency(apiKeyId)
|
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||||
@@ -93,7 +261,7 @@ router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
|||||||
* DELETE /admin/concurrency
|
* DELETE /admin/concurrency
|
||||||
* 强制清理所有并发计数
|
* 强制清理所有并发计数
|
||||||
*/
|
*/
|
||||||
router.delete('/concurrency', async (req, res) => {
|
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await redis.forceClearAllConcurrency()
|
const result = await redis.forceClearAllConcurrency()
|
||||||
|
|
||||||
@@ -118,7 +286,7 @@ router.delete('/concurrency', async (req, res) => {
|
|||||||
* POST /admin/concurrency/cleanup
|
* POST /admin/concurrency/cleanup
|
||||||
* 清理过期的并发条目(不影响活跃请求)
|
* 清理过期的并发条目(不影响活跃请求)
|
||||||
*/
|
*/
|
||||||
router.post('/concurrency/cleanup', async (req, res) => {
|
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.body
|
const { apiKeyId } = req.body
|
||||||
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
|
|||||||
const ccrAccountService = require('../../services/ccrAccountService')
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
const geminiAccountService = require('../../services/geminiAccountService')
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
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')
|
||||||
const CostCalculator = require('../../utils/costCalculator')
|
const CostCalculator = require('../../utils/costCalculator')
|
||||||
const pricingService = require('../../services/pricingService')
|
|
||||||
const config = require('../../../config/config')
|
const config = require('../../../config/config')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
@@ -22,9 +20,14 @@ const router = express.Router()
|
|||||||
// 获取系统概览
|
// 获取系统概览
|
||||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// 先检查是否有全局预聚合数据
|
||||||
|
const globalStats = await redis.getGlobalStats()
|
||||||
|
|
||||||
|
// 根据是否有全局统计决定查询策略
|
||||||
|
let apiKeys = null
|
||||||
|
let apiKeyCount = null
|
||||||
|
|
||||||
const [
|
const [
|
||||||
,
|
|
||||||
apiKeys,
|
|
||||||
claudeAccounts,
|
claudeAccounts,
|
||||||
claudeConsoleAccounts,
|
claudeConsoleAccounts,
|
||||||
geminiAccounts,
|
geminiAccounts,
|
||||||
@@ -37,8 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
systemAverages,
|
systemAverages,
|
||||||
realtimeMetrics
|
realtimeMetrics
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
redis.getSystemStats(),
|
|
||||||
apiKeyService.getAllApiKeys(),
|
|
||||||
claudeAccountService.getAllAccounts(),
|
claudeAccountService.getAllAccounts(),
|
||||||
claudeConsoleAccountService.getAllAccounts(),
|
claudeConsoleAccountService.getAllAccounts(),
|
||||||
geminiAccountService.getAllAccounts(),
|
geminiAccountService.getAllAccounts(),
|
||||||
@@ -52,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
redis.getRealtimeSystemMetrics()
|
redis.getRealtimeSystemMetrics()
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 有全局统计时只获取计数,否则拉全量
|
||||||
|
if (globalStats) {
|
||||||
|
apiKeyCount = await redis.getApiKeyCount()
|
||||||
|
} else {
|
||||||
|
apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
}
|
||||||
|
|
||||||
// 处理Bedrock账户数据
|
// 处理Bedrock账户数据
|
||||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||||
@@ -68,250 +76,118 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalDroidAccounts = droidAccounts.filter(
|
// 通用账户统计函数 - 单次遍历完成所有统计
|
||||||
(acc) =>
|
const countAccountStats = (accounts, opts = {}) => {
|
||||||
normalizeBoolean(acc.isActive) &&
|
const { isStringType = false, checkGeminiRateLimit = false } = opts
|
||||||
acc.status !== 'blocked' &&
|
let normal = 0,
|
||||||
acc.status !== 'unauthorized' &&
|
abnormal = 0,
|
||||||
normalizeBoolean(acc.schedulable) &&
|
paused = 0,
|
||||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
rateLimited = 0
|
||||||
).length
|
|
||||||
const abnormalDroidAccounts = droidAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedDroidAccounts = droidAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
!normalizeBoolean(acc.schedulable) &&
|
|
||||||
normalizeBoolean(acc.isActive) &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
|
||||||
isRateLimitedFlag(acc.rateLimitStatus)
|
|
||||||
).length
|
|
||||||
|
|
||||||
// 计算使用统计(统一使用allTokens)
|
for (const acc of accounts) {
|
||||||
const totalTokensUsed = apiKeys.reduce(
|
const isActive = isStringType
|
||||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
? acc.isActive === 'true' ||
|
||||||
0
|
acc.isActive === true ||
|
||||||
)
|
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
|
||||||
const totalRequestsUsed = apiKeys.reduce(
|
: acc.isActive
|
||||||
(sum, key) => sum + (key.usage?.total?.requests || 0),
|
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
0
|
const isSchedulable = isStringType
|
||||||
)
|
? acc.schedulable !== 'false' && acc.schedulable !== false
|
||||||
const totalInputTokensUsed = apiKeys.reduce(
|
: acc.schedulable !== false
|
||||||
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
|
const isRateLimited = checkGeminiRateLimit
|
||||||
0
|
? acc.rateLimitStatus === 'limited' ||
|
||||||
)
|
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
const totalOutputTokensUsed = apiKeys.reduce(
|
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const totalCacheCreateTokensUsed = apiKeys.reduce(
|
|
||||||
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const totalCacheReadTokensUsed = apiKeys.reduce(
|
|
||||||
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const totalAllTokensUsed = apiKeys.reduce(
|
|
||||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
|
if (!isActive || isBlocked) {
|
||||||
|
abnormal++
|
||||||
|
} else if (!isSchedulable) {
|
||||||
|
paused++
|
||||||
|
} else if (isRateLimited) {
|
||||||
|
rateLimited++
|
||||||
|
} else {
|
||||||
|
normal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { normal, abnormal, paused, rateLimited }
|
||||||
|
}
|
||||||
|
|
||||||
// Claude账户统计 - 根据账户管理页面的判断逻辑
|
// Droid 账户统计(特殊逻辑)
|
||||||
const normalClaudeAccounts = claudeAccounts.filter(
|
let normalDroidAccounts = 0,
|
||||||
(acc) =>
|
abnormalDroidAccounts = 0,
|
||||||
acc.isActive &&
|
pausedDroidAccounts = 0,
|
||||||
acc.status !== 'blocked' &&
|
rateLimitedDroidAccounts = 0
|
||||||
acc.status !== 'unauthorized' &&
|
for (const acc of droidAccounts) {
|
||||||
acc.schedulable !== false &&
|
const isActive = normalizeBoolean(acc.isActive)
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
).length
|
const isSchedulable = normalizeBoolean(acc.schedulable)
|
||||||
const abnormalClaudeAccounts = claudeAccounts.filter(
|
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
|
||||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedClaudeAccounts = claudeAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.schedulable === false &&
|
|
||||||
acc.isActive &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedClaudeAccounts = claudeAccounts.filter(
|
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
|
||||||
).length
|
|
||||||
|
|
||||||
// Claude Console账户统计
|
if (!isActive || isBlocked) {
|
||||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
abnormalDroidAccounts++
|
||||||
(acc) =>
|
} else if (!isSchedulable) {
|
||||||
acc.isActive &&
|
pausedDroidAccounts++
|
||||||
acc.status !== 'blocked' &&
|
} else if (isRateLimited) {
|
||||||
acc.status !== 'unauthorized' &&
|
rateLimitedDroidAccounts++
|
||||||
acc.schedulable !== false &&
|
} else {
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
normalDroidAccounts++
|
||||||
).length
|
}
|
||||||
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
}
|
||||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.schedulable === false &&
|
|
||||||
acc.isActive &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
|
||||||
).length
|
|
||||||
|
|
||||||
// Gemini账户统计
|
// 计算使用统计
|
||||||
const normalGeminiAccounts = geminiAccounts.filter(
|
let totalTokensUsed = 0,
|
||||||
(acc) =>
|
totalRequestsUsed = 0,
|
||||||
acc.isActive &&
|
totalInputTokensUsed = 0,
|
||||||
acc.status !== 'blocked' &&
|
totalOutputTokensUsed = 0,
|
||||||
acc.status !== 'unauthorized' &&
|
totalCacheCreateTokensUsed = 0,
|
||||||
acc.schedulable !== false &&
|
totalCacheReadTokensUsed = 0,
|
||||||
!(
|
totalAllTokensUsed = 0,
|
||||||
acc.rateLimitStatus === 'limited' ||
|
activeApiKeys = 0,
|
||||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
totalApiKeys = 0
|
||||||
)
|
|
||||||
).length
|
|
||||||
const abnormalGeminiAccounts = geminiAccounts.filter(
|
|
||||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedGeminiAccounts = geminiAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.schedulable === false &&
|
|
||||||
acc.isActive &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedGeminiAccounts = geminiAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.rateLimitStatus === 'limited' ||
|
|
||||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
|
||||||
).length
|
|
||||||
|
|
||||||
// Bedrock账户统计
|
if (globalStats) {
|
||||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
// 使用预聚合数据(快速路径)
|
||||||
(acc) =>
|
totalRequestsUsed = globalStats.requests
|
||||||
acc.isActive &&
|
totalInputTokensUsed = globalStats.inputTokens
|
||||||
acc.status !== 'blocked' &&
|
totalOutputTokensUsed = globalStats.outputTokens
|
||||||
acc.status !== 'unauthorized' &&
|
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
|
||||||
acc.schedulable !== false &&
|
totalCacheReadTokensUsed = globalStats.cacheReadTokens
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
totalAllTokensUsed = globalStats.allTokens
|
||||||
).length
|
totalTokensUsed = totalAllTokensUsed
|
||||||
const abnormalBedrockAccounts = bedrockAccounts.filter(
|
totalApiKeys = apiKeyCount.total
|
||||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
activeApiKeys = apiKeyCount.active
|
||||||
).length
|
} else {
|
||||||
const pausedBedrockAccounts = bedrockAccounts.filter(
|
// 回退到遍历(兼容旧数据)
|
||||||
(acc) =>
|
totalApiKeys = apiKeys.length
|
||||||
acc.schedulable === false &&
|
for (const key of apiKeys) {
|
||||||
acc.isActive &&
|
const usage = key.usage?.total
|
||||||
acc.status !== 'blocked' &&
|
if (usage) {
|
||||||
acc.status !== 'unauthorized'
|
totalTokensUsed += usage.allTokens || 0
|
||||||
).length
|
totalRequestsUsed += usage.requests || 0
|
||||||
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
|
totalInputTokensUsed += usage.inputTokens || 0
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
totalOutputTokensUsed += usage.outputTokens || 0
|
||||||
).length
|
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||||
|
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||||
|
totalAllTokensUsed += usage.allTokens || 0
|
||||||
|
}
|
||||||
|
if (key.isActive) {
|
||||||
|
activeApiKeys++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI账户统计
|
// 各平台账户统计(单次遍历)
|
||||||
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
const claudeStats = countAccountStats(claudeAccounts)
|
||||||
const normalOpenAIAccounts = openaiAccounts.filter(
|
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts)
|
||||||
(acc) =>
|
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true })
|
||||||
(acc.isActive === 'true' ||
|
const bedrockStats = countAccountStats(bedrockAccounts)
|
||||||
acc.isActive === true ||
|
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true })
|
||||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
const ccrStats = countAccountStats(ccrAccounts)
|
||||||
acc.status !== 'blocked' &&
|
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true })
|
||||||
acc.status !== 'unauthorized' &&
|
|
||||||
acc.schedulable !== 'false' &&
|
|
||||||
acc.schedulable !== false && // 包括'true'、true和undefined
|
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
|
||||||
).length
|
|
||||||
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.isActive === 'false' ||
|
|
||||||
acc.isActive === false ||
|
|
||||||
acc.status === 'blocked' ||
|
|
||||||
acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedOpenAIAccounts = openaiAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
|
||||||
(acc.isActive === 'true' ||
|
|
||||||
acc.isActive === true ||
|
|
||||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
|
||||||
).length
|
|
||||||
|
|
||||||
// CCR账户统计
|
|
||||||
const normalCcrAccounts = ccrAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.isActive &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized' &&
|
|
||||||
acc.schedulable !== false &&
|
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
|
||||||
).length
|
|
||||||
const abnormalCcrAccounts = ccrAccounts.filter(
|
|
||||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedCcrAccounts = ccrAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.schedulable === false &&
|
|
||||||
acc.isActive &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
|
||||||
).length
|
|
||||||
|
|
||||||
// OpenAI-Responses账户统计
|
|
||||||
// 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型
|
|
||||||
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
(acc.isActive === 'true' ||
|
|
||||||
acc.isActive === true ||
|
|
||||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized' &&
|
|
||||||
acc.schedulable !== 'false' &&
|
|
||||||
acc.schedulable !== false &&
|
|
||||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
|
||||||
).length
|
|
||||||
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.isActive === 'false' ||
|
|
||||||
acc.isActive === false ||
|
|
||||||
acc.status === 'blocked' ||
|
|
||||||
acc.status === 'unauthorized'
|
|
||||||
).length
|
|
||||||
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
|
||||||
(acc.isActive === 'true' ||
|
|
||||||
acc.isActive === true ||
|
|
||||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
|
||||||
acc.status !== 'blocked' &&
|
|
||||||
acc.status !== 'unauthorized'
|
|
||||||
).length
|
|
||||||
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
|
||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
|
||||||
).length
|
|
||||||
|
|
||||||
const dashboard = {
|
const dashboard = {
|
||||||
overview: {
|
overview: {
|
||||||
totalApiKeys: apiKeys.length,
|
totalApiKeys,
|
||||||
activeApiKeys,
|
activeApiKeys,
|
||||||
// 总账户统计(所有平台)
|
// 总账户统计(所有平台)
|
||||||
totalAccounts:
|
totalAccounts:
|
||||||
@@ -323,90 +199,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
openaiResponsesAccounts.length +
|
openaiResponsesAccounts.length +
|
||||||
ccrAccounts.length,
|
ccrAccounts.length,
|
||||||
normalAccounts:
|
normalAccounts:
|
||||||
normalClaudeAccounts +
|
claudeStats.normal +
|
||||||
normalClaudeConsoleAccounts +
|
claudeConsoleStats.normal +
|
||||||
normalGeminiAccounts +
|
geminiStats.normal +
|
||||||
normalBedrockAccounts +
|
bedrockStats.normal +
|
||||||
normalOpenAIAccounts +
|
openaiStats.normal +
|
||||||
normalOpenAIResponsesAccounts +
|
openaiResponsesStats.normal +
|
||||||
normalCcrAccounts,
|
ccrStats.normal,
|
||||||
abnormalAccounts:
|
abnormalAccounts:
|
||||||
abnormalClaudeAccounts +
|
claudeStats.abnormal +
|
||||||
abnormalClaudeConsoleAccounts +
|
claudeConsoleStats.abnormal +
|
||||||
abnormalGeminiAccounts +
|
geminiStats.abnormal +
|
||||||
abnormalBedrockAccounts +
|
bedrockStats.abnormal +
|
||||||
abnormalOpenAIAccounts +
|
openaiStats.abnormal +
|
||||||
abnormalOpenAIResponsesAccounts +
|
openaiResponsesStats.abnormal +
|
||||||
abnormalCcrAccounts +
|
ccrStats.abnormal +
|
||||||
abnormalDroidAccounts,
|
abnormalDroidAccounts,
|
||||||
pausedAccounts:
|
pausedAccounts:
|
||||||
pausedClaudeAccounts +
|
claudeStats.paused +
|
||||||
pausedClaudeConsoleAccounts +
|
claudeConsoleStats.paused +
|
||||||
pausedGeminiAccounts +
|
geminiStats.paused +
|
||||||
pausedBedrockAccounts +
|
bedrockStats.paused +
|
||||||
pausedOpenAIAccounts +
|
openaiStats.paused +
|
||||||
pausedOpenAIResponsesAccounts +
|
openaiResponsesStats.paused +
|
||||||
pausedCcrAccounts +
|
ccrStats.paused +
|
||||||
pausedDroidAccounts,
|
pausedDroidAccounts,
|
||||||
rateLimitedAccounts:
|
rateLimitedAccounts:
|
||||||
rateLimitedClaudeAccounts +
|
claudeStats.rateLimited +
|
||||||
rateLimitedClaudeConsoleAccounts +
|
claudeConsoleStats.rateLimited +
|
||||||
rateLimitedGeminiAccounts +
|
geminiStats.rateLimited +
|
||||||
rateLimitedBedrockAccounts +
|
bedrockStats.rateLimited +
|
||||||
rateLimitedOpenAIAccounts +
|
openaiStats.rateLimited +
|
||||||
rateLimitedOpenAIResponsesAccounts +
|
openaiResponsesStats.rateLimited +
|
||||||
rateLimitedCcrAccounts +
|
ccrStats.rateLimited +
|
||||||
rateLimitedDroidAccounts,
|
rateLimitedDroidAccounts,
|
||||||
// 各平台详细统计
|
// 各平台详细统计
|
||||||
accountsByPlatform: {
|
accountsByPlatform: {
|
||||||
claude: {
|
claude: {
|
||||||
total: claudeAccounts.length,
|
total: claudeAccounts.length,
|
||||||
normal: normalClaudeAccounts,
|
normal: claudeStats.normal,
|
||||||
abnormal: abnormalClaudeAccounts,
|
abnormal: claudeStats.abnormal,
|
||||||
paused: pausedClaudeAccounts,
|
paused: claudeStats.paused,
|
||||||
rateLimited: rateLimitedClaudeAccounts
|
rateLimited: claudeStats.rateLimited
|
||||||
},
|
},
|
||||||
'claude-console': {
|
'claude-console': {
|
||||||
total: claudeConsoleAccounts.length,
|
total: claudeConsoleAccounts.length,
|
||||||
normal: normalClaudeConsoleAccounts,
|
normal: claudeConsoleStats.normal,
|
||||||
abnormal: abnormalClaudeConsoleAccounts,
|
abnormal: claudeConsoleStats.abnormal,
|
||||||
paused: pausedClaudeConsoleAccounts,
|
paused: claudeConsoleStats.paused,
|
||||||
rateLimited: rateLimitedClaudeConsoleAccounts
|
rateLimited: claudeConsoleStats.rateLimited
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
total: geminiAccounts.length,
|
total: geminiAccounts.length,
|
||||||
normal: normalGeminiAccounts,
|
normal: geminiStats.normal,
|
||||||
abnormal: abnormalGeminiAccounts,
|
abnormal: geminiStats.abnormal,
|
||||||
paused: pausedGeminiAccounts,
|
paused: geminiStats.paused,
|
||||||
rateLimited: rateLimitedGeminiAccounts
|
rateLimited: geminiStats.rateLimited
|
||||||
},
|
},
|
||||||
bedrock: {
|
bedrock: {
|
||||||
total: bedrockAccounts.length,
|
total: bedrockAccounts.length,
|
||||||
normal: normalBedrockAccounts,
|
normal: bedrockStats.normal,
|
||||||
abnormal: abnormalBedrockAccounts,
|
abnormal: bedrockStats.abnormal,
|
||||||
paused: pausedBedrockAccounts,
|
paused: bedrockStats.paused,
|
||||||
rateLimited: rateLimitedBedrockAccounts
|
rateLimited: bedrockStats.rateLimited
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
total: openaiAccounts.length,
|
total: openaiAccounts.length,
|
||||||
normal: normalOpenAIAccounts,
|
normal: openaiStats.normal,
|
||||||
abnormal: abnormalOpenAIAccounts,
|
abnormal: openaiStats.abnormal,
|
||||||
paused: pausedOpenAIAccounts,
|
paused: openaiStats.paused,
|
||||||
rateLimited: rateLimitedOpenAIAccounts
|
rateLimited: openaiStats.rateLimited
|
||||||
},
|
},
|
||||||
ccr: {
|
ccr: {
|
||||||
total: ccrAccounts.length,
|
total: ccrAccounts.length,
|
||||||
normal: normalCcrAccounts,
|
normal: ccrStats.normal,
|
||||||
abnormal: abnormalCcrAccounts,
|
abnormal: ccrStats.abnormal,
|
||||||
paused: pausedCcrAccounts,
|
paused: ccrStats.paused,
|
||||||
rateLimited: rateLimitedCcrAccounts
|
rateLimited: ccrStats.rateLimited
|
||||||
},
|
},
|
||||||
'openai-responses': {
|
'openai-responses': {
|
||||||
total: openaiResponsesAccounts.length,
|
total: openaiResponsesAccounts.length,
|
||||||
normal: normalOpenAIResponsesAccounts,
|
normal: openaiResponsesStats.normal,
|
||||||
abnormal: abnormalOpenAIResponsesAccounts,
|
abnormal: openaiResponsesStats.abnormal,
|
||||||
paused: pausedOpenAIResponsesAccounts,
|
paused: openaiResponsesStats.paused,
|
||||||
rateLimited: rateLimitedOpenAIResponsesAccounts
|
rateLimited: openaiResponsesStats.rateLimited
|
||||||
},
|
},
|
||||||
droid: {
|
droid: {
|
||||||
total: droidAccounts.length,
|
total: droidAccounts.length,
|
||||||
@@ -418,20 +294,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
},
|
},
|
||||||
// 保留旧字段以兼容
|
// 保留旧字段以兼容
|
||||||
activeAccounts:
|
activeAccounts:
|
||||||
normalClaudeAccounts +
|
claudeStats.normal +
|
||||||
normalClaudeConsoleAccounts +
|
claudeConsoleStats.normal +
|
||||||
normalGeminiAccounts +
|
geminiStats.normal +
|
||||||
normalBedrockAccounts +
|
bedrockStats.normal +
|
||||||
normalOpenAIAccounts +
|
openaiStats.normal +
|
||||||
normalOpenAIResponsesAccounts +
|
openaiResponsesStats.normal +
|
||||||
normalCcrAccounts +
|
ccrStats.normal +
|
||||||
normalDroidAccounts,
|
normalDroidAccounts,
|
||||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
|
||||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
|
||||||
totalGeminiAccounts: geminiAccounts.length,
|
totalGeminiAccounts: geminiAccounts.length,
|
||||||
activeGeminiAccounts: normalGeminiAccounts,
|
activeGeminiAccounts: geminiStats.normal,
|
||||||
rateLimitedGeminiAccounts,
|
rateLimitedGeminiAccounts: geminiStats.rateLimited,
|
||||||
totalTokensUsed,
|
totalTokensUsed,
|
||||||
totalRequestsUsed,
|
totalRequestsUsed,
|
||||||
totalInputTokensUsed,
|
totalInputTokensUsed,
|
||||||
@@ -461,8 +337,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
},
|
},
|
||||||
systemHealth: {
|
systemHealth: {
|
||||||
redisConnected: redis.isConnected,
|
redisConnected: redis.isConnected,
|
||||||
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
|
||||||
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
geminiAccountsHealthy: geminiStats.normal > 0,
|
||||||
droidAccountsHealthy: normalDroidAccounts > 0,
|
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
},
|
},
|
||||||
@@ -482,7 +358,7 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
|||||||
const { period = 'daily' } = req.query // daily, monthly
|
const { period = 'daily' } = req.query // daily, monthly
|
||||||
|
|
||||||
// 获取基础API Key统计
|
// 获取基础API Key统计
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
|
||||||
const stats = apiKeys.map((key) => ({
|
const stats = apiKeys.map((key) => ({
|
||||||
keyId: key.id,
|
keyId: key.id,
|
||||||
@@ -512,55 +388,48 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
// 收集所有需要扫描的日期
|
||||||
|
const datePatterns = []
|
||||||
// 获取所有模型的统计数据
|
|
||||||
let searchPatterns = []
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
// 自定义日期范围,生成多个日期的搜索模式
|
// 自定义日期范围
|
||||||
const start = new Date(startDate)
|
const start = new Date(startDate)
|
||||||
const end = new Date(endDate)
|
const end = new Date(endDate)
|
||||||
|
|
||||||
// 确保日期范围有效
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制最大范围为365天
|
|
||||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
if (daysDiff > 365) {
|
if (daysDiff > 365) {
|
||||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成日期范围内所有日期的搜索模式
|
|
||||||
const currentDate = new Date(start)
|
const currentDate = new Date(start)
|
||||||
while (currentDate <= end) {
|
while (currentDate <= end) {
|
||||||
const dateStr = redis.getDateStringInTimezone(currentDate)
|
const dateStr = redis.getDateStringInTimezone(currentDate)
|
||||||
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
|
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
|
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`)
|
||||||
} else {
|
} else {
|
||||||
// 使用默认的period
|
// 使用默认的period
|
||||||
const pattern =
|
const pattern =
|
||||||
period === 'daily'
|
period === 'daily'
|
||||||
? `usage:model:daily:*:${today}`
|
? `usage:model:daily:*:${today}`
|
||||||
: `usage:model:monthly:*:${currentMonth}`
|
: `usage:model:monthly:*:${currentMonth}`
|
||||||
searchPatterns = [pattern]
|
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('📊 Searching patterns:', searchPatterns)
|
// 按日期集合扫描,串行避免并行触发多次全库 SCAN
|
||||||
|
const allResults = []
|
||||||
// 获取所有匹配的keys
|
for (const { pattern } of datePatterns) {
|
||||||
const allKeys = []
|
const results = await redis.scanAndGetAllChunked(pattern)
|
||||||
for (const pattern of searchPatterns) {
|
allResults.push(...results)
|
||||||
const keys = await client.keys(pattern)
|
|
||||||
allKeys.push(...keys)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
|
logger.info(`📊 Found ${allResults.length} matching keys in total`)
|
||||||
|
|
||||||
// 模型名标准化函数(与redis.js保持一致)
|
// 模型名标准化函数(与redis.js保持一致)
|
||||||
const normalizeModelName = (model) => {
|
const normalizeModelName = (model) => {
|
||||||
@@ -570,23 +439,23 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
// 对于Bedrock模型,去掉区域前缀进行统一
|
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||||
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
let normalized = model.replace(/^[a-z0-9-]+\./, '')
|
||||||
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
normalized = normalized.replace('anthropic.', '')
|
||||||
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
|
normalized = normalized.replace(/-v\d+:\d+$/, '')
|
||||||
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
|
|
||||||
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等)
|
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对于其他模型,去掉常见的版本后缀
|
|
||||||
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聚合相同模型的数据
|
// 聚合相同模型的数据
|
||||||
const modelStatsMap = new Map()
|
const modelStatsMap = new Map()
|
||||||
|
|
||||||
for (const key of allKeys) {
|
for (const { key, data } of allResults) {
|
||||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
// 支持 daily 和 monthly 两种格式
|
||||||
|
const match =
|
||||||
|
key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
|
||||||
|
key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/)
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
||||||
@@ -595,7 +464,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const rawModel = match[1]
|
const rawModel = match[1]
|
||||||
const normalizedModel = normalizeModelName(rawModel)
|
const normalizedModel = normalizeModelName(rawModel)
|
||||||
const data = await client.hgetall(key)
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
const stats = modelStatsMap.get(normalizedModel) || {
|
const stats = modelStatsMap.get(normalizedModel) || {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express')
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
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')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
@@ -142,67 +143,143 @@ router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res)
|
|||||||
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const accounts = await droidAccountService.getAllAccounts()
|
const accounts = await droidAccountService.getAllAccounts()
|
||||||
const allApiKeys = await redis.getAllApiKeys()
|
const accountIds = accounts.map((a) => a.id)
|
||||||
|
|
||||||
// 添加使用统计
|
// 并行获取:轻量 API Keys + 分组信息 + daily cost
|
||||||
const accountsWithStats = await Promise.all(
|
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||||
accounts.map(async (account) => {
|
apiKeyService.getAllApiKeysLite(),
|
||||||
try {
|
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'),
|
||||||
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
redis.batchGetAccountDailyCost(accountIds)
|
||||||
let groupInfos = []
|
])
|
||||||
try {
|
|
||||||
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
|
||||||
} catch (groupError) {
|
|
||||||
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
|
||||||
groupInfos = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupIds = groupInfos.map((group) => group.id)
|
// 构建绑定数映射(droid 需要展开 group 绑定)
|
||||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
// 1. 先构建 groupId -> accountIds 映射
|
||||||
const binding = key.droidAccountId
|
const groupToAccountIds = new Map()
|
||||||
if (!binding) {
|
for (const [accountId, groups] of allGroupInfosMap) {
|
||||||
return count
|
for (const group of groups) {
|
||||||
}
|
if (!groupToAccountIds.has(group.id)) {
|
||||||
if (binding === account.id) {
|
groupToAccountIds.set(group.id, [])
|
||||||
return count + 1
|
}
|
||||||
}
|
groupToAccountIds.get(group.id).push(accountId)
|
||||||
if (binding.startsWith('group:')) {
|
}
|
||||||
const groupId = binding.substring('group:'.length)
|
}
|
||||||
if (groupIds.includes(groupId)) {
|
|
||||||
return count + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
const formattedAccount = formatAccountExpiry(account)
|
// 2. 单次遍历构建绑定数
|
||||||
return {
|
const directBindingCount = new Map()
|
||||||
...formattedAccount,
|
const groupBindingCount = new Map()
|
||||||
schedulable: account.schedulable === 'true',
|
for (const key of allApiKeys) {
|
||||||
boundApiKeysCount,
|
const binding = key.droidAccountId
|
||||||
groupInfos,
|
if (!binding) {
|
||||||
usage: {
|
continue
|
||||||
daily: usageStats.daily,
|
}
|
||||||
total: usageStats.total,
|
if (binding.startsWith('group:')) {
|
||||||
averages: usageStats.averages
|
const groupId = binding.substring('group:'.length)
|
||||||
}
|
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1)
|
||||||
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
}
|
||||||
const formattedAccount = formatAccountExpiry(account)
|
}
|
||||||
return {
|
|
||||||
...formattedAccount,
|
// 批量获取使用统计
|
||||||
boundApiKeysCount: 0,
|
const client = redis.getClientSafe()
|
||||||
groupInfos: [],
|
const today = redis.getDateStringInTimezone()
|
||||||
usage: {
|
const tzDate = redis.getDateInTimezone()
|
||||||
daily: { tokens: 0, requests: 0 },
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
total: { tokens: 0, requests: 0 },
|
|
||||||
averages: { rpm: 0, tpm: 0 }
|
const statsPipeline = client.pipeline()
|
||||||
}
|
for (const accountId of accountIds) {
|
||||||
}
|
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||||
|
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||||
|
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||||
|
}
|
||||||
|
const statsResults = await statsPipeline.exec()
|
||||||
|
|
||||||
|
// 处理统计数据
|
||||||
|
const allUsageStatsMap = new Map()
|
||||||
|
const parseUsage = (data) => ({
|
||||||
|
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||||
|
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||||
|
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||||
|
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||||
|
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||||
|
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||||
|
allTokens:
|
||||||
|
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||||
|
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建 accountId -> createdAt 映射用于计算 averages
|
||||||
|
const accountCreatedAtMap = new Map()
|
||||||
|
for (const account of accounts) {
|
||||||
|
accountCreatedAtMap.set(
|
||||||
|
account.id,
|
||||||
|
account.createdAt ? new Date(account.createdAt) : new Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < accountIds.length; i++) {
|
||||||
|
const accountId = accountIds[i]
|
||||||
|
const [errTotal, total] = statsResults[i * 3]
|
||||||
|
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||||
|
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||||
|
|
||||||
|
const totalData = errTotal ? {} : parseUsage(total)
|
||||||
|
const totalTokens = totalData.tokens || 0
|
||||||
|
const totalRequests = totalData.requests || 0
|
||||||
|
|
||||||
|
// 计算 averages
|
||||||
|
const createdAt = accountCreatedAtMap.get(accountId)
|
||||||
|
const now = new Date()
|
||||||
|
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||||
|
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
|
||||||
|
|
||||||
|
allUsageStatsMap.set(accountId, {
|
||||||
|
total: totalData,
|
||||||
|
daily: errDaily ? {} : parseUsage(daily),
|
||||||
|
monthly: errMonthly ? {} : parseUsage(monthly),
|
||||||
|
averages: {
|
||||||
|
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||||
|
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
|
||||||
|
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||||
|
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// 处理账户数据
|
||||||
|
const accountsWithStats = accounts.map((account) => {
|
||||||
|
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||||
|
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||||
|
daily: { tokens: 0, requests: 0 },
|
||||||
|
total: { tokens: 0, requests: 0 },
|
||||||
|
monthly: { tokens: 0, requests: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
|
||||||
|
}
|
||||||
|
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||||
|
|
||||||
|
// 计算绑定数:直接绑定 + 通过 group 绑定
|
||||||
|
let boundApiKeysCount = directBindingCount.get(account.id) || 0
|
||||||
|
for (const group of groupInfos) {
|
||||||
|
boundApiKeysCount += groupBindingCount.get(group.id) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
schedulable: account.schedulable === 'true',
|
||||||
|
boundApiKeysCount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { ...usageStats.daily, cost: dailyCost },
|
||||||
|
total: usageStats.total,
|
||||||
|
monthly: usageStats.monthly,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return res.json({ success: true, data: accountsWithStats })
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -434,7 +511,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取绑定的 API Key 数量
|
// 获取绑定的 API Key 数量
|
||||||
const allApiKeys = await redis.getAllApiKeys()
|
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
const groupIds = groupInfos.map((group) => group.id)
|
const groupIds = groupInfos.map((group) => group.id)
|
||||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||||
const binding = key.droidAccountId
|
const binding = key.droidAccountId
|
||||||
@@ -524,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Droid 账户连通性
|
||||||
|
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await droidAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 token 有效
|
||||||
|
const tokenResult = await droidAccountService.ensureValidToken(accountId)
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Token refresh failed',
|
||||||
|
message: tokenResult.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = tokenResult
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const apiUrl = 'https://api.factory.ai/v1/messages'
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.content?.[0]?.text) {
|
||||||
|
responseText = response.data.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// 🤖 Gemini OAuth 账户管理
|
// 🤖 Gemini OAuth 账户管理
|
||||||
|
function getDefaultRedirectUri(oauthProvider) {
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
|
||||||
|
}
|
||||||
|
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
|
||||||
|
}
|
||||||
|
|
||||||
// 生成 Gemini OAuth 授权 URL
|
// 生成 Gemini OAuth 授权 URL
|
||||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { state, proxy } = req.body // 接收代理配置
|
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
|
||||||
|
|
||||||
// 使用新的 codeassist.google.com 回调地址
|
const redirectUri = getDefaultRedirectUri(oauthProvider)
|
||||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
|
||||||
|
|
||||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||||
|
|
||||||
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: authState,
|
state: authState,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
oauthProvider: resolvedOauthProvider
|
||||||
|
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||||
|
|
||||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||||
const sessionId = authState
|
const sessionId = authState
|
||||||
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
redirectUri: finalRedirectUri,
|
redirectUri: finalRedirectUri,
|
||||||
codeVerifier, // 保存 PKCE code verifier
|
codeVerifier, // 保存 PKCE code verifier
|
||||||
proxy: proxy || null, // 保存代理配置
|
proxy: proxy || null, // 保存代理配置
|
||||||
|
oauthProvider: resolvedOauthProvider,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
authUrl,
|
authUrl,
|
||||||
sessionId
|
sessionId,
|
||||||
|
oauthProvider: resolvedOauthProvider
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,7 +74,7 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
|||||||
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
|
logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||||
return res.json({ success: true, data: { tokens: result.tokens } })
|
return res.json({ success: true, data: { tokens: result.tokens } })
|
||||||
} else {
|
} else {
|
||||||
return res.json({ success: false, error: result.error })
|
return res.json({ success: false, error: result.error })
|
||||||
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
|||||||
// 交换 Gemini 授权码
|
// 交换 Gemini 授权码
|
||||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code, sessionId, proxy: requestProxy } = req.body
|
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||||
|
let resolvedOauthProvider = oauthProvider
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: 'Authorization code is required' })
|
return res.status(400).json({ error: 'Authorization code is required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirectUri = 'https://codeassist.google.com/authcode'
|
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
|
||||||
let codeVerifier = null
|
let codeVerifier = null
|
||||||
let proxyConfig = null
|
let proxyConfig = null
|
||||||
|
|
||||||
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
redirectUri: sessionRedirectUri,
|
redirectUri: sessionRedirectUri,
|
||||||
codeVerifier: sessionCodeVerifier,
|
codeVerifier: sessionCodeVerifier,
|
||||||
proxy
|
proxy,
|
||||||
|
oauthProvider: sessionOauthProvider
|
||||||
} = sessionData
|
} = sessionData
|
||||||
redirectUri = sessionRedirectUri || redirectUri
|
redirectUri = sessionRedirectUri || redirectUri
|
||||||
codeVerifier = sessionCodeVerifier
|
codeVerifier = sessionCodeVerifier
|
||||||
proxyConfig = proxy // 获取代理配置
|
proxyConfig = proxy // 获取代理配置
|
||||||
|
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||||
|
// 会话里保存的 provider 仅作为兜底
|
||||||
|
resolvedOauthProvider = sessionOauthProvider
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||||
)
|
)
|
||||||
@@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
code,
|
code,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig, // 传递代理配置
|
||||||
|
resolvedOauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 清理 OAuth 会话
|
// 清理 OAuth 会话
|
||||||
@@ -128,8 +143,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
await redis.deleteOAuthSession(sessionId)
|
await redis.deleteOAuthSession(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
logger.success('Successfully exchanged Gemini authorization code')
|
||||||
return res.json({ success: true, data: { tokens } })
|
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||||
@@ -483,7 +498,7 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const result = await geminiAccountService.resetAccountStatus(id)
|
const result = await geminiAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
|
logger.success(`Admin reset status for Gemini account: ${id}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset Gemini account status:', error)
|
logger.error('❌ Failed to reset Gemini account status:', error)
|
||||||
@@ -491,4 +506,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Gemini 账户连通性
|
||||||
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'gemini-2.5-flash' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 token 有效
|
||||||
|
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Token refresh failed',
|
||||||
|
message: tokenResult.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = tokenResult
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||||
|
const payload = createGeminiTestPayload(model)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||||
|
responseText = response.data.candidates[0].content.parts[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -31,53 +31,108 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理使用统计和绑定的 API Key 数量
|
const accountIds = accounts.map((a) => a.id)
|
||||||
const accountsWithStats = await Promise.all(
|
|
||||||
accounts.map(async (account) => {
|
|
||||||
// 检查并清除过期的限流状态
|
|
||||||
await geminiApiAccountService.checkAndClearRateLimit(account.id)
|
|
||||||
|
|
||||||
// 获取使用统计信息
|
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态
|
||||||
let usageStats
|
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||||
try {
|
apiKeyService.getAllApiKeysLite(),
|
||||||
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
|
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'),
|
||||||
} catch (error) {
|
redis.batchGetAccountDailyCost(accountIds),
|
||||||
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
|
// 批量清除限流状态
|
||||||
usageStats = {
|
Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id)))
|
||||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
])
|
||||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
|
||||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算绑定的API Key数量(支持 api: 前缀)
|
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||||
const allKeys = await redis.getAllApiKeys()
|
const bindingCountMap = new Map()
|
||||||
let boundCount = 0
|
for (const key of allApiKeys) {
|
||||||
|
const binding = key.geminiAccountId
|
||||||
|
if (!binding) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 处理 api: 前缀
|
||||||
|
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
|
||||||
|
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of allKeys) {
|
// 批量获取使用统计
|
||||||
if (key.geminiAccountId) {
|
const client = redis.getClientSafe()
|
||||||
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
|
const today = redis.getDateStringInTimezone()
|
||||||
if (key.geminiAccountId === `api:${account.id}`) {
|
const tzDate = redis.getDateInTimezone()
|
||||||
boundCount++
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分组信息
|
const statsPipeline = client.pipeline()
|
||||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
for (const accountId of accountIds) {
|
||||||
|
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||||
|
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||||
|
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||||
|
}
|
||||||
|
const statsResults = await statsPipeline.exec()
|
||||||
|
|
||||||
return {
|
// 处理统计数据
|
||||||
...account,
|
const allUsageStatsMap = new Map()
|
||||||
groupInfos,
|
for (let i = 0; i < accountIds.length; i++) {
|
||||||
usage: {
|
const accountId = accountIds[i]
|
||||||
daily: usageStats.daily,
|
const [errTotal, total] = statsResults[i * 3]
|
||||||
total: usageStats.total,
|
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||||
averages: usageStats.averages || usageStats.monthly
|
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||||
},
|
|
||||||
boundApiKeys: boundCount
|
const parseUsage = (data) => ({
|
||||||
}
|
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||||
|
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||||
|
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||||
|
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||||
|
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||||
|
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||||
|
allTokens:
|
||||||
|
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||||
|
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
allUsageStatsMap.set(accountId, {
|
||||||
|
total: errTotal ? {} : parseUsage(total),
|
||||||
|
daily: errDaily ? {} : parseUsage(daily),
|
||||||
|
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理账户数据
|
||||||
|
const accountsWithStats = accounts.map((account) => {
|
||||||
|
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||||
|
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||||
|
const boundCount = bindingCountMap.get(account.id) || 0
|
||||||
|
|
||||||
|
// 计算 averages(rpm/tpm)
|
||||||
|
const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
|
||||||
|
const daysSinceCreated = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
)
|
||||||
|
const totalMinutes = daysSinceCreated * 24 * 60
|
||||||
|
const totalRequests = usageStats.total.requests || 0
|
||||||
|
const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { ...usageStats.daily, cost: dailyCost },
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: {
|
||||||
|
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||||
|
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boundApiKeys: boundCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
res.json({ success: true, data: accountsWithStats })
|
res.json({ success: true, data: accountsWithStats })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -275,7 +330,7 @@ router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) =>
|
|||||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ ${message}`)
|
logger.success(`${message}`)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -389,7 +444,7 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
|
|||||||
|
|
||||||
const result = await geminiApiAccountService.resetAccountStatus(id)
|
const result = await geminiApiAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
|
logger.success(`Admin reset status for Gemini-API account: ${id}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ 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 syncRoutes = require('./sync')
|
||||||
|
const serviceRatesRoutes = require('./serviceRates')
|
||||||
|
const quotaCardsRoutes = require('./quotaCards')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,8 +40,13 @@ 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('/', syncRoutes)
|
||||||
|
router.use('/', serviceRatesRoutes)
|
||||||
|
router.use('/', quotaCardsRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
||||||
|
|
||||||
logger.success('🔗 Generated OpenAI OAuth authorization URL')
|
logger.success('Generated OpenAI OAuth authorization URL')
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -191,7 +191,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
// 清理 Redis 会话
|
// 清理 Redis 会话
|
||||||
await redis.deleteOAuthSession(sessionId)
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
logger.success('✅ OpenAI OAuth token exchange successful')
|
logger.success('OpenAI OAuth token exchange successful')
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -386,7 +386,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
delete refreshedAccount.accessToken
|
delete refreshedAccount.accessToken
|
||||||
delete refreshedAccount.refreshToken
|
delete refreshedAccount.refreshToken
|
||||||
|
|
||||||
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -450,7 +450,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -541,7 +541,7 @@ router.put('/:id', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Token 验证成功,继续更新账户信息`)
|
logger.success(`Token 验证成功,继续更新账户信息`)
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
// 刷新失败,恢复原始 token
|
// 刷新失败,恢复原始 token
|
||||||
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
||||||
@@ -755,7 +755,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const result = await openaiAccountService.resetAccountStatus(accountId)
|
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||||
|
|
||||||
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
logger.success(`Admin reset status for OpenAI account: ${accountId}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset OpenAI account status:', error)
|
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||||
|
|||||||
@@ -39,92 +39,97 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
const accountIds = accounts.map((a) => a.id)
|
||||||
const accountsWithStats = await Promise.all(
|
|
||||||
accounts.map(async (account) => {
|
|
||||||
try {
|
|
||||||
// 检查是否需要重置额度
|
|
||||||
const today = redis.getDateStringInTimezone()
|
|
||||||
if (account.lastResetDate !== today) {
|
|
||||||
// 今天还没重置过,需要重置
|
|
||||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
|
||||||
dailyUsage: '0',
|
|
||||||
lastResetDate: today,
|
|
||||||
quotaStoppedAt: ''
|
|
||||||
})
|
|
||||||
account.dailyUsage = '0'
|
|
||||||
account.lastResetDate = today
|
|
||||||
account.quotaStoppedAt = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查并清除过期的限流状态
|
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
|
||||||
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
|
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||||
|
apiKeyService.getAllApiKeysLite(),
|
||||||
|
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
|
||||||
|
redis.batchGetAccountDailyCost(accountIds),
|
||||||
|
// 批量清理限流状态
|
||||||
|
Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
|
||||||
|
])
|
||||||
|
|
||||||
// 获取使用统计信息
|
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||||
let usageStats
|
const bindingCountMap = new Map()
|
||||||
try {
|
for (const key of allApiKeys) {
|
||||||
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
|
const binding = key.openaiAccountId
|
||||||
} catch (error) {
|
if (!binding) {
|
||||||
logger.debug(
|
continue
|
||||||
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
|
}
|
||||||
error
|
// 处理 responses: 前缀
|
||||||
)
|
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
|
||||||
usageStats = {
|
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
}
|
||||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
|
||||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算绑定的API Key数量(支持 responses: 前缀)
|
// 批量获取使用统计(不含 daily cost,已单独获取)
|
||||||
const allKeys = await redis.getAllApiKeys()
|
const client = redis.getClientSafe()
|
||||||
let boundCount = 0
|
const today = redis.getDateStringInTimezone()
|
||||||
|
const tzDate = redis.getDateInTimezone()
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
for (const key of allKeys) {
|
const statsPipeline = client.pipeline()
|
||||||
// 检查是否绑定了该账户(包括 responses: 前缀)
|
for (const accountId of accountIds) {
|
||||||
if (
|
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||||
key.openaiAccountId === account.id ||
|
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||||
key.openaiAccountId === `responses:${account.id}`
|
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||||
) {
|
}
|
||||||
boundCount++
|
const statsResults = await statsPipeline.exec()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调试日志:检查绑定计数
|
// 处理统计数据
|
||||||
if (boundCount > 0) {
|
const allUsageStatsMap = new Map()
|
||||||
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
|
for (let i = 0; i < accountIds.length; i++) {
|
||||||
}
|
const accountId = accountIds[i]
|
||||||
|
const [errTotal, total] = statsResults[i * 3]
|
||||||
|
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||||
|
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||||
|
|
||||||
// 获取分组信息
|
const parseUsage = (data) => ({
|
||||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||||
|
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||||
const formattedAccount = formatAccountExpiry(account)
|
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||||
return {
|
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||||
...formattedAccount,
|
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||||
groupInfos,
|
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||||
boundApiKeysCount: boundCount,
|
allTokens:
|
||||||
usage: {
|
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||||
daily: usageStats.daily,
|
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||||
total: usageStats.total,
|
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||||
monthly: usageStats.monthly
|
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||||
}
|
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
|
|
||||||
const formattedAccount = formatAccountExpiry(account)
|
|
||||||
return {
|
|
||||||
...formattedAccount,
|
|
||||||
groupInfos: [],
|
|
||||||
boundApiKeysCount: 0,
|
|
||||||
usage: {
|
|
||||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
|
||||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
|
||||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
allUsageStatsMap.set(accountId, {
|
||||||
|
total: errTotal ? {} : parseUsage(total),
|
||||||
|
daily: errDaily ? {} : parseUsage(daily),
|
||||||
|
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||||
|
const accountsWithStats = accounts.map((account) => {
|
||||||
|
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||||
|
const boundCount = bindingCountMap.get(account.id) || 0
|
||||||
|
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
boundApiKeysCount: boundCount,
|
||||||
|
usage: {
|
||||||
|
daily: { ...usageStats.daily, cost: dailyCost },
|
||||||
|
total: usageStats.total,
|
||||||
|
monthly: usageStats.monthly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
res.json({ success: true, data: accountsWithStats })
|
res.json({ success: true, data: accountsWithStats })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -413,7 +418,7 @@ router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, as
|
|||||||
|
|
||||||
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
|
logger.success(`Admin reset status for OpenAI-Responses account: ${id}`)
|
||||||
return res.json({ success: true, data: result })
|
return res.json({ success: true, data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
||||||
@@ -432,7 +437,7 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
|||||||
quotaStoppedAt: ''
|
quotaStoppedAt: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -447,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 OpenAI-Responses 账户连通性
|
||||||
|
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'gpt-4o-mini' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的 API Key
|
||||||
|
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||||
|
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||||
|
const payload = createOpenAITestPayload(model)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.choices?.[0]?.message?.content) {
|
||||||
|
responseText = response.data.choices[0].message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
242
src/routes/admin/quotaCards.js
Normal file
242
src/routes/admin/quotaCards.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 额度卡/时间卡管理路由
|
||||||
|
*/
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const quotaCardService = require('../../services/quotaCardService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 额度卡管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 获取额度卡上限配置
|
||||||
|
router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await quotaCardService.getLimitsConfig()
|
||||||
|
res.json({ success: true, data: config })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota card limits:', error)
|
||||||
|
res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新额度卡上限配置
|
||||||
|
router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body
|
||||||
|
const config = await quotaCardService.saveLimitsConfig({
|
||||||
|
enabled,
|
||||||
|
maxExpiryDays,
|
||||||
|
maxTotalCostLimit
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: config })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to save quota card limits:', error)
|
||||||
|
res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取额度卡列表
|
||||||
|
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, limit = 100, offset = 0 } = req.query
|
||||||
|
const result = await quotaCardService.getAllCards({
|
||||||
|
status,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota cards:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取额度卡统计
|
||||||
|
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await quotaCardService.getCardStats()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota card stats:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个额度卡详情
|
||||||
|
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const card = await quotaCardService.getCardById(req.params.id)
|
||||||
|
if (!card) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Card not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: card
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建额度卡
|
||||||
|
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'type is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdBy = req.session?.username || 'admin'
|
||||||
|
const options = {
|
||||||
|
type,
|
||||||
|
quotaAmount: parseFloat(quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(timeAmount || 0),
|
||||||
|
timeUnit: timeUnit || 'days',
|
||||||
|
expiresAt,
|
||||||
|
note,
|
||||||
|
createdBy
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (count > 1) {
|
||||||
|
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
|
||||||
|
} else {
|
||||||
|
result = await quotaCardService.createCard(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除未使用的额度卡
|
||||||
|
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await quotaCardService.deleteCard(req.params.id)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 核销记录管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 获取核销记录列表
|
||||||
|
router.get('/redemptions', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
|
||||||
|
const result = await quotaCardService.getRedemptions({
|
||||||
|
userId,
|
||||||
|
apiKeyId,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get redemptions:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤销核销
|
||||||
|
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { reason } = req.body
|
||||||
|
const revokedBy = req.session?.username || 'admin'
|
||||||
|
|
||||||
|
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to revoke redemption:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 延长有效期
|
||||||
|
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { amount, unit = 'days' } = req.body
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'amount must be a positive number'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to extend expiry:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
72
src/routes/admin/serviceRates.js
Normal file
72
src/routes/admin/serviceRates.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 服务倍率配置管理路由
|
||||||
|
*/
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const serviceRatesService = require('../../services/serviceRatesService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
|
||||||
|
// 获取服务倍率配置
|
||||||
|
router.get('/service-rates', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rates = await serviceRatesService.getRates()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新服务倍率配置
|
||||||
|
router.put('/service-rates', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rates, baseService } = req.body
|
||||||
|
|
||||||
|
if (!rates || typeof rates !== 'object') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'rates is required and must be an object'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBy = req.session?.username || 'admin'
|
||||||
|
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取可用服务列表
|
||||||
|
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const services = await serviceRatesService.getAvailableServices()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: services
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get available services:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
464
src/routes/admin/sync.js
Normal file
464
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Sync / Export (for migration)
|
||||||
|
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
function toBool(value, defaultValue = false) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
if (value === true || value === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (value === false || value === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxy(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||||
|
const host = proxy.host || ''
|
||||||
|
const port = Number(proxy.port || 0)
|
||||||
|
|
||||||
|
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: String(protocol),
|
||||||
|
host: String(host),
|
||||||
|
port,
|
||||||
|
username: proxy.username ? String(proxy.username) : '',
|
||||||
|
password: proxy.password ? String(proxy.password) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||||
|
if (!supportedModels) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(supportedModels)) {
|
||||||
|
const mapping = {}
|
||||||
|
for (const model of supportedModels) {
|
||||||
|
if (typeof model === 'string' && model.trim()) {
|
||||||
|
mapping[model.trim()] = model.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof supportedModels === 'object') {
|
||||||
|
const mapping = {}
|
||||||
|
for (const [from, to] of Object.entries(supportedModels)) {
|
||||||
|
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||||
|
mapping[from.trim()] = to.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(raw, fallback = null) {
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (_) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export accounts for migration (includes secrets).
|
||||||
|
// GET /admin/sync/export-accounts?include_secrets=true
|
||||||
|
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||||
|
if (!includeSecrets) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'include_secrets_required',
|
||||||
|
message: 'Set include_secrets=true to export secrets'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Claude official OAuth / Setup Token accounts =====
|
||||||
|
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||||
|
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||||
|
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||||
|
let decryptedClaudeAiOauth = null
|
||||||
|
if (account.claudeAiOauth) {
|
||||||
|
try {
|
||||||
|
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||||
|
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||||
|
} catch (_) {
|
||||||
|
decryptedClaudeAiOauth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScopes =
|
||||||
|
account.scopes && account.scopes.trim()
|
||||||
|
? account.scopes
|
||||||
|
: decryptedClaudeAiOauth?.scopes
|
||||||
|
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||||
|
|
||||||
|
const accessToken =
|
||||||
|
account.accessToken && String(account.accessToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||||
|
: decryptedClaudeAiOauth?.accessToken || ''
|
||||||
|
|
||||||
|
const refreshToken =
|
||||||
|
account.refreshToken && String(account.refreshToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||||
|
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||||
|
|
||||||
|
let expiresAt = null
|
||||||
|
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||||
|
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||||
|
expiresAt = new Date(expiresAtMs).toISOString()
|
||||||
|
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||||
|
try {
|
||||||
|
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||||
|
} catch (_) {
|
||||||
|
expiresAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||||
|
|
||||||
|
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||||
|
let orgUuid = null
|
||||||
|
let accountUuid = null
|
||||||
|
if (account.subscriptionInfo) {
|
||||||
|
try {
|
||||||
|
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||||
|
orgUuid = subscriptionInfo.organizationUuid || null
|
||||||
|
accountUuid = subscriptionInfo.accountUuid || null
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||||
|
if (!expiresIn && isOAuth) {
|
||||||
|
expiresIn = 28800 // 8 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken || undefined,
|
||||||
|
expires_at: expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (orgUuid) {
|
||||||
|
credentials.org_uuid = orgUuid
|
||||||
|
}
|
||||||
|
if (accountUuid) {
|
||||||
|
credentials.account_uuid = accountUuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'claude-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'claude',
|
||||||
|
crs_auth_type: authType,
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_subscription_info: account.subscriptionInfo || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'claude-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'claude',
|
||||||
|
authType,
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Claude Console API Key accounts =====
|
||||||
|
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
const claudeConsoleAccounts = []
|
||||||
|
for (const summary of claudeConsoleSummaries) {
|
||||||
|
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.apiUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeConsoleAccounts.push({
|
||||||
|
kind: 'claude-console-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'claude-console',
|
||||||
|
isActive: full.isActive === true,
|
||||||
|
schedulable: full.schedulable !== false,
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'claude-console-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'claude-console',
|
||||||
|
crs_is_active: full.isActive === true,
|
||||||
|
crs_schedulable: full.schedulable !== false,
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI OAuth accounts =====
|
||||||
|
const openaiOAuthAccounts = []
|
||||||
|
{
|
||||||
|
const openaiIds = await redis.getAllIdsByIndex(
|
||||||
|
'openai:account:index',
|
||||||
|
'openai:account:*',
|
||||||
|
/^openai:account:(.+)$/
|
||||||
|
)
|
||||||
|
for (const id of openaiIds) {
|
||||||
|
const account = await openaiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = account.accessToken
|
||||||
|
? openaiAccountService.decrypt(account.accessToken)
|
||||||
|
: ''
|
||||||
|
if (!accessToken) {
|
||||||
|
// Skip broken/legacy records without decryptable token
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes =
|
||||||
|
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||||
|
? account.scopes.trim().split(' ')
|
||||||
|
: []
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(account.proxy)
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (account.expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||||
|
if (!expiresIn) {
|
||||||
|
expiresIn = 864000 // 10 days
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: account.refreshToken || undefined,
|
||||||
|
id_token: account.idToken || undefined,
|
||||||
|
expires_at: account.expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (account.accountId) {
|
||||||
|
credentials.chatgpt_account_id = account.accountId
|
||||||
|
}
|
||||||
|
if (account.chatgptUserId) {
|
||||||
|
credentials.chatgpt_user_id = account.chatgptUserId
|
||||||
|
}
|
||||||
|
if (account.organizationId) {
|
||||||
|
credentials.organization_id = account.organizationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'openai-oauth-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'openai',
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_email: account.email || undefined,
|
||||||
|
crs_chatgpt_account_id: account.accountId || undefined,
|
||||||
|
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||||
|
crs_organization_id: account.organizationId || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiOAuthAccounts.push({
|
||||||
|
kind: 'openai-oauth-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'openai',
|
||||||
|
authType: 'oauth',
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI Responses API Key accounts =====
|
||||||
|
const openaiResponsesAccounts = []
|
||||||
|
const openaiResponseIds = await redis.getAllIdsByIndex(
|
||||||
|
'openai_responses_account:index',
|
||||||
|
'openai_responses_account:*',
|
||||||
|
/^openai_responses_account:(.+)$/
|
||||||
|
)
|
||||||
|
for (const id of openaiResponseIds) {
|
||||||
|
const full = await openaiResponsesAccountService.getAccount(id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.baseApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiResponsesAccounts.push({
|
||||||
|
kind: 'openai-responses-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'openai-responses',
|
||||||
|
isActive: full.isActive === 'true',
|
||||||
|
schedulable: full.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'openai-responses-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'openai-responses',
|
||||||
|
crs_is_active: full.isActive === 'true',
|
||||||
|
crs_schedulable: full.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
claudeAccounts,
|
||||||
|
claudeConsoleAccounts,
|
||||||
|
openaiOAuthAccounts,
|
||||||
|
openaiResponsesAccounts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to export accounts for sync:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'export_failed',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '', // Base64编码的图标数据
|
siteIconData: '', // Base64编码的图标数据
|
||||||
showAdminButton: true, // 是否显示管理后台按钮
|
showAdminButton: true, // 是否显示管理后台按钮
|
||||||
|
apiStatsNotice: {
|
||||||
|
enabled: false,
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
},
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +301,7 @@ 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, apiStatsNotice } = req.body
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||||
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
|||||||
siteIcon: (siteIcon || '').trim(),
|
siteIcon: (siteIcon || '').trim(),
|
||||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||||
showAdminButton: showAdminButton !== false, // 默认为true
|
showAdminButton: showAdminButton !== false, // 默认为true
|
||||||
|
apiStatsNotice: {
|
||||||
|
enabled: apiStatsNotice?.enabled === true,
|
||||||
|
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||||||
|
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||||||
|
},
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,39 @@ const apiKeyService = require('../services/apiKeyService')
|
|||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
const claudeAccountService = require('../services/claudeAccountService')
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
|
const serviceRatesService = require('../services/serviceRatesService')
|
||||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
const modelsConfig = require('../../config/models')
|
||||||
|
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 📋 获取可用模型列表(公开接口)
|
||||||
|
router.get('/models', (req, res) => {
|
||||||
|
const { service } = req.query
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
// 返回指定服务的模型
|
||||||
|
const models = modelsConfig.getModelsByService(service)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: models
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回所有模型(按服务分组)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claude: modelsConfig.CLAUDE_MODELS,
|
||||||
|
gemini: modelsConfig.GEMINI_MODELS,
|
||||||
|
openai: modelsConfig.OPENAI_MODELS,
|
||||||
|
other: modelsConfig.OTHER_MODELS,
|
||||||
|
all: modelsConfig.getAllModels()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 🏠 重定向页面请求到新版 admin-spa
|
// 🏠 重定向页面请求到新版 admin-spa
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.redirect(301, '/admin-next/api-stats')
|
res.redirect(301, '/admin-next/api-stats')
|
||||||
@@ -39,7 +68,7 @@ router.post('/api/get-key-id', async (req, res) => {
|
|||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
logger.security(`Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid API key',
|
error: 'Invalid API key',
|
||||||
message: validation.error
|
message: validation.error
|
||||||
@@ -87,7 +116,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
keyData = await redis.getApiKey(apiId)
|
keyData = await redis.getApiKey(apiId)
|
||||||
|
|
||||||
if (!keyData || Object.keys(keyData).length === 0) {
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'API key not found',
|
error: 'API key not found',
|
||||||
message: 'The specified API key does not exist'
|
message: 'The specified API key does not exist'
|
||||||
@@ -155,7 +184,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients,
|
allowedClients,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
// 添加激活相关字段
|
// 添加激活相关字段
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
@@ -166,7 +195,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
} else if (apiKey) {
|
} else if (apiKey) {
|
||||||
// 通过 apiKey 查询(保持向后兼容)
|
// 通过 apiKey 查询(保持向后兼容)
|
||||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
logger.security(`Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid API key format',
|
error: 'Invalid API key format',
|
||||||
message: 'API key format is invalid'
|
message: 'API key format is invalid'
|
||||||
@@ -191,7 +220,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
keyData = validatedKeyData
|
keyData = validatedKeyData
|
||||||
keyId = keyData.id
|
keyId = keyData.id
|
||||||
} else {
|
} else {
|
||||||
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
logger.security(`Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'API Key or ID is required',
|
error: 'API Key or ID is required',
|
||||||
message: 'Please provide your API Key or API ID'
|
message: 'Please provide your API Key or API ID'
|
||||||
@@ -206,74 +235,84 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
||||||
const fullKeyData = keyData
|
const fullKeyData = keyData
|
||||||
|
|
||||||
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
|
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
|
||||||
|
// 计算总费用 - 优先使用持久化的总费用计数器
|
||||||
let totalCost = 0
|
let totalCost = 0
|
||||||
let formattedCost = '$0.000000'
|
let formattedCost = '$0.000000'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
// 获取所有月度模型统计(与model-stats接口相同的逻辑)
|
// 读取累积的总费用(没有 TTL 的持久键)
|
||||||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
const modelUsageMap = new Map()
|
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||||
|
|
||||||
for (const key of allModelKeys) {
|
if (allTimeCost > 0) {
|
||||||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
totalCost = allTimeCost
|
||||||
if (!modelMatch) {
|
formattedCost = CostCalculator.formatCost(allTimeCost)
|
||||||
continue
|
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
|
||||||
}
|
} else {
|
||||||
|
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
|
||||||
|
const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`)
|
||||||
|
const modelUsageMap = new Map()
|
||||||
|
|
||||||
const model = modelMatch[1]
|
for (const { key, data } of allModelResults) {
|
||||||
const data = await client.hgetall(key)
|
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||||
|
if (!modelMatch) {
|
||||||
if (data && Object.keys(data).length > 0) {
|
continue
|
||||||
if (!modelUsageMap.has(model)) {
|
|
||||||
modelUsageMap.set(model, {
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelUsage = modelUsageMap.get(model)
|
const model = modelMatch[1]
|
||||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
|
||||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
|
||||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
|
||||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按模型计算费用并汇总
|
if (data && Object.keys(data).length > 0) {
|
||||||
for (const [model, usage] of modelUsageMap) {
|
if (!modelUsageMap.has(model)) {
|
||||||
const usageData = {
|
modelUsageMap.set(model, {
|
||||||
input_tokens: usage.inputTokens,
|
inputTokens: 0,
|
||||||
output_tokens: usage.outputTokens,
|
outputTokens: 0,
|
||||||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
cacheCreateTokens: 0,
|
||||||
cache_read_input_tokens: usage.cacheReadTokens
|
cacheReadTokens: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelUsage = modelUsageMap.get(model)
|
||||||
|
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||||||
|
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||||||
|
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||||
|
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usageData, model)
|
// 按模型计算费用并汇总
|
||||||
totalCost += costResult.costs.total
|
for (const [model, usage] of modelUsageMap) {
|
||||||
}
|
const usageData = {
|
||||||
|
input_tokens: usage.inputTokens,
|
||||||
|
output_tokens: usage.outputTokens,
|
||||||
|
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: usage.cacheReadTokens
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有模型级别的详细数据,回退到总体数据计算
|
const costResult = CostCalculator.calculateCost(usageData, model)
|
||||||
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
|
totalCost += costResult.costs.total
|
||||||
const usage = fullKeyData.usage.total
|
|
||||||
const costUsage = {
|
|
||||||
input_tokens: usage.inputTokens || 0,
|
|
||||||
output_tokens: usage.outputTokens || 0,
|
|
||||||
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
|
||||||
cache_read_input_tokens: usage.cacheReadTokens || 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
// 如果没有模型级别的详细数据,回退到总体数据计算
|
||||||
totalCost = costResult.costs.total
|
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
|
||||||
}
|
const usage = fullKeyData.usage.total
|
||||||
|
const costUsage = {
|
||||||
|
input_tokens: usage.inputTokens || 0,
|
||||||
|
output_tokens: usage.outputTokens || 0,
|
||||||
|
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
||||||
|
cache_read_input_tokens: usage.cacheReadTokens || 0
|
||||||
|
}
|
||||||
|
|
||||||
formattedCost = CostCalculator.formatCost(totalCost)
|
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
||||||
|
totalCost = costResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedCost = CostCalculator.formatCost(totalCost)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
|
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
|
||||||
// 回退到简单计算
|
// 回退到简单计算
|
||||||
if (fullKeyData.usage?.total?.allTokens > 0) {
|
if (fullKeyData.usage?.total?.allTokens > 0) {
|
||||||
const usage = fullKeyData.usage.total
|
const usage = fullKeyData.usage.total
|
||||||
@@ -464,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
restrictedModels: fullKeyData.restrictedModels || [],
|
restrictedModels: fullKeyData.restrictedModels || [],
|
||||||
enableClientRestriction: fullKeyData.enableClientRestriction || false,
|
enableClientRestriction: fullKeyData.enableClientRestriction || false,
|
||||||
allowedClients: fullKeyData.allowedClients || []
|
allowedClients: fullKeyData.allowedClients || []
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// Key 级别的服务倍率
|
||||||
|
serviceRates: (() => {
|
||||||
|
try {
|
||||||
|
return fullKeyData.serviceRates
|
||||||
|
? typeof fullKeyData.serviceRates === 'string'
|
||||||
|
? JSON.parse(fullKeyData.serviceRates)
|
||||||
|
: fullKeyData.serviceRates
|
||||||
|
: {}
|
||||||
|
} catch (e) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -587,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
...usage.monthly,
|
...usage.monthly,
|
||||||
cost: costStats.monthly
|
cost: costStats.monthly
|
||||||
},
|
},
|
||||||
totalCost: costStats.total
|
totalCost: costStats.total,
|
||||||
|
serviceRates: (() => {
|
||||||
|
try {
|
||||||
|
return keyData.serviceRates
|
||||||
|
? typeof keyData.serviceRates === 'string'
|
||||||
|
? JSON.parse(keyData.serviceRates)
|
||||||
|
: keyData.serviceRates
|
||||||
|
: {}
|
||||||
|
} catch (e) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -691,7 +754,7 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const _client = redis.getClientSafe()
|
||||||
const tzDate = redis.getDateInTimezone()
|
const tzDate = redis.getDateInTimezone()
|
||||||
const today = redis.getDateStringInTimezone()
|
const today = redis.getDateStringInTimezone()
|
||||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||||
@@ -706,9 +769,9 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
? `usage:${apiId}:model:daily:*:${today}`
|
? `usage:${apiId}:model:daily:*:${today}`
|
||||||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||||||
|
|
||||||
const keys = await client.keys(pattern)
|
const results = await redis.scanAndGetAllChunked(pattern)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const { key, data } of results) {
|
||||||
const match = key.match(
|
const match = key.match(
|
||||||
period === 'daily'
|
period === 'daily'
|
||||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
@@ -720,7 +783,6 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = match[1]
|
const model = match[1]
|
||||||
const data = await client.hgetall(key)
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
if (!modelUsageMap.has(model)) {
|
if (!modelUsageMap.has(model)) {
|
||||||
@@ -730,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
allTokens: 0
|
allTokens: 0,
|
||||||
|
realCostMicro: 0,
|
||||||
|
ratedCostMicro: 0,
|
||||||
|
hasStoredCost: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||||
|
modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0
|
||||||
|
modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0
|
||||||
|
// 检查 Redis 数据是否包含成本字段
|
||||||
|
if ('realCostMicro' in data || 'ratedCostMicro' in data) {
|
||||||
|
modelUsage.hasStoredCost = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 转换为数组并计算费用
|
// 转换为数组并处理费用
|
||||||
const modelStats = []
|
const modelStats = []
|
||||||
for (const [model, usage] of modelUsageMap) {
|
for (const [model, usage] of modelUsageMap) {
|
||||||
const usageData = {
|
const usageData = {
|
||||||
@@ -756,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
cache_read_input_tokens: usage.cacheReadTokens
|
cache_read_input_tokens: usage.cacheReadTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用存储的费用,否则回退到重新计算
|
||||||
|
const { hasStoredCost } = usage
|
||||||
const costData = CostCalculator.calculateCost(usageData, model)
|
const costData = CostCalculator.calculateCost(usageData, model)
|
||||||
|
|
||||||
|
// 如果有存储的费用,覆盖计算的费用
|
||||||
|
if (hasStoredCost) {
|
||||||
|
costData.costs.real = (usage.realCostMicro || 0) / 1000000
|
||||||
|
costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000
|
||||||
|
costData.costs.total = costData.costs.real // 保持兼容
|
||||||
|
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
modelStats.push({
|
modelStats.push({
|
||||||
model,
|
model,
|
||||||
requests: usage.requests,
|
requests: usage.requests,
|
||||||
@@ -768,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
allTokens: usage.allTokens,
|
allTokens: usage.allTokens,
|
||||||
costs: costData.costs,
|
costs: costData.costs,
|
||||||
formatted: costData.formatted,
|
formatted: costData.formatted,
|
||||||
pricing: costData.pricing
|
pricing: costData.pricing,
|
||||||
|
isLegacy: !hasStoredCost
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,13 +873,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// maxTokens 白名单
|
||||||
|
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
|
||||||
|
const sanitizeMaxTokens = (value) =>
|
||||||
|
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
|
||||||
|
|
||||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||||
router.post('/api-key/test', async (req, res) => {
|
router.post('/api-key/test', async (req, res) => {
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -830,7 +918,7 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
apiUrl,
|
apiUrl,
|
||||||
authorization: apiKey,
|
authorization: apiKey,
|
||||||
responseStream: res,
|
responseStream: res,
|
||||||
payload: createClaudeTestPayload(model, { stream: true }),
|
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
extraHeaders: { 'x-api-key': apiKey }
|
extraHeaders: { 'x-api-key': apiKey }
|
||||||
})
|
})
|
||||||
@@ -840,13 +928,317 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'Test failed',
|
error: 'Test failed',
|
||||||
message: error.message || 'Internal server error'
|
message: getSafeMessage(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write(
|
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🧪 Gemini API Key 端点测试接口
|
||||||
|
router.post('/api-key/test-gemini', async (req, res) => {
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'API Key is required',
|
||||||
|
message: 'Please provide your API Key'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid API key format',
|
||||||
|
message: 'API key format is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
message: validation.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Gemini 权限
|
||||||
|
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Permission denied',
|
||||||
|
message: 'This API key does not have Gemini permission'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const port = config.server.port || 3000
|
||||||
|
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||||
|
|
||||||
|
const axios = require('axios')
|
||||||
|
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(apiUrl, payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const chunks = []
|
||||||
|
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
response.data.on('end', () => {
|
||||||
|
const errorData = Buffer.concat(chunks).toString()
|
||||||
|
let errorMsg = `API Error: ${response.status}`
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(errorData)
|
||||||
|
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||||
|
} catch {
|
||||||
|
if (errorData.length < 200) {
|
||||||
|
errorMsg = errorData || errorMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString()
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const jsonStr = line.substring(5).trim()
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
// Gemini 格式: candidates[0].content.parts[0].text
|
||||||
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||||
|
if (text) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
} catch (axiosError) {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Gemini API Key test failed:', error)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Test failed',
|
||||||
|
message: getSafeMessage(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🧪 OpenAI/Codex API Key 端点测试接口
|
||||||
|
router.post('/api-key/test-openai', async (req, res) => {
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'API Key is required',
|
||||||
|
message: 'Please provide your API Key'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid API key format',
|
||||||
|
message: 'API key format is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
message: validation.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 OpenAI 权限
|
||||||
|
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Permission denied',
|
||||||
|
message: 'This API key does not have OpenAI permission'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const port = config.server.port || 3000
|
||||||
|
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||||
|
|
||||||
|
const axios = require('axios')
|
||||||
|
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(apiUrl, payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'User-Agent': 'codex_cli_rs/1.0.0'
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const chunks = []
|
||||||
|
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
response.data.on('end', () => {
|
||||||
|
const errorData = Buffer.concat(chunks).toString()
|
||||||
|
let errorMsg = `API Error: ${response.status}`
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(errorData)
|
||||||
|
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||||
|
} catch {
|
||||||
|
if (errorData.length < 200) {
|
||||||
|
errorMsg = errorData || errorMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString()
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const jsonStr = line.substring(5).trim()
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
// OpenAI Responses 格式: output[].content[].text 或 delta
|
||||||
|
if (data.type === 'response.output_text.delta' && data.delta) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
|
||||||
|
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
} catch (axiosError) {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI API Key test failed:', error)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Test failed',
|
||||||
|
message: getSafeMessage(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -875,7 +1267,7 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
keyData = await redis.getApiKey(apiId)
|
keyData = await redis.getApiKey(apiId)
|
||||||
|
|
||||||
if (!keyData || Object.keys(keyData).length === 0) {
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'API key not found',
|
error: 'API key not found',
|
||||||
message: 'The specified API key does not exist'
|
message: 'The specified API key does not exist'
|
||||||
@@ -931,33 +1323,37 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
||||||
const client = redis.getClientSafe()
|
const _client = redis.getClientSafe()
|
||||||
// 使用与管理页面相同的时区处理逻辑
|
// 使用与管理页面相同的时区处理逻辑
|
||||||
const tzDate = redis.getDateInTimezone()
|
const tzDate = redis.getDateInTimezone()
|
||||||
const today = redis.getDateStringInTimezone()
|
const today = redis.getDateStringInTimezone()
|
||||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
const pattern =
|
let pattern
|
||||||
period === 'daily'
|
let matchRegex
|
||||||
? `usage:${keyId}:model:daily:*:${today}`
|
if (period === 'daily') {
|
||||||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
pattern = `usage:${keyId}:model:daily:*:${today}`
|
||||||
|
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
|
} else if (period === 'alltime') {
|
||||||
|
pattern = `usage:${keyId}:model:alltime:*`
|
||||||
|
matchRegex = /usage:.+:model:alltime:(.+)$/
|
||||||
|
} else {
|
||||||
|
// monthly
|
||||||
|
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||||
|
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||||
|
}
|
||||||
|
|
||||||
const keys = await client.keys(pattern)
|
const results = await redis.scanAndGetAllChunked(pattern)
|
||||||
const modelStats = []
|
const modelStats = []
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const { key, data } of results) {
|
||||||
const match = key.match(
|
const match = key.match(matchRegex)
|
||||||
period === 'daily'
|
|
||||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
|
||||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = match[1]
|
const model = match[1]
|
||||||
const data = await client.hgetall(key)
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
const usage = {
|
const usage = {
|
||||||
@@ -967,8 +1363,30 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用存储的费用,否则回退到重新计算
|
||||||
|
// 检查字段是否存在(而非 > 0),以支持真正的零成本场景
|
||||||
|
const realCostMicro = parseInt(data.realCostMicro) || 0
|
||||||
|
const ratedCostMicro = parseInt(data.ratedCostMicro) || 0
|
||||||
|
const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data
|
||||||
const costData = CostCalculator.calculateCost(usage, model)
|
const costData = CostCalculator.calculateCost(usage, model)
|
||||||
|
|
||||||
|
// 如果有存储的费用,覆盖计算的费用
|
||||||
|
if (hasStoredCost) {
|
||||||
|
costData.costs.real = realCostMicro / 1000000
|
||||||
|
costData.costs.rated = ratedCostMicro / 1000000
|
||||||
|
costData.costs.total = costData.costs.real
|
||||||
|
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// alltime 键不存储 allTokens,需要计算
|
||||||
|
const allTokens =
|
||||||
|
period === 'alltime'
|
||||||
|
? usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
: parseInt(data.allTokens) || 0
|
||||||
|
|
||||||
modelStats.push({
|
modelStats.push({
|
||||||
model,
|
model,
|
||||||
requests: parseInt(data.requests) || 0,
|
requests: parseInt(data.requests) || 0,
|
||||||
@@ -976,10 +1394,11 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
cacheReadTokens: usage.cache_read_input_tokens,
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
allTokens: parseInt(data.allTokens) || 0,
|
allTokens,
|
||||||
costs: costData.costs,
|
costs: costData.costs,
|
||||||
formatted: costData.formatted,
|
formatted: costData.formatted,
|
||||||
pricing: costData.pricing
|
pricing: costData.pricing,
|
||||||
|
isLegacy: !hasStoredCost
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1007,4 +1426,170 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 📊 获取服务倍率配置(公开接口)
|
||||||
|
router.get('/service-rates', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rates = await serviceRatesService.getRates()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to retrieve service rates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份)
|
||||||
|
router.post('/api/redeem-card', async (req, res) => {
|
||||||
|
const quotaCardService = require('../services/quotaCardService')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiId, code } = req.body
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
const hour = new Date().toISOString().slice(0, 13)
|
||||||
|
|
||||||
|
// 防暴力破解:检查失败锁定
|
||||||
|
const failKey = `redeem_card:fail:${clientIP}`
|
||||||
|
const failCount = parseInt((await redis.client.get(failKey)) || '0')
|
||||||
|
if (failCount >= 5) {
|
||||||
|
logger.security(`🔒 Card redemption locked for IP: ${clientIP}`)
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: '失败次数过多,请1小时后再试'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防暴力破解:检查 IP 速率限制
|
||||||
|
const ipKey = `redeem_card:ip:${clientIP}:${hour}`
|
||||||
|
const ipCount = await redis.client.incr(ipKey)
|
||||||
|
await redis.client.expire(ipKey, 3600)
|
||||||
|
if (ipCount > 10) {
|
||||||
|
logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`)
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: '请求过于频繁,请稍后再试'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiId || !code) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '请输入卡号'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 apiId 格式
|
||||||
|
if (
|
||||||
|
typeof apiId !== 'string' ||
|
||||||
|
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API ID 格式无效'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 存在且有效
|
||||||
|
const keyData = await redis.getApiKey(apiId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API Key 不存在'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.isActive !== 'true') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API Key 已禁用'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用兑换服务
|
||||||
|
const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats')
|
||||||
|
|
||||||
|
// 成功时清除失败计数(静默处理,不影响成功响应)
|
||||||
|
redis.client.del(failKey).catch(() => {})
|
||||||
|
|
||||||
|
logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 失败时增加失败计数(静默处理,不影响错误响应)
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
const failKey = `redeem_card:fail:${clientIP}`
|
||||||
|
redis.client
|
||||||
|
.incr(failKey)
|
||||||
|
.then(() => redis.client.expire(failKey, 3600))
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
logger.error('❌ Failed to redeem card:', error)
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📋 公开的兑换记录查询接口(通过 apiId 验证身份)
|
||||||
|
router.get('/api/redemption-history', async (req, res) => {
|
||||||
|
const quotaCardService = require('../services/quotaCardService')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiId, limit = 50, offset = 0 } = req.query
|
||||||
|
|
||||||
|
if (!apiId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少 API ID'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 apiId 格式
|
||||||
|
if (
|
||||||
|
typeof apiId !== 'string' ||
|
||||||
|
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API ID 格式无效'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 存在
|
||||||
|
const keyData = await redis.getApiKey(apiId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API Key 不存在'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该 API Key 的兑换记录
|
||||||
|
const result = await quotaCardService.getRedemptions({
|
||||||
|
apiKeyId: apiId,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get redemption history:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class AtomicUsageReporter {
|
|||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
modelToRecord,
|
modelToRecord,
|
||||||
accountId
|
accountId,
|
||||||
|
'azure-openai'
|
||||||
)
|
)
|
||||||
|
|
||||||
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
|
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleStandardGenerateContent,
|
handleStandardGenerateContent,
|
||||||
handleStandardStreamGenerateContent,
|
handleStandardStreamGenerateContent,
|
||||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
|||||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v1internal 独有路由(listExperiments)
|
// v1internal 独有路由
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,12 @@ router.post(
|
|||||||
handleSimpleEndpoint('listExperiments')
|
handleSimpleEndpoint('listExperiments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||||
|
*/
|
||||||
|
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v1beta/models/:modelName:listExperiments
|
* POST /v1beta/models/:modelName:listExperiments
|
||||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ const router = express.Router()
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { authenticateApiKey } = require('../middleware/auth')
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
const claudeRelayService = require('../services/claudeRelayService')
|
const claudeRelayService = require('../services/claudeRelayService')
|
||||||
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||||
const openaiToClaude = require('../services/openaiToClaude')
|
const openaiToClaude = require('../services/openaiToClaude')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||||
|
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const pricingService = require('../services/pricingService')
|
const pricingService = require('../services/pricingService')
|
||||||
@@ -19,18 +21,24 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
|||||||
|
|
||||||
// 🔧 辅助函数:检查 API Key 权限
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(
|
||||||
|
rateLimitInfo,
|
||||||
|
usageSummary,
|
||||||
|
model,
|
||||||
|
context = '',
|
||||||
|
keyId = null,
|
||||||
|
accountType = null
|
||||||
|
) {
|
||||||
if (!rateLimitInfo) {
|
if (!rateLimitInfo) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = context ? ` (${context})` : ''
|
const label = context ? ` (${context})` : ''
|
||||||
|
|
||||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
|
||||||
.then(({ totalTokens, totalCost }) => {
|
.then(({ totalTokens, totalCost }) => {
|
||||||
if (totalTokens > 0) {
|
if (totalTokens > 0) {
|
||||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||||
@@ -235,7 +243,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const { accountId } = accountSelection
|
const { accountId, accountType } = accountSelection
|
||||||
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
// 获取该账号存储的 Claude Code headers
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
@@ -265,72 +273,107 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
// 创建 usage 回调函数
|
||||||
claudeRequest,
|
const usageCallback = (usage) => {
|
||||||
apiKeyData,
|
// 记录使用统计
|
||||||
res,
|
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||||
claudeCodeHeaders,
|
const model = usage.model || claudeRequest.model
|
||||||
(usage) => {
|
const cacheCreateTokens =
|
||||||
// 记录使用统计
|
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||||
const model = usage.model || claudeRequest.model
|
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||||
const cacheCreateTokens =
|
: usage.cache_creation_input_tokens || 0) || 0
|
||||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
|
||||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
|
||||||
: usage.cache_creation_input_tokens || 0) || 0
|
|
||||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
|
||||||
|
|
||||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(
|
.recordUsageWithDetails(
|
||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
model,
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error('❌ Failed to record usage:', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
|
||||||
req.rateLimitInfo,
|
|
||||||
{
|
|
||||||
inputTokens: usage.input_tokens || 0,
|
|
||||||
outputTokens: usage.output_tokens || 0,
|
|
||||||
cacheCreateTokens,
|
|
||||||
cacheReadTokens
|
|
||||||
},
|
|
||||||
model,
|
model,
|
||||||
'openai-claude-stream'
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
}
|
.catch((error) => {
|
||||||
},
|
logger.error('❌ Failed to record usage:', error)
|
||||||
// 流转换器
|
})
|
||||||
(() => {
|
|
||||||
// 为每个请求创建独立的会话ID
|
queueRateLimitUpdate(
|
||||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
req.rateLimitInfo,
|
||||||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
{
|
||||||
})(),
|
inputTokens: usage.input_tokens || 0,
|
||||||
{
|
outputTokens: usage.output_tokens || 0,
|
||||||
betaHeader:
|
cacheCreateTokens,
|
||||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
cacheReadTokens
|
||||||
|
},
|
||||||
|
model,
|
||||||
|
`openai-${accountType}-stream`,
|
||||||
|
req.apiKey?.id,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// 创建流转换器
|
||||||
|
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||||
|
const streamTransformer = (chunk) =>
|
||||||
|
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||||
|
|
||||||
|
// 根据账户类型选择转发服务
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
// Claude Console 账户使用 Console 转发服务
|
||||||
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
streamTransformer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer,
|
||||||
|
{
|
||||||
|
betaHeader:
|
||||||
|
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 非流式请求
|
// 非流式请求
|
||||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||||
|
|
||||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
// 根据账户类型选择转发服务
|
||||||
const claudeResponse = await claudeRelayService.relayRequest(
|
let claudeResponse
|
||||||
claudeRequest,
|
if (accountType === 'claude-console') {
|
||||||
apiKeyData,
|
// Claude Console 账户使用 Console 转发服务
|
||||||
req,
|
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||||
res,
|
claudeRequest,
|
||||||
claudeCodeHeaders,
|
apiKeyData,
|
||||||
{ betaHeader: 'oauth-2025-04-20' }
|
req,
|
||||||
)
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
claudeResponse = await claudeRelayService.relayRequest(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
{ betaHeader: 'oauth-2025-04-20' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 Claude 响应
|
// 解析 Claude 响应
|
||||||
let claudeData
|
let claudeData
|
||||||
@@ -376,7 +419,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
accountId
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record usage:', error)
|
logger.error('❌ Failed to record usage:', error)
|
||||||
@@ -391,7 +435,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
'openai-claude-non-stream'
|
`openai-${accountType}-non-stream`,
|
||||||
|
req.apiKey?.id,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,16 +448,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ OpenAI-Claude request error:', error)
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||||
|
} else {
|
||||||
|
logger.error('❌ OpenAI-Claude request error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
const status = error.status || 500
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
res.status(status).json({
|
if (!res.headersSent) {
|
||||||
error: {
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
message: error.message || 'Internal server error',
|
if (error.message === 'Client disconnected') {
|
||||||
type: 'server_error',
|
res.status(499).end()
|
||||||
code: 'internal_error'
|
} else {
|
||||||
|
const status = error.status || 500
|
||||||
|
res.status(status).json({
|
||||||
|
error: {
|
||||||
|
message: getSafeMessage(error),
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -19,10 +20,19 @@ function generateSessionHash(req) {
|
|||||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAntigravityProjectId(account) {
|
||||||
|
if (account.projectId) {
|
||||||
|
return account.projectId
|
||||||
|
}
|
||||||
|
if (account.tempProjectId) {
|
||||||
|
return account.tempProjectId
|
||||||
|
}
|
||||||
|
return `ag-${crypto.randomBytes(8).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 API Key 权限
|
// 检查 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 格式
|
||||||
@@ -335,25 +345,48 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
account.accessToken,
|
account.accessToken,
|
||||||
account.refreshToken,
|
account.refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
if (actualStream) {
|
if (actualStream) {
|
||||||
// 流式响应
|
// 流式响应
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
let { projectId } = account
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
projectId = ensureAntigravityProjectId(account)
|
||||||
|
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||||
|
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||||
|
account.tempProjectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('StreamGenerateContent request', {
|
logger.info('StreamGenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
const streamResponse =
|
||||||
client,
|
oauthProvider === 'antigravity'
|
||||||
{ model, request: geminiRequestBody },
|
? await geminiAccountService.generateContentStreamAntigravity(
|
||||||
null, // user_prompt_id
|
client,
|
||||||
account.projectId, // 使用有权限的项目ID
|
{ model, request: geminiRequestBody },
|
||||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
null, // user_prompt_id
|
||||||
abortController.signal, // 传递中止信号
|
projectId,
|
||||||
proxyConfig // 传递代理配置
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
)
|
abortController.signal, // 传递中止信号
|
||||||
|
proxyConfig // 传递代理配置
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContentStream(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
projectId, // 使用有权限的项目ID
|
||||||
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
|
abortController.signal, // 传递中止信号
|
||||||
|
proxyConfig // 传递代理配置
|
||||||
|
)
|
||||||
|
|
||||||
// 设置流式响应头
|
// 设置流式响应头
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
@@ -499,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,
|
||||||
@@ -507,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
0, // cacheCreateTokens
|
0, // cacheCreateTokens
|
||||||
0, // cacheReadTokens
|
0, // cacheReadTokens
|
||||||
model,
|
model,
|
||||||
account.id
|
account.id,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||||
@@ -559,20 +592,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 非流式响应
|
// 非流式响应
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
let { projectId } = account
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
projectId = ensureAntigravityProjectId(account)
|
||||||
|
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||||
|
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||||
|
account.tempProjectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('GenerateContent request', {
|
logger.info('GenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
client,
|
oauthProvider === 'antigravity'
|
||||||
{ model, request: geminiRequestBody },
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
null, // user_prompt_id
|
client,
|
||||||
account.projectId, // 使用有权限的项目ID
|
{ model, request: geminiRequestBody },
|
||||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
null, // user_prompt_id
|
||||||
proxyConfig // 传递代理配置
|
projectId,
|
||||||
)
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
|
proxyConfig // 传递代理配置
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContent(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
projectId, // 使用有权限的项目ID
|
||||||
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
|
proxyConfig // 传递代理配置
|
||||||
|
)
|
||||||
|
|
||||||
// 转换为 OpenAI 格式并返回
|
// 转换为 OpenAI 格式并返回
|
||||||
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
|
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
|
||||||
@@ -580,7 +634,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,
|
||||||
@@ -588,7 +641,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
0, // cacheCreateTokens
|
0, // cacheCreateTokens
|
||||||
0, // cacheReadTokens
|
0, // cacheReadTokens
|
||||||
model,
|
model,
|
||||||
account.id
|
account.id,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`
|
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`
|
||||||
@@ -604,7 +658,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OpenAI-Gemini request error:', error)
|
const statusForLog = error?.status || error?.response?.status
|
||||||
|
logger.error('OpenAI-Gemini request error', {
|
||||||
|
message: error?.message,
|
||||||
|
status: statusForLog,
|
||||||
|
code: error?.code,
|
||||||
|
requestUrl: error?.config?.url,
|
||||||
|
requestMethod: error?.config?.method,
|
||||||
|
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
|
||||||
|
})
|
||||||
|
|
||||||
// 处理速率限制
|
// 处理速率限制
|
||||||
if (error.status === 429) {
|
if (error.status === 429) {
|
||||||
@@ -613,17 +675,24 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 OpenAI 格式的错误响应
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
const status = error.status || 500
|
if (!res.headersSent) {
|
||||||
const errorResponse = {
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
error: error.error || {
|
if (error.message === 'Client disconnected') {
|
||||||
message: error.message || 'Internal server error',
|
res.status(499).end()
|
||||||
type: 'server_error',
|
} else {
|
||||||
code: 'internal_error'
|
// 返回 OpenAI 格式的错误响应
|
||||||
|
const status = error.status || 500
|
||||||
|
const errorResponse = {
|
||||||
|
error: error.error || {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(status).json(errorResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(status).json(errorResponse)
|
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
@@ -633,8 +702,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// OpenAI 兼容的模型列表端点
|
// 获取可用模型列表的共享处理器
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
async function handleGetModels(req, res) {
|
||||||
try {
|
try {
|
||||||
const apiKeyData = req.apiKey
|
const apiKeyData = req.apiKey
|
||||||
|
|
||||||
@@ -665,8 +734,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
let models = []
|
let models = []
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
// 获取实际的模型列表
|
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
try {
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
models =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
account.proxy,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
: await getAvailableModels(account.accessToken, account.proxy)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
|
||||||
|
models = []
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 返回默认模型列表
|
// 返回默认模型列表
|
||||||
models = [
|
models = [
|
||||||
@@ -679,6 +761,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用了模型限制,过滤模型列表
|
// 如果启用了模型限制,过滤模型列表
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||||
@@ -698,8 +791,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return undefined
|
}
|
||||||
})
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||||
|
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||||
|
router.get('/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
// OpenAI 兼容的模型详情端点
|
// OpenAI 兼容的模型详情端点
|
||||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ const openaiAccountService = require('../services/openaiAccountService')
|
|||||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
const redis = require('../models/redis')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
const { IncrementalSSEParser } = require('../utils/sseParser')
|
||||||
|
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||||
|
|
||||||
// 创建代理 Agent(使用统一的代理工具)
|
// 创建代理 Agent(使用统一的代理工具)
|
||||||
function createProxyAgent(proxy) {
|
function createProxyAgent(proxy) {
|
||||||
@@ -20,8 +23,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 = {}) {
|
||||||
@@ -68,7 +70,7 @@ function extractCodexUsageHeaders(headers) {
|
|||||||
return hasData ? snapshot : null
|
return hasData ? snapshot : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) {
|
||||||
if (!req.rateLimitInfo) {
|
if (!req.rateLimitInfo) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
|||||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||||
req.rateLimitInfo,
|
req.rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model
|
model,
|
||||||
|
req.apiKey?.id,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
|
|
||||||
if (totalTokens > 0) {
|
if (totalTokens > 0) {
|
||||||
@@ -275,7 +279,9 @@ const handleResponses = async (req, res) => {
|
|||||||
'text_formatting',
|
'text_formatting',
|
||||||
'truncation',
|
'truncation',
|
||||||
'text',
|
'text',
|
||||||
'service_tier'
|
'service_tier',
|
||||||
|
'prompt_cache_retention',
|
||||||
|
'safety_identifier'
|
||||||
]
|
]
|
||||||
fieldsToRemove.forEach((field) => {
|
fieldsToRemove.forEach((field) => {
|
||||||
delete req.body[field]
|
delete req.body[field]
|
||||||
@@ -576,7 +582,6 @@ const handleResponses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理响应并捕获 usage 数据和真实的 model
|
// 处理响应并捕获 usage 数据和真实的 model
|
||||||
let buffer = ''
|
|
||||||
let usageData = null
|
let usageData = null
|
||||||
let actualModel = null
|
let actualModel = null
|
||||||
let usageReported = false
|
let usageReported = false
|
||||||
@@ -612,7 +617,8 @@ const handleResponses = async (req, res) => {
|
|||||||
0, // OpenAI没有cache_creation_tokens
|
0, // OpenAI没有cache_creation_tokens
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
actualModel,
|
actualModel,
|
||||||
accountId
|
accountId,
|
||||||
|
'openai'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -628,7 +634,8 @@ const handleResponses = async (req, res) => {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
actualModel,
|
actualModel,
|
||||||
'openai-non-stream'
|
'openai-non-stream',
|
||||||
|
'openai'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,74 +651,50 @@ const handleResponses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 SSE 事件以捕获 usage 数据和 model
|
// 使用增量 SSE 解析器
|
||||||
const parseSSEForUsage = (data) => {
|
const sseParser = new IncrementalSSEParser()
|
||||||
const lines = data.split('\n')
|
|
||||||
|
|
||||||
for (const line of lines) {
|
// 处理解析出的事件
|
||||||
if (line.startsWith('event: response.completed')) {
|
const processSSEEvent = (eventData) => {
|
||||||
// 下一行应该是数据
|
// 检查是否是 response.completed 事件
|
||||||
continue
|
if (eventData.type === 'response.completed' && eventData.response) {
|
||||||
|
// 从响应中获取真实的 model
|
||||||
|
if (eventData.response.model) {
|
||||||
|
actualModel = eventData.response.model
|
||||||
|
logger.debug(`📊 Captured actual model: ${actualModel}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('data: ')) {
|
// 获取 usage 数据
|
||||||
try {
|
if (eventData.response.usage) {
|
||||||
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
|
usageData = eventData.response.usage
|
||||||
const eventData = JSON.parse(jsonStr)
|
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否是 response.completed 事件
|
// 检查是否有限流错误
|
||||||
if (eventData.type === 'response.completed' && eventData.response) {
|
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||||
// 从响应中获取真实的 model
|
rateLimitDetected = true
|
||||||
if (eventData.response.model) {
|
if (eventData.error.resets_in_seconds) {
|
||||||
actualModel = eventData.response.model
|
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||||
logger.debug(`📊 Captured actual model: ${actualModel}`)
|
logger.warn(
|
||||||
}
|
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||||
|
)
|
||||||
// 获取 usage 数据
|
|
||||||
if (eventData.response.usage) {
|
|
||||||
usageData = eventData.response.usage
|
|
||||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有限流错误
|
|
||||||
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
|
||||||
rateLimitDetected = true
|
|
||||||
if (eventData.error.resets_in_seconds) {
|
|
||||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
|
||||||
logger.warn(
|
|
||||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream.data.on('data', (chunk) => {
|
upstream.data.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
|
||||||
|
|
||||||
// 转发数据给客户端
|
// 转发数据给客户端
|
||||||
if (!res.destroyed) {
|
if (!res.destroyed) {
|
||||||
res.write(chunk)
|
res.write(chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同时解析数据以捕获 usage 信息
|
// 使用增量解析器处理数据
|
||||||
buffer += chunkStr
|
const events = sseParser.feed(chunk.toString())
|
||||||
|
for (const event of events) {
|
||||||
// 处理完整的 SSE 事件
|
if (event.type === 'data' && event.data) {
|
||||||
if (buffer.includes('\n\n')) {
|
processSSEEvent(event.data)
|
||||||
const events = buffer.split('\n\n')
|
|
||||||
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (event.trim()) {
|
|
||||||
parseSSEForUsage(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -721,8 +704,14 @@ const handleResponses = async (req, res) => {
|
|||||||
|
|
||||||
upstream.data.on('end', async () => {
|
upstream.data.on('end', async () => {
|
||||||
// 处理剩余的 buffer
|
// 处理剩余的 buffer
|
||||||
if (buffer.trim()) {
|
const remaining = sseParser.getRemaining()
|
||||||
parseSSEForUsage(buffer)
|
if (remaining.trim()) {
|
||||||
|
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'data' && event.data) {
|
||||||
|
processSSEEvent(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
@@ -744,7 +733,8 @@ const handleResponses = async (req, res) => {
|
|||||||
0, // OpenAI没有cache_creation_tokens
|
0, // OpenAI没有cache_creation_tokens
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
modelToRecord,
|
modelToRecord,
|
||||||
accountId
|
accountId,
|
||||||
|
'openai'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -761,7 +751,8 @@ const handleResponses = async (req, res) => {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
modelToRecord,
|
modelToRecord,
|
||||||
'openai-stream'
|
'openai-stream',
|
||||||
|
'openai'
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to record OpenAI usage:', error)
|
logger.error('Failed to record OpenAI usage:', error)
|
||||||
@@ -851,13 +842,15 @@ const handleResponses = async (req, res) => {
|
|||||||
|
|
||||||
let responsePayload = error.response?.data
|
let responsePayload = error.response?.data
|
||||||
if (!responsePayload) {
|
if (!responsePayload) {
|
||||||
responsePayload = { error: { message: error.message || 'Internal server error' } }
|
responsePayload = { error: { message: getSafeMessage(error) } }
|
||||||
} else if (typeof responsePayload === 'string') {
|
} else if (typeof responsePayload === 'string') {
|
||||||
responsePayload = { error: { message: responsePayload } }
|
responsePayload = { error: { message: getSafeMessage(responsePayload) } }
|
||||||
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
|
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
|
||||||
responsePayload = {
|
responsePayload = {
|
||||||
error: { message: responsePayload.message || error.message || 'Internal server error' }
|
error: { message: getSafeMessage(responsePayload.message || error) }
|
||||||
}
|
}
|
||||||
|
} else if (responsePayload.error?.message) {
|
||||||
|
responsePayload.error.message = getSafeMessage(responsePayload.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -875,16 +868,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
|
|||||||
// 使用情况统计端点
|
// 使用情况统计端点
|
||||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { usage } = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
object: 'usage',
|
object: 'usage',
|
||||||
total_tokens: usage.total.tokens,
|
total_tokens: usage?.total?.tokens || 0,
|
||||||
total_requests: usage.total.requests,
|
total_requests: usage?.total?.requests || 0,
|
||||||
daily_tokens: usage.daily.tokens,
|
daily_tokens: usage?.daily?.tokens || 0,
|
||||||
daily_requests: usage.daily.requests,
|
daily_requests: usage?.daily?.requests || 0,
|
||||||
monthly_tokens: usage.monthly.tokens,
|
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||||
monthly_requests: usage.monthly.requests
|
monthly_requests: usage?.monthly?.requests || 0
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get usage stats:', error)
|
logger.error('Failed to get usage stats:', error)
|
||||||
@@ -901,25 +896,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
|
|||||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const keyData = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
const tokensUsed = usage?.total?.tokens || 0
|
||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
description: keyData.description,
|
description: keyData.description,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: tokensUsed,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
keyData.tokenLimit > 0
|
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
|
||||||
: null,
|
|
||||||
rate_limit: {
|
rate_limit: {
|
||||||
window: keyData.rateLimitWindow,
|
window: keyData.rateLimitWindow,
|
||||||
requests: keyData.rateLimitRequests
|
requests: keyData.rateLimitRequests
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
total: keyData.usage.total,
|
total: usage?.total || {},
|
||||||
daily: keyData.usage.daily,
|
daily: usage?.daily || {},
|
||||||
monthly: keyData.usage.monthly
|
monthly: usage?.monthly || {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||||
|
|
||||||
// 检查权限
|
// 检查权限
|
||||||
const permissions = req.apiKey.permissions || 'all'
|
const { permissions } = req.apiKey
|
||||||
|
|
||||||
if (backend === 'claude') {
|
if (backend === 'claude') {
|
||||||
// Claude 后端:通过 OpenAI 兼容层
|
// Claude 后端:通过 OpenAI 兼容层
|
||||||
if (permissions !== 'all' && permissions !== 'claude') {
|
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access Claude',
|
message: 'This API key does not have permission to access Claude',
|
||||||
@@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
await handleChatCompletion(req, res, req.apiKey)
|
await handleChatCompletion(req, res, req.apiKey)
|
||||||
} else if (backend === 'openai') {
|
} else if (backend === 'openai') {
|
||||||
// OpenAI 后端
|
// OpenAI 后端
|
||||||
if (permissions !== 'all' && permissions !== 'openai') {
|
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access OpenAI',
|
message: 'This API key does not have permission to access OpenAI',
|
||||||
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
return await openaiRoutes.handleResponses(req, res)
|
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',
|
||||||
|
|||||||
@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 额度卡核销相关路由
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const quotaCardService = require('../services/quotaCardService')
|
||||||
|
|
||||||
|
// 🎫 核销额度卡
|
||||||
|
router.post('/redeem-card', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, apiKeyId } = req.body
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing card code',
|
||||||
|
message: 'Card code is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing API key ID',
|
||||||
|
message: 'API key ID is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 属于当前用户
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'API key not found',
|
||||||
|
message: 'The specified API key does not exist'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You can only redeem cards to your own API keys'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行核销
|
||||||
|
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
|
||||||
|
|
||||||
|
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Redeem card error:', error)
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Redeem failed',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📋 获取用户的核销历史
|
||||||
|
router.get('/redemption-history', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 50, offset = 0 } = req.query
|
||||||
|
|
||||||
|
const result = await quotaCardService.getRedemptions({
|
||||||
|
userId: req.user.id,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Get redemption history error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get redemption history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 获取用户的额度信息
|
||||||
|
router.get('/quota-info', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.query
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing API key ID',
|
||||||
|
message: 'API key ID is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 属于当前用户
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'API key not found',
|
||||||
|
message: 'The specified API key does not exist'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You can only view your own API key quota'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为聚合 Key
|
||||||
|
if (keyData.isAggregated !== 'true') {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isAggregated: false,
|
||||||
|
message: 'This is a traditional API key, not using quota system'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析聚合 Key 数据
|
||||||
|
let permissions = []
|
||||||
|
let serviceQuotaLimits = {}
|
||||||
|
let serviceQuotaUsed = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
permissions = JSON.parse(keyData.permissions || '[]')
|
||||||
|
} catch (e) {
|
||||||
|
permissions = [keyData.permissions]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
|
||||||
|
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isAggregated: true,
|
||||||
|
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||||
|
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||||
|
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
|
||||||
|
permissions,
|
||||||
|
serviceQuotaLimits,
|
||||||
|
serviceQuotaUsed,
|
||||||
|
expiresAt: keyData.expiresAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Get quota info error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get quota info',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ router.post('/auth/login', async (req, res) => {
|
|||||||
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
|
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
|
||||||
|
|
||||||
if (!isValidUsername || !isValidPassword) {
|
if (!isValidUsername || !isValidPassword) {
|
||||||
logger.security(`🔒 Failed login attempt for username: ${username}`)
|
logger.security(`Failed login attempt for username: ${username}`)
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid credentials',
|
error: 'Invalid credentials',
|
||||||
message: 'Invalid username or password'
|
message: 'Invalid username or password'
|
||||||
@@ -96,7 +96,7 @@ router.post('/auth/login', async (req, res) => {
|
|||||||
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
|
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
|
||||||
// init.json 是唯一真实数据源
|
// init.json 是唯一真实数据源
|
||||||
|
|
||||||
logger.success(`🔐 Admin login successful: ${username}`)
|
logger.success(`Admin login successful: ${username}`)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -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) {
|
||||||
@@ -183,7 +197,7 @@ router.post('/auth/change-password', async (req, res) => {
|
|||||||
// 验证当前密码
|
// 验证当前密码
|
||||||
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
|
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`)
|
logger.security(`Invalid current password attempt for user: ${sessionData.username}`)
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid current password',
|
error: 'Invalid current password',
|
||||||
message: 'Current password is incorrect'
|
message: 'Current password is incorrect'
|
||||||
@@ -239,7 +253,7 @@ router.post('/auth/change-password', async (req, res) => {
|
|||||||
// 清除当前会话(强制用户重新登录)
|
// 清除当前会话(强制用户重新登录)
|
||||||
await redis.deleteSession(token)
|
await redis.deleteSession(token)
|
||||||
|
|
||||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`)
|
logger.success(`Admin password changed successfully for user: ${updatedUsername}`)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -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)
|
||||||
|
|||||||
789
src/services/accountBalanceService.js
Normal file
789
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
const redis = require('../models/redis')
|
||||||
|
const balanceScriptService = require('./balanceScriptService')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
class AccountBalanceService {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.redis = options.redis || redis
|
||||||
|
this.logger = options.logger || logger
|
||||||
|
|
||||||
|
this.providers = new Map()
|
||||||
|
|
||||||
|
this.CACHE_TTL_SECONDS = 3600
|
||||||
|
this.LOCAL_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
this.LOW_BALANCE_THRESHOLD = 10
|
||||||
|
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||||
|
this.DEFAULT_CONCURRENCY = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedPlatforms() {
|
||||||
|
return [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'gemini',
|
||||||
|
'gemini-api',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'azure_openai',
|
||||||
|
'bedrock',
|
||||||
|
'droid',
|
||||||
|
'ccr'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(platform).trim().toLowerCase()
|
||||||
|
|
||||||
|
// 兼容实施文档与历史命名
|
||||||
|
if (value === 'claude-official') {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
if (value === 'azure-openai') {
|
||||||
|
return 'azure_openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持前端平台键一致
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider(platform, provider) {
|
||||||
|
const normalized = this.normalizePlatform(platform)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('registerProvider: 缺少 platform')
|
||||||
|
}
|
||||||
|
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||||
|
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||||
|
}
|
||||||
|
this.providers.set(normalized, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(accountId, platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccountBalance(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsBalance(platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const results = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
try {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||||
|
queryApi,
|
||||||
|
useCache
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: acc?.id,
|
||||||
|
platform: normalizedPlatform,
|
||||||
|
balance: null,
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message || '批量查询失败'
|
||||||
|
},
|
||||||
|
name: acc?.name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceSummary() {
|
||||||
|
const platforms = this.getSupportedPlatforms()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||||
|
const platformData = {
|
||||||
|
count: accounts.length,
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
accounts: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of balances) {
|
||||||
|
platformData.accounts.push(item)
|
||||||
|
|
||||||
|
const amount = item?.data?.balance?.amount
|
||||||
|
const percentage = item?.data?.quota?.percentage
|
||||||
|
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||||
|
|
||||||
|
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||||
|
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||||
|
const isHighUsage =
|
||||||
|
typeof percentage === 'number' &&
|
||||||
|
Number.isFinite(percentage) &&
|
||||||
|
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||||
|
|
||||||
|
if (hasAmount) {
|
||||||
|
platformData.totalBalance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowBalance || isHighUsage) {
|
||||||
|
platformData.lowBalanceCount += 1
|
||||||
|
summary.lowBalanceCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
platformData.totalCost += totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.platforms[platform] = platformData
|
||||||
|
summary.totalBalance += platformData.totalBalance
|
||||||
|
summary.totalCost += platformData.totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
if (!normalizedPlatform) {
|
||||||
|
throw new Error('缺少 platform 参数')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||||
|
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(accountId, platform) {
|
||||||
|
if (!accountId || !platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service || typeof service.getAccount !== 'function') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.getAccount(accountId)
|
||||||
|
|
||||||
|
// 处理不同服务返回格式的差异
|
||||||
|
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||||
|
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||||
|
return result.success ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsByPlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 特殊:返回 { success, data }
|
||||||
|
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||||
|
const result = await service.getAllAccounts()
|
||||||
|
return result?.success ? result.data || [] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'openai-responses') {
|
||||||
|
return await service.getAllAccounts(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof service.getAllAccounts !== 'function') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAllAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||||
|
const queryMode = this._parseQueryMode(options.queryApi)
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const accountId = account?.id
|
||||||
|
if (!accountId) {
|
||||||
|
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||||
|
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: '账户数据异常',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
'unknown',
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
{ scriptEnabled: false, scriptConfigured: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||||
|
let scriptConfig = null
|
||||||
|
let scriptConfigured = false
|
||||||
|
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||||
|
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||||
|
scriptConfigured = !!(
|
||||||
|
scriptConfig &&
|
||||||
|
scriptConfig.scriptBody &&
|
||||||
|
String(scriptConfig.scriptBody).trim().length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const scriptEnabled = isBalanceScriptEnabled()
|
||||||
|
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||||
|
|
||||||
|
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||||
|
const localStatistics = localBalance.statistics || {}
|
||||||
|
|
||||||
|
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||||
|
|
||||||
|
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||||||
|
const effectiveQueryMode =
|
||||||
|
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||||||
|
? 'local'
|
||||||
|
: queryMode
|
||||||
|
|
||||||
|
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||||||
|
if (effectiveQueryMode !== 'api') {
|
||||||
|
if (useCache) {
|
||||||
|
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||||
|
if (cached && cached.status === 'success') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: cached.status,
|
||||||
|
errorMessage: cached.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? cached.balance,
|
||||||
|
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || cached.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: cached.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'cache',
|
||||||
|
cached.ttlSeconds,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveQueryMode === 'local') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: localBalance.lastCalculated
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||||
|
let providerResult
|
||||||
|
|
||||||
|
if (scriptEnabled && scriptConfigured) {
|
||||||
|
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||||
|
} else {
|
||||||
|
const provider = this.providers.get(platform)
|
||||||
|
if (!provider) {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: `不支持的平台: ${platform}`,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemoteSuccess =
|
||||||
|
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||||
|
|
||||||
|
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||||
|
if (isRemoteSuccess) {
|
||||||
|
await this.redis.setAccountBalance(
|
||||||
|
platform,
|
||||||
|
accountId,
|
||||||
|
providerResult,
|
||||||
|
this.CACHE_TTL_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = isRemoteSuccess ? 'api' : 'local'
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: providerResult.status,
|
||||||
|
errorMessage: providerResult.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||||
|
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: providerResult.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
source,
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromScript(scriptConfig, accountId, platform) {
|
||||||
|
try {
|
||||||
|
const result = await balanceScriptService.execute({
|
||||||
|
scriptBody: scriptConfig.scriptBody,
|
||||||
|
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
|
||||||
|
variables: {
|
||||||
|
baseUrl: scriptConfig.baseUrl || '',
|
||||||
|
apiKey: scriptConfig.apiKey || '',
|
||||||
|
token: scriptConfig.token || '',
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
extra: scriptConfig.extra || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapped = result?.mapped || {}
|
||||||
|
return {
|
||||||
|
status: mapped.status || 'error',
|
||||||
|
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
|
||||||
|
currency: mapped.currency || 'USD',
|
||||||
|
quota: mapped.quota || null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: mapped.rawData || result?.response?.data || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: mapped.errorMessage || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '脚本执行失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromProvider(provider, account) {
|
||||||
|
try {
|
||||||
|
const result = await provider.queryBalance(account)
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||||
|
currency: result?.currency || 'USD',
|
||||||
|
quota: result?.quota || null,
|
||||||
|
queryMethod: result?.queryMethod || 'api',
|
||||||
|
rawData: result?.rawData || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '查询失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromLocal(accountId, platform) {
|
||||||
|
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||||
|
if (cached && cached.statistics) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await this._computeLocalStatistics(accountId)
|
||||||
|
const localBalance = {
|
||||||
|
status: 'success',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
statistics,
|
||||||
|
queryMethod: 'local',
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||||
|
return localBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeLocalStatistics(accountId) {
|
||||||
|
const safeNumber = (value) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||||
|
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||||
|
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||||
|
const totalCost = await this._computeTotalCost(accountId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost,
|
||||||
|
dailyCost,
|
||||||
|
monthlyCost,
|
||||||
|
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||||
|
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||||
|
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||||
|
return {
|
||||||
|
totalCost: 0,
|
||||||
|
dailyCost: 0,
|
||||||
|
monthlyCost: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
dailyRequests: 0,
|
||||||
|
monthlyRequests: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeMonthlyCost(accountId) {
|
||||||
|
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeTotalCost(accountId) {
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sumModelCostsByKeysPattern(pattern) {
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
let totalCost = 0
|
||||||
|
let cursor = '0'
|
||||||
|
const scanCount = 200
|
||||||
|
let iterations = 0
|
||||||
|
const maxIterations = 2000
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||||
|
cursor = nextCursor
|
||||||
|
iterations += 1
|
||||||
|
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const [, data] = results[i] || []
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(keys[i]).split(':')
|
||||||
|
const model = parts[4] || 'unknown'
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: parseInt(data.inputTokens || 0),
|
||||||
|
output_tokens: parseInt(data.outputTokens || 0),
|
||||||
|
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||||
|
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(usage, model)
|
||||||
|
totalCost += costResult.costs.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iterations >= maxIterations) {
|
||||||
|
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return totalCost
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildQuotaFromLocal(account, statistics) {
|
||||||
|
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return { balance: null, currency: null, quota: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = Number(account.dailyQuota || 0)
|
||||||
|
const used = Number(statistics?.dailyCost || 0)
|
||||||
|
|
||||||
|
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||||
|
|
||||||
|
// 不限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true,
|
||||||
|
resetAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
resetAt,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeNextResetAt(resetTime) {
|
||||||
|
const now = new Date()
|
||||||
|
const tzNow = this.redis.getDateInTimezone(now)
|
||||||
|
const offsetMs = tzNow.getTime() - now.getTime()
|
||||||
|
|
||||||
|
const [h, m] = String(resetTime || '00:00')
|
||||||
|
.split(':')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
|
||||||
|
const resetHour = Number.isFinite(h) ? h : 0
|
||||||
|
const resetMinute = Number.isFinite(m) ? m : 0
|
||||||
|
|
||||||
|
const year = tzNow.getUTCFullYear()
|
||||||
|
const month = tzNow.getUTCMonth()
|
||||||
|
const day = tzNow.getUTCDate()
|
||||||
|
|
||||||
|
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||||
|
if (resetAtMs <= now.getTime()) {
|
||||||
|
resetAtMs += 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(resetAtMs).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||||
|
const currency = balanceData.currency || 'USD'
|
||||||
|
|
||||||
|
let cacheExpiresAt = null
|
||||||
|
if (source === 'cache') {
|
||||||
|
const ttl =
|
||||||
|
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||||
|
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
balance:
|
||||||
|
typeof amount === 'number'
|
||||||
|
? {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
formattedAmount: this._formatCurrency(amount, currency)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quota: balanceData.quota || null,
|
||||||
|
statistics: balanceData.statistics || {},
|
||||||
|
source,
|
||||||
|
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||||
|
cacheExpiresAt,
|
||||||
|
status: balanceData.status || 'success',
|
||||||
|
error: balanceData.errorMessage || null,
|
||||||
|
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatCurrency(amount, currency = 'USD') {
|
||||||
|
try {
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||||
|
} catch (error) {
|
||||||
|
return `$${amount.toFixed(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseQueryMode(value) {
|
||||||
|
if (value === 'auto') {
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
const parsed = this._parseBoolean(value)
|
||||||
|
return parsed ? 'api' : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mapWithConcurrency(items, limit, mapper) {
|
||||||
|
const concurrency = Math.max(1, Number(limit) || 1)
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|
||||||
|
const results = new Array(list.length)
|
||||||
|
let nextIndex = 0
|
||||||
|
|
||||||
|
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||||
|
while (nextIndex < list.length) {
|
||||||
|
const currentIndex = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountBalanceService = new AccountBalanceService()
|
||||||
|
module.exports = accountBalanceService
|
||||||
|
module.exports.AccountBalanceService = AccountBalanceService
|
||||||
@@ -7,6 +7,62 @@ class AccountGroupService {
|
|||||||
this.GROUPS_KEY = 'account_groups'
|
this.GROUPS_KEY = 'account_groups'
|
||||||
this.GROUP_PREFIX = 'account_group:'
|
this.GROUP_PREFIX = 'account_group:'
|
||||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
|
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
|
||||||
|
this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:'
|
||||||
|
this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保反向索引存在(启动时自动调用)
|
||||||
|
* 检查是否已迁移,如果没有则自动回填
|
||||||
|
*/
|
||||||
|
async ensureReverseIndexes() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已迁移
|
||||||
|
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||||
|
if (migrated === 'true') {
|
||||||
|
logger.debug('📁 账户分组反向索引已存在,跳过回填')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('📁 开始回填账户分组反向索引...')
|
||||||
|
|
||||||
|
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||||
|
if (allGroupIds.length === 0) {
|
||||||
|
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalOperations = 0
|
||||||
|
|
||||||
|
for (const groupId of allGroupIds) {
|
||||||
|
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||||
|
if (!group || !group.platform) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||||
|
if (members.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const accountId of members) {
|
||||||
|
pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId)
|
||||||
|
}
|
||||||
|
await pipeline.exec()
|
||||||
|
totalOperations += members.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||||
|
logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations} 条`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 账户分组反向索引回填失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +106,7 @@ class AccountGroupService {
|
|||||||
// 添加到分组集合
|
// 添加到分组集合
|
||||||
await client.sadd(this.GROUPS_KEY, groupId)
|
await client.sadd(this.GROUPS_KEY, groupId)
|
||||||
|
|
||||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`)
|
logger.success(`创建账户分组成功: ${name} (${platform})`)
|
||||||
|
|
||||||
return group
|
return group
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,7 +157,7 @@ class AccountGroupService {
|
|||||||
// 返回更新后的完整数据
|
// 返回更新后的完整数据
|
||||||
const updatedGroup = await client.hgetall(groupKey)
|
const updatedGroup = await client.hgetall(groupKey)
|
||||||
|
|
||||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`)
|
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
|
||||||
|
|
||||||
return updatedGroup
|
return updatedGroup
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,7 +199,7 @@ class AccountGroupService {
|
|||||||
// 从分组集合中移除
|
// 从分组集合中移除
|
||||||
await client.srem(this.GROUPS_KEY, groupId)
|
await client.srem(this.GROUPS_KEY, groupId)
|
||||||
|
|
||||||
logger.success(`✅ 删除账户分组成功: ${group.name}`)
|
logger.success(`删除账户分组成功: ${group.name}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 删除账户分组失败:', error)
|
logger.error('❌ 删除账户分组失败:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -234,7 +290,10 @@ class AccountGroupService {
|
|||||||
// 添加到分组成员集合
|
// 添加到分组成员集合
|
||||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||||
|
|
||||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`)
|
// 维护反向索引
|
||||||
|
await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId)
|
||||||
|
|
||||||
|
logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 添加账户到分组失败:', error)
|
logger.error('❌ 添加账户到分组失败:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -245,15 +304,26 @@ class AccountGroupService {
|
|||||||
* 从分组移除账户
|
* 从分组移除账户
|
||||||
* @param {string} accountId - 账户ID
|
* @param {string} accountId - 账户ID
|
||||||
* @param {string} groupId - 分组ID
|
* @param {string} groupId - 分组ID
|
||||||
|
* @param {string} platform - 平台(可选,如果不传则从分组获取)
|
||||||
*/
|
*/
|
||||||
async removeAccountFromGroup(accountId, groupId) {
|
async removeAccountFromGroup(accountId, groupId, platform = null) {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
// 从分组成员集合中移除
|
// 从分组成员集合中移除
|
||||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||||
|
|
||||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`)
|
// 维护反向索引
|
||||||
|
let groupPlatform = platform
|
||||||
|
if (!groupPlatform) {
|
||||||
|
const group = await this.getGroup(groupId)
|
||||||
|
groupPlatform = group?.platform
|
||||||
|
}
|
||||||
|
if (groupPlatform) {
|
||||||
|
await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`从分组移除账户成功: ${accountId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 从分组移除账户失败:', error)
|
logger.error('❌ 从分组移除账户失败:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -399,7 +469,7 @@ class AccountGroupService {
|
|||||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 批量设置账户分组失败:', error)
|
logger.error('❌ 批量设置账户分组失败:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -409,8 +479,9 @@ class AccountGroupService {
|
|||||||
/**
|
/**
|
||||||
* 从所有分组中移除账户
|
* 从所有分组中移除账户
|
||||||
* @param {string} accountId - 账户ID
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台(可选,用于清理反向索引)
|
||||||
*/
|
*/
|
||||||
async removeAccountFromAllGroups(accountId) {
|
async removeAccountFromAllGroups(accountId, platform = null) {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||||
@@ -419,12 +490,155 @@ class AccountGroupService {
|
|||||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
// 清理反向索引
|
||||||
|
if (platform) {
|
||||||
|
await client.del(`account_groups_reverse:${platform}:${accountId}`)
|
||||||
|
} else {
|
||||||
|
// 如果没有指定平台,清理所有可能的平台
|
||||||
|
const platforms = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const p of platforms) {
|
||||||
|
pipeline.del(`account_groups_reverse:${p}:${accountId}`)
|
||||||
|
}
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`从所有分组移除账户成功: ${accountId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取多个账户的分组信息(性能优化版本,使用反向索引)
|
||||||
|
* @param {Array<string>} accountIds - 账户ID数组
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {Object} options - 选项
|
||||||
|
* @param {boolean} options.skipMemberCount - 是否跳过 memberCount(默认 true)
|
||||||
|
* @returns {Map<string, Array>} accountId -> 分组信息数组的映射
|
||||||
|
*/
|
||||||
|
async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) {
|
||||||
|
const { skipMemberCount = true } = options
|
||||||
|
|
||||||
|
if (!accountIds || accountIds.length === 0) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
|
// Pipeline 批量获取所有账户的分组ID
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`)
|
||||||
|
}
|
||||||
|
const groupIdResults = await pipeline.exec()
|
||||||
|
|
||||||
|
// 收集所有需要的分组ID
|
||||||
|
const uniqueGroupIds = new Set()
|
||||||
|
const accountGroupIdsMap = new Map()
|
||||||
|
let hasAnyGroups = false
|
||||||
|
accountIds.forEach((accountId, i) => {
|
||||||
|
const [err, groupIds] = groupIdResults[i]
|
||||||
|
const ids = err ? [] : groupIds || []
|
||||||
|
accountGroupIdsMap.set(accountId, ids)
|
||||||
|
ids.forEach((id) => {
|
||||||
|
uniqueGroupIds.add(id)
|
||||||
|
hasAnyGroups = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果反向索引全空,回退到原方法(兼容未迁移的数据)
|
||||||
|
if (!hasAnyGroups) {
|
||||||
|
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||||
|
if (migrated !== 'true') {
|
||||||
|
logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups')
|
||||||
|
const result = new Map()
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
try {
|
||||||
|
const groups = await this.getAccountGroups(accountId)
|
||||||
|
result.set(accountId, groups)
|
||||||
|
} catch {
|
||||||
|
result.set(accountId, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
|
||||||
|
const emptyIndexAccountIds = []
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const ids = accountGroupIdsMap.get(accountId) || []
|
||||||
|
if (ids.length === 0) {
|
||||||
|
emptyIndexAccountIds.push(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
|
||||||
|
// 部分账户索引缺失,逐个查询并补建
|
||||||
|
for (const accountId of emptyIndexAccountIds) {
|
||||||
|
try {
|
||||||
|
const groups = await this.getAccountGroups(accountId)
|
||||||
|
if (groups.length > 0) {
|
||||||
|
const groupIds = groups.map((g) => g.id)
|
||||||
|
accountGroupIdsMap.set(accountId, groupIds)
|
||||||
|
groupIds.forEach((id) => uniqueGroupIds.add(id))
|
||||||
|
// 异步补建反向索引
|
||||||
|
client
|
||||||
|
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略错误,保持空数组
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取分组详情
|
||||||
|
const groupDetailsMap = new Map()
|
||||||
|
if (uniqueGroupIds.size > 0) {
|
||||||
|
const detailPipeline = client.pipeline()
|
||||||
|
const groupIdArray = Array.from(uniqueGroupIds)
|
||||||
|
for (const groupId of groupIdArray) {
|
||||||
|
detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||||
|
if (!skipMemberCount) {
|
||||||
|
detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detailResults = await detailPipeline.exec()
|
||||||
|
|
||||||
|
const step = skipMemberCount ? 1 : 2
|
||||||
|
for (let i = 0; i < groupIdArray.length; i++) {
|
||||||
|
const groupId = groupIdArray[i]
|
||||||
|
const [err1, groupData] = detailResults[i * step]
|
||||||
|
if (!err1 && groupData && Object.keys(groupData).length > 0) {
|
||||||
|
const group = { ...groupData }
|
||||||
|
if (!skipMemberCount) {
|
||||||
|
const [err2, memberCount] = detailResults[i * step + 1]
|
||||||
|
group.memberCount = err2 ? 0 : memberCount || 0
|
||||||
|
}
|
||||||
|
groupDetailsMap.set(groupId, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建最终结果
|
||||||
|
const result = new Map()
|
||||||
|
for (const [accountId, groupIds] of accountGroupIdsMap) {
|
||||||
|
const groups = groupIds
|
||||||
|
.map((gid) => groupDetailsMap.get(gid))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
result.set(accountId, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 批量获取账户分组失败:', error)
|
||||||
|
return new Map(accountIds.map((id) => [id, []]))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new AccountGroupService()
|
module.exports = new AccountGroupService()
|
||||||
|
|||||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal 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
|
||||||
3094
src/services/anthropicGeminiBridgeService.js
Normal file
3094
src/services/anthropicGeminiBridgeService.js
Normal file
File diff suppressed because it is too large
Load Diff
595
src/services/antigravityClient.js
Normal file
595
src/services/antigravityClient.js
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const https = require('https')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
|
||||||
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const {
|
||||||
|
mapAntigravityUpstreamModel,
|
||||||
|
normalizeAntigravityModelInput,
|
||||||
|
getAntigravityModelMetadata
|
||||||
|
} = require('../utils/antigravityModel')
|
||||||
|
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
|
||||||
|
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
|
||||||
|
|
||||||
|
const keepAliveAgent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 30000,
|
||||||
|
timeout: 120000,
|
||||||
|
maxSockets: 100,
|
||||||
|
maxFreeSockets: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAntigravityApiUrl() {
|
||||||
|
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url) {
|
||||||
|
const str = String(url || '').trim()
|
||||||
|
return str.endsWith('/') ? str.slice(0, -1) : str
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityApiUrlCandidates() {
|
||||||
|
const configured = normalizeBaseUrl(getAntigravityApiUrl())
|
||||||
|
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
const prod = 'https://cloudcode-pa.googleapis.com'
|
||||||
|
|
||||||
|
// 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。
|
||||||
|
if (process.env.ANTIGRAVITY_API_URL) {
|
||||||
|
return [configured]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。
|
||||||
|
if (configured === normalizeBaseUrl(daily)) {
|
||||||
|
return [configured, prod]
|
||||||
|
}
|
||||||
|
if (configured === normalizeBaseUrl(prod)) {
|
||||||
|
return [configured, daily]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [configured, prod, daily].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityHeaders(accessToken, baseUrl) {
|
||||||
|
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
|
||||||
|
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
try {
|
||||||
|
host = new URL(resolvedBaseUrl).host || host
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Host: host,
|
||||||
|
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Encoding': 'gzip',
|
||||||
|
requestType: 'agent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravityProjectId() {
|
||||||
|
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravitySessionId() {
|
||||||
|
return `sess-${uuidv4()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravityProjectId(projectId, requestData) {
|
||||||
|
const candidate = projectId || requestData?.project || requestData?.projectId || null
|
||||||
|
return candidate || generateAntigravityProjectId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravitySessionId(sessionId, requestData) {
|
||||||
|
const candidate =
|
||||||
|
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
|
||||||
|
return candidate || generateAntigravitySessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
|
||||||
|
const model = mapAntigravityUpstreamModel(requestData?.model)
|
||||||
|
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
|
||||||
|
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
|
||||||
|
const requestPayload = {
|
||||||
|
...(requestData?.request || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.session_id !== undefined) {
|
||||||
|
delete requestPayload.session_id
|
||||||
|
}
|
||||||
|
requestPayload.sessionId = resolvedSessionId
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
project: resolvedProjectId,
|
||||||
|
requestId: `req-${uuidv4()}`,
|
||||||
|
model,
|
||||||
|
userAgent: 'antigravity',
|
||||||
|
request: {
|
||||||
|
...requestPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPromptId) {
|
||||||
|
envelope.user_prompt_id = userPromptId
|
||||||
|
envelope.userPromptId = userPromptId
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAntigravityEnvelope(envelope)
|
||||||
|
return { model, envelope }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityThinking(model, requestPayload) {
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generationConfig } = requestPayload
|
||||||
|
if (!generationConfig || typeof generationConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { thinkingConfig } = generationConfig
|
||||||
|
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedModel = normalizeAntigravityModelInput(model)
|
||||||
|
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
|
||||||
|
delete thinkingConfig.thinkingLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = getAntigravityModelMetadata(normalizedModel)
|
||||||
|
if (metadata && !metadata.thinking) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!metadata || !metadata.thinking) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetRaw = Number(thinkingConfig.thinkingBudget)
|
||||||
|
if (!Number.isFinite(budgetRaw)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let budget = Math.trunc(budgetRaw)
|
||||||
|
|
||||||
|
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
|
||||||
|
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
|
||||||
|
|
||||||
|
if (maxBudget !== null && budget > maxBudget) {
|
||||||
|
budget = maxBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
|
||||||
|
? generationConfig.maxOutputTokens
|
||||||
|
: null
|
||||||
|
let setDefaultMax = false
|
||||||
|
if (!effectiveMax && metadata.maxCompletionTokens) {
|
||||||
|
effectiveMax = metadata.maxCompletionTokens
|
||||||
|
setDefaultMax = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveMax && budget >= effectiveMax) {
|
||||||
|
budget = Math.max(0, effectiveMax - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minBudget !== null && budget >= 0 && budget < minBudget) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkingConfig.thinkingBudget = budget
|
||||||
|
if (setDefaultMax) {
|
||||||
|
generationConfig.maxOutputTokens = effectiveMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityEnvelope(envelope) {
|
||||||
|
if (!envelope || typeof envelope !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const model = String(envelope.model || '')
|
||||||
|
const requestPayload = envelope.request
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.safetySettings !== undefined) {
|
||||||
|
delete requestPayload.safetySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE)
|
||||||
|
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
|
||||||
|
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
|
||||||
|
if (existing?.mode !== 'NONE') {
|
||||||
|
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
|
||||||
|
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:非 Claude 模型移除 maxOutputTokens(Antigravity 环境不稳定)
|
||||||
|
normalizeAntigravityThinking(model, requestPayload)
|
||||||
|
if (!model.includes('claude')) {
|
||||||
|
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
|
||||||
|
delete requestPayload.generationConfig.maxOutputTokens
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude 模型:parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400)
|
||||||
|
if (!Array.isArray(requestPayload.tools)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of requestPayload.tools) {
|
||||||
|
if (!tool || typeof tool !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const decls = Array.isArray(tool.functionDeclarations)
|
||||||
|
? tool.functionDeclarations
|
||||||
|
: Array.isArray(tool.function_declarations)
|
||||||
|
? tool.function_declarations
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!decls) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decl of decls) {
|
||||||
|
if (!decl || typeof decl !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let schema =
|
||||||
|
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
|
||||||
|
if (typeof schema === 'string' && schema) {
|
||||||
|
try {
|
||||||
|
schema = JSON.parse(schema)
|
||||||
|
} catch (_) {
|
||||||
|
schema = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decl.parameters = cleanJsonSchemaForGemini(schema)
|
||||||
|
delete decl.parametersJsonSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
requestData,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
userPromptId = null,
|
||||||
|
stream = false,
|
||||||
|
signal = null,
|
||||||
|
params = null,
|
||||||
|
timeoutMs = null
|
||||||
|
}) {
|
||||||
|
const { model, envelope } = buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
let endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
|
||||||
|
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL,则不改变顺序。
|
||||||
|
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
|
||||||
|
const prodHost = 'cloudcode-pa.googleapis.com'
|
||||||
|
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
const ordered = []
|
||||||
|
for (const u of endpoints) {
|
||||||
|
if (String(u).includes(prodHost)) {
|
||||||
|
ordered.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const u of endpoints) {
|
||||||
|
if (!String(u).includes(prodHost)) {
|
||||||
|
ordered.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重并保持 prod -> daily 的稳定顺序
|
||||||
|
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
|
||||||
|
const av = String(a)
|
||||||
|
const bv = String(b)
|
||||||
|
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
|
||||||
|
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
|
||||||
|
return aScore - bScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRetryable = (error) => {
|
||||||
|
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||||
|
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status === 429) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
const data = error?.response?.data
|
||||||
|
const safeToString = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// axios responseType=stream 时,data 可能是 stream(存在循环引用),不能 JSON.stringify
|
||||||
|
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
try {
|
||||||
|
return value.toString('utf8')
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = safeToString(data)
|
||||||
|
const msg = (text || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
msg.includes('requested model is currently unavailable') ||
|
||||||
|
msg.includes('tool_use') ||
|
||||||
|
msg.includes('tool_result') ||
|
||||||
|
msg.includes('requested entity was not found') ||
|
||||||
|
msg.includes('not found')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
let retriedAfterDelay = false
|
||||||
|
|
||||||
|
const attemptRequest = async () => {
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
...(params ? { params } : {}),
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: envelope,
|
||||||
|
timeout: stream ? 0 : timeoutMs || 600000,
|
||||||
|
...(stream ? { responseType: 'stream' } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
axiosConfig.signal = signal
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dumpAntigravityUpstreamRequest({
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
url,
|
||||||
|
baseUrl,
|
||||||
|
params: axiosConfig.params || null,
|
||||||
|
headers: axiosConfig.headers,
|
||||||
|
envelope
|
||||||
|
}).catch(() => {})
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return { model, response }
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status || null
|
||||||
|
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && isRetryable(error)) {
|
||||||
|
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
|
||||||
|
status,
|
||||||
|
from: baseUrl,
|
||||||
|
to: endpoints[index + 1],
|
||||||
|
model
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await attemptRequest()
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||||
|
const data = error?.response?.data
|
||||||
|
|
||||||
|
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
|
||||||
|
const safeDataToString = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// stream 对象存在循环引用,不能 JSON.stringify
|
||||||
|
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
try {
|
||||||
|
return value.toString('utf8')
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = safeDataToString(data)
|
||||||
|
if (
|
||||||
|
msg.toLowerCase().includes('resource_exhausted') ||
|
||||||
|
msg.toLowerCase().includes('no capacity')
|
||||||
|
) {
|
||||||
|
retriedAfterDelay = true
|
||||||
|
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
return await attemptRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
const endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:fetchAvailableModels`
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: {},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && (status === 429 || status === 404)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity fetchAvailableModels failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countTokens({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
contents,
|
||||||
|
model,
|
||||||
|
timeoutMs = 30000
|
||||||
|
}) {
|
||||||
|
const upstreamModel = mapAntigravityUpstreamModel(model)
|
||||||
|
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
const endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:countTokens`
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: {
|
||||||
|
request: {
|
||||||
|
model: `models/${upstreamModel}`,
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && (status === 429 || status === 404)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity countTokens failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAntigravityApiUrl,
|
||||||
|
getAntigravityApiUrlCandidates,
|
||||||
|
getAntigravityHeaders,
|
||||||
|
buildAntigravityEnvelope,
|
||||||
|
request,
|
||||||
|
fetchAvailableModels,
|
||||||
|
countTokens
|
||||||
|
}
|
||||||
173
src/services/antigravityRelayService.js
Normal file
173
src/services/antigravityRelayService.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||||
|
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
|
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
const { contents, systemInstruction } = convertMessagesToGemini(messages)
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
model: requestedModel,
|
||||||
|
request: {
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: maxTokens,
|
||||||
|
candidateCount: 1,
|
||||||
|
topP: 0.95,
|
||||||
|
topK: 40
|
||||||
|
},
|
||||||
|
...(sessionId ? { sessionId } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInstruction) {
|
||||||
|
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestData
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||||
|
let buffer = ''
|
||||||
|
let totalUsage = {
|
||||||
|
promptTokenCount: 0,
|
||||||
|
candidatesTokenCount: 0,
|
||||||
|
totalTokenCount: 0
|
||||||
|
}
|
||||||
|
let usageRecorded = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of response.data) {
|
||||||
|
buffer += chunk.toString()
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonData = line
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonData = line.substring(6).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonData || jsonData === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData)
|
||||||
|
const payload = data?.response || data
|
||||||
|
|
||||||
|
if (payload?.usageMetadata) {
|
||||||
|
totalUsage = payload.usageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const openaiChunk = convertGeminiResponse(payload, model, true)
|
||||||
|
if (openaiChunk) {
|
||||||
|
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
|
||||||
|
const finishReason = openaiChunk.choices?.[0]?.finish_reason
|
||||||
|
if (finishReason === 'stop') {
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
|
||||||
|
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId,
|
||||||
|
'gemini'
|
||||||
|
)
|
||||||
|
usageRecorded = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore chunk parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId,
|
||||||
|
'gemini'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAntigravityRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature = 0.7,
|
||||||
|
maxTokens = 4096,
|
||||||
|
stream = false,
|
||||||
|
accessToken,
|
||||||
|
proxy,
|
||||||
|
apiKeyId,
|
||||||
|
signal,
|
||||||
|
projectId,
|
||||||
|
accountId = null
|
||||||
|
}) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
|
||||||
|
const requestData = buildRequestData({
|
||||||
|
messages,
|
||||||
|
model: requestedModel,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
|
sessionId: apiKeyId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig: proxy,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId: apiKeyId,
|
||||||
|
stream,
|
||||||
|
signal,
|
||||||
|
params: { alt: 'sse' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = response.data?.response || response.data
|
||||||
|
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
|
||||||
|
|
||||||
|
if (apiKeyId && openaiResponse?.usage) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
|
openaiResponse.usage.completion_tokens || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
requestedModel,
|
||||||
|
accountId,
|
||||||
|
'gemini'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendAntigravityRequest
|
||||||
|
}
|
||||||
654
src/services/apiKeyIndexService.js
Normal file
654
src/services/apiKeyIndexService.js
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
/**
|
||||||
|
* API Key 索引服务
|
||||||
|
* 维护 Sorted Set 索引以支持高效分页查询
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { randomUUID } = require('crypto')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class ApiKeyIndexService {
|
||||||
|
constructor() {
|
||||||
|
this.redis = null
|
||||||
|
this.INDEX_VERSION_KEY = 'apikey:index:version'
|
||||||
|
this.CURRENT_VERSION = 2 // 版本升级,触发重建
|
||||||
|
this.isBuilding = false
|
||||||
|
this.buildProgress = { current: 0, total: 0 }
|
||||||
|
|
||||||
|
// 索引键名
|
||||||
|
this.INDEX_KEYS = {
|
||||||
|
CREATED_AT: 'apikey:idx:createdAt',
|
||||||
|
LAST_USED_AT: 'apikey:idx:lastUsedAt',
|
||||||
|
NAME: 'apikey:idx:name',
|
||||||
|
ACTIVE_SET: 'apikey:set:active',
|
||||||
|
DELETED_SET: 'apikey:set:deleted',
|
||||||
|
ALL_SET: 'apikey:idx:all',
|
||||||
|
TAGS_ALL: 'apikey:tags:all' // 所有标签的集合
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化服务
|
||||||
|
*/
|
||||||
|
init(redis) {
|
||||||
|
this.redis = redis
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时检查并重建索引
|
||||||
|
*/
|
||||||
|
async checkAndRebuild() {
|
||||||
|
if (!this.redis) {
|
||||||
|
logger.warn('⚠️ ApiKeyIndexService: Redis not initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||||
|
|
||||||
|
// 始终检查并回填 hash_map(幂等操作,确保升级兼容)
|
||||||
|
this.rebuildHashMap().catch((err) => {
|
||||||
|
logger.error('❌ API Key hash_map 回填失败:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parseInt(version) >= this.CURRENT_VERSION) {
|
||||||
|
logger.info('✅ API Key 索引已是最新版本')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台异步重建,不阻塞启动
|
||||||
|
this.rebuildIndexes().catch((err) => {
|
||||||
|
logger.error('❌ API Key 索引重建失败:', err)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 检查 API Key 索引版本失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回填 apikey:hash_map(升级兼容)
|
||||||
|
* 扫描所有 API Key,确保 hash -> keyId 映射存在
|
||||||
|
*/
|
||||||
|
async rebuildHashMap() {
|
||||||
|
if (!this.redis) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const keyIds = await this.redis.scanApiKeyIds()
|
||||||
|
|
||||||
|
let rebuilt = 0
|
||||||
|
const BATCH_SIZE = 100
|
||||||
|
|
||||||
|
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||||
|
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
// 批量获取 API Key 数据
|
||||||
|
for (const keyId of batch) {
|
||||||
|
pipeline.hgetall(`apikey:${keyId}`)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
// 检查并回填缺失的映射
|
||||||
|
const fillPipeline = client.pipeline()
|
||||||
|
let needFill = false
|
||||||
|
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const keyData = results[j]?.[1]
|
||||||
|
if (keyData && keyData.apiKey) {
|
||||||
|
// keyData.apiKey 存储的是哈希值
|
||||||
|
const exists = await client.hexists('apikey:hash_map', keyData.apiKey)
|
||||||
|
if (!exists) {
|
||||||
|
fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j])
|
||||||
|
rebuilt++
|
||||||
|
needFill = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needFill) {
|
||||||
|
await fillPipeline.exec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rebuilt > 0) {
|
||||||
|
logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 回填 hash_map 失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查索引是否可用
|
||||||
|
*/
|
||||||
|
async isIndexReady() {
|
||||||
|
if (!this.redis || this.isBuilding) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||||
|
return parseInt(version) >= this.CURRENT_VERSION
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重建所有索引
|
||||||
|
*/
|
||||||
|
async rebuildIndexes() {
|
||||||
|
if (this.isBuilding) {
|
||||||
|
logger.warn('⚠️ API Key 索引正在重建中,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBuilding = true
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
logger.info('🔨 开始重建 API Key 索引...')
|
||||||
|
|
||||||
|
// 0. 先删除版本号,让 _checkIndexReady 返回 false,查询回退到 SCAN
|
||||||
|
await client.del(this.INDEX_VERSION_KEY)
|
||||||
|
|
||||||
|
// 1. 清除旧索引
|
||||||
|
const indexKeys = Object.values(this.INDEX_KEYS)
|
||||||
|
for (const key of indexKeys) {
|
||||||
|
await client.del(key)
|
||||||
|
}
|
||||||
|
// 清除标签索引(用 SCAN 避免阻塞)
|
||||||
|
let cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100)
|
||||||
|
cursor = newCursor
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await client.del(...keys)
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
// 2. 扫描所有 API Key
|
||||||
|
const keyIds = await this.redis.scanApiKeyIds()
|
||||||
|
this.buildProgress = { current: 0, total: keyIds.length }
|
||||||
|
|
||||||
|
logger.info(`📊 发现 ${keyIds.length} 个 API Key,开始建立索引...`)
|
||||||
|
|
||||||
|
// 3. 批量处理(每批 500 个)
|
||||||
|
const BATCH_SIZE = 500
|
||||||
|
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||||
|
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||||
|
const apiKeys = await this.redis.batchGetApiKeys(batch)
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
for (const apiKey of apiKeys) {
|
||||||
|
if (!apiKey || !apiKey.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = apiKey.id
|
||||||
|
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
|
||||||
|
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||||
|
const name = (apiKey.name || '').toLowerCase()
|
||||||
|
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||||
|
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||||
|
|
||||||
|
// 创建时间索引
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||||
|
|
||||||
|
// 最后使用时间索引
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||||
|
|
||||||
|
// 名称索引(用于排序,存储格式:name\0keyId)
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||||
|
|
||||||
|
// 全部集合
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||||
|
|
||||||
|
// 状态集合
|
||||||
|
if (isDeleted) {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
} else if (isActive) {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签索引
|
||||||
|
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag && typeof tag === 'string') {
|
||||||
|
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec()
|
||||||
|
this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length)
|
||||||
|
|
||||||
|
// 每批次后短暂让出 CPU
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新版本号
|
||||||
|
await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION)
|
||||||
|
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||||
|
logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ API Key 索引重建失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.isBuilding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加单个 API Key 到索引
|
||||||
|
*/
|
||||||
|
async addToIndex(apiKey) {
|
||||||
|
if (!this.redis || !apiKey || !apiKey.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const keyId = apiKey.id
|
||||||
|
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now()
|
||||||
|
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||||
|
const name = (apiKey.name || '').toLowerCase()
|
||||||
|
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||||
|
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||||
|
|
||||||
|
if (isDeleted) {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
} else if (isActive) {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
} else {
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签索引
|
||||||
|
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag && typeof tag === 'string') {
|
||||||
|
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新索引(状态、名称、标签变化时调用)
|
||||||
|
*/
|
||||||
|
async updateIndex(keyId, updates, oldData = {}) {
|
||||||
|
if (!this.redis || !keyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
// 更新名称索引
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
const oldName = (oldData.name || '').toLowerCase()
|
||||||
|
const newName = (updates.name || '').toLowerCase()
|
||||||
|
if (oldName !== newName) {
|
||||||
|
pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`)
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后使用时间索引
|
||||||
|
if (updates.lastUsedAt !== undefined) {
|
||||||
|
const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0
|
||||||
|
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态集合
|
||||||
|
if (updates.isActive !== undefined || updates.isDeleted !== undefined) {
|
||||||
|
const isActive = updates.isActive ?? oldData.isActive
|
||||||
|
const isDeleted = updates.isDeleted ?? oldData.isDeleted
|
||||||
|
|
||||||
|
if (isDeleted === true || isDeleted === 'true') {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
} else if (isActive === true || isActive === 'true') {
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
} else {
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新标签索引
|
||||||
|
const removedTags = []
|
||||||
|
if (updates.tags !== undefined) {
|
||||||
|
const oldTags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||||
|
const newTags = Array.isArray(updates.tags) ? updates.tags : []
|
||||||
|
|
||||||
|
// 移除旧标签
|
||||||
|
for (const tag of oldTags) {
|
||||||
|
if (tag && !newTags.includes(tag)) {
|
||||||
|
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||||
|
removedTags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 添加新标签
|
||||||
|
for (const tag of newTags) {
|
||||||
|
if (tag && typeof tag === 'string') {
|
||||||
|
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||||
|
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec()
|
||||||
|
|
||||||
|
// 检查被移除的标签集合是否为空,为空则从 tags:all 移除
|
||||||
|
for (const tag of removedTags) {
|
||||||
|
const count = await client.scard(`apikey:tag:${tag}`)
|
||||||
|
if (count === 0) {
|
||||||
|
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从索引中移除 API Key
|
||||||
|
*/
|
||||||
|
async removeFromIndex(keyId, oldData = {}) {
|
||||||
|
if (!this.redis || !keyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
const name = (oldData.name || '').toLowerCase()
|
||||||
|
|
||||||
|
pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId)
|
||||||
|
pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId)
|
||||||
|
pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||||
|
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||||
|
|
||||||
|
// 移除标签索引
|
||||||
|
const tags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag) {
|
||||||
|
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec()
|
||||||
|
|
||||||
|
// 检查标签集合是否为空,为空则从 tags:all 移除
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag) {
|
||||||
|
const count = await client.scard(`apikey:tag:${tag}`)
|
||||||
|
if (count === 0) {
|
||||||
|
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用索引进行分页查询
|
||||||
|
* 使用 ZINTERSTORE 优化,避免全量拉回内存
|
||||||
|
*/
|
||||||
|
async queryWithIndex(options = {}) {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
isActive,
|
||||||
|
tag,
|
||||||
|
excludeDeleted = true
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const tempSets = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 构建筛选集合
|
||||||
|
let filterSet = this.INDEX_KEYS.ALL_SET
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if (isActive === true || isActive === 'true') {
|
||||||
|
// 筛选活跃的
|
||||||
|
filterSet = this.INDEX_KEYS.ACTIVE_SET
|
||||||
|
} else if (isActive === false || isActive === 'false') {
|
||||||
|
// 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted)
|
||||||
|
const tempKey = `apikey:tmp:inactive:${randomUUID()}`
|
||||||
|
if (excludeDeleted) {
|
||||||
|
await client.sdiffstore(
|
||||||
|
tempKey,
|
||||||
|
this.INDEX_KEYS.ALL_SET,
|
||||||
|
this.INDEX_KEYS.ACTIVE_SET,
|
||||||
|
this.INDEX_KEYS.DELETED_SET
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET)
|
||||||
|
}
|
||||||
|
await client.expire(tempKey, 60)
|
||||||
|
filterSet = tempKey
|
||||||
|
tempSets.push(tempKey)
|
||||||
|
} else if (excludeDeleted) {
|
||||||
|
// 排除已删除:ALL - DELETED
|
||||||
|
const tempKey = `apikey:tmp:notdeleted:${randomUUID()}`
|
||||||
|
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET)
|
||||||
|
await client.expire(tempKey, 60)
|
||||||
|
filterSet = tempKey
|
||||||
|
tempSets.push(tempKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签筛选
|
||||||
|
if (tag) {
|
||||||
|
const tagSet = `apikey:tag:${tag}`
|
||||||
|
const tempKey = `apikey:tmp:tag:${randomUUID()}`
|
||||||
|
await client.sinterstore(tempKey, filterSet, tagSet)
|
||||||
|
await client.expire(tempKey, 60)
|
||||||
|
filterSet = tempKey
|
||||||
|
tempSets.push(tempKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取筛选后的 keyId 集合
|
||||||
|
const filterMembers = await client.smembers(filterSet)
|
||||||
|
if (filterMembers.length === 0) {
|
||||||
|
// 没有匹配的数据
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||||||
|
availableTags: await this._getAvailableTags(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 排序
|
||||||
|
let sortedKeyIds
|
||||||
|
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
// 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const keyId of filterMembers) {
|
||||||
|
pipeline.hget(`apikey:${keyId}`, 'name')
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
// 组装并排序
|
||||||
|
const items = filterMembers.map((keyId, i) => ({
|
||||||
|
keyId,
|
||||||
|
name: (results[i]?.[1] || '').toLowerCase()
|
||||||
|
}))
|
||||||
|
items.sort((a, b) =>
|
||||||
|
sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||||
|
)
|
||||||
|
sortedKeyIds = items.map((item) => item.keyId)
|
||||||
|
} else {
|
||||||
|
// createdAt / lastUsedAt 索引成员是 keyId,可以用 ZINTERSTORE
|
||||||
|
const sortIndex = this._getSortIndex(sortBy)
|
||||||
|
const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}`
|
||||||
|
tempSets.push(tempSortedKey)
|
||||||
|
|
||||||
|
// 将 filterSet 转换为 Sorted Set(所有分数为 0)
|
||||||
|
const filterZsetKey = `apikey:tmp:filter:${randomUUID()}`
|
||||||
|
tempSets.push(filterZsetKey)
|
||||||
|
|
||||||
|
const zaddArgs = []
|
||||||
|
for (const member of filterMembers) {
|
||||||
|
zaddArgs.push(0, member)
|
||||||
|
}
|
||||||
|
await client.zadd(filterZsetKey, ...zaddArgs)
|
||||||
|
await client.expire(filterZsetKey, 60)
|
||||||
|
|
||||||
|
// ZINTERSTORE:取交集,使用排序索引的分数(WEIGHTS 0 1)
|
||||||
|
await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1)
|
||||||
|
await client.expire(tempSortedKey, 60)
|
||||||
|
|
||||||
|
// 获取排序后的 keyId
|
||||||
|
sortedKeyIds =
|
||||||
|
sortOrder === 'desc'
|
||||||
|
? await client.zrevrange(tempSortedKey, 0, -1)
|
||||||
|
: await client.zrange(tempSortedKey, 0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 分页
|
||||||
|
const total = sortedKeyIds.length
|
||||||
|
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
|
||||||
|
const validPage = Math.min(Math.max(1, page), totalPages)
|
||||||
|
const start = (validPage - 1) * pageSize
|
||||||
|
const pageKeyIds = sortedKeyIds.slice(start, start + pageSize)
|
||||||
|
|
||||||
|
// 5. 获取数据
|
||||||
|
const items = await this.redis.batchGetApiKeys(pageKeyIds)
|
||||||
|
|
||||||
|
// 6. 获取所有标签
|
||||||
|
const availableTags = await this._getAvailableTags(client)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page: validPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
},
|
||||||
|
availableTags
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 7. 清理临时集合
|
||||||
|
for (const tempKey of tempSets) {
|
||||||
|
client.del(tempKey).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取排序索引键名
|
||||||
|
*/
|
||||||
|
_getSortIndex(sortBy) {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'createdAt':
|
||||||
|
return this.INDEX_KEYS.CREATED_AT
|
||||||
|
case 'lastUsedAt':
|
||||||
|
return this.INDEX_KEYS.LAST_USED_AT
|
||||||
|
case 'name':
|
||||||
|
return this.INDEX_KEYS.NAME
|
||||||
|
default:
|
||||||
|
return this.INDEX_KEYS.CREATED_AT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用标签(从 tags:all 集合)
|
||||||
|
*/
|
||||||
|
async _getAvailableTags(client) {
|
||||||
|
try {
|
||||||
|
const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL)
|
||||||
|
return tags.sort()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 lastUsedAt 索引(供 recordUsage 调用)
|
||||||
|
*/
|
||||||
|
async updateLastUsedAt(keyId, lastUsedAt) {
|
||||||
|
if (!this.redis || !keyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now()
|
||||||
|
await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取索引状态
|
||||||
|
*/
|
||||||
|
async getStatus() {
|
||||||
|
if (!this.redis) {
|
||||||
|
return { ready: false, building: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||||
|
const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: parseInt(version) >= this.CURRENT_VERSION,
|
||||||
|
building: this.isBuilding,
|
||||||
|
progress: this.buildProgress,
|
||||||
|
version: parseInt(version) || 0,
|
||||||
|
currentVersion: this.CURRENT_VERSION,
|
||||||
|
totalIndexed: totalCount
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ready: false, building: this.isBuilding }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例
|
||||||
|
const apiKeyIndexService = new ApiKeyIndexService()
|
||||||
|
|
||||||
|
module.exports = apiKeyIndexService
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,7 @@ async function createAccount(accountData) {
|
|||||||
|
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||||
|
await redisClient.addToIndex('azure_openai:account:index', accountId)
|
||||||
|
|
||||||
// 如果是共享账户,添加到共享账户集合
|
// 如果是共享账户,添加到共享账户集合
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
|
|||||||
// 从Redis中删除账户数据
|
// 从Redis中删除账户数据
|
||||||
await client.del(accountKey)
|
await client.del(accountKey)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
|
||||||
|
|
||||||
// 从共享账户集合中移除
|
// 从共享账户集合中移除
|
||||||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
|
|||||||
|
|
||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
async function getAllAccounts() {
|
async function getAllAccounts() {
|
||||||
const client = redisClient.getClientSafe()
|
const accountIds = await redisClient.getAllIdsByIndex(
|
||||||
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
'azure_openai:account:index',
|
||||||
|
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^azure_openai:account:(.+)$/
|
||||||
|
)
|
||||||
|
|
||||||
if (!keys || keys.length === 0) {
|
if (!accountIds || accountIds.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
for (const key of keys) {
|
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||||
const accountData = await client.hgetall(key)
|
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const accountData = dataList[i]
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
// 不返回敏感数据给前端
|
// 不返回敏感数据给前端
|
||||||
delete accountData.apiKey
|
delete accountData.apiKey
|
||||||
|
|||||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal 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
|
||||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const claudeAccountService = require('../claudeAccountService')
|
||||||
|
|
||||||
|
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude(OAuth):优先尝试获取 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
|
||||||
@@ -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
|
||||||
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const antigravityClient = require('../antigravityClient')
|
||||||
|
const geminiAccountService = require('../geminiAccountService')
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
|
|
||||||
|
function clamp01(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (value < 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (value > 1) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuotaCategory(displayName, modelId) {
|
||||||
|
const name = String(displayName || '')
|
||||||
|
const id = String(modelId || '')
|
||||||
|
|
||||||
|
if (name.includes('Gemini') && name.includes('Pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.includes('Flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('Claude') || name.includes('GPT-OSS')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (id.includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
if (id.includes('claude') || id.includes('gpt-oss')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || id || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityQuota(modelsResponse) {
|
||||||
|
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
|
||||||
|
|
||||||
|
if (!models || typeof models !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseRemainingFraction = (quotaInfo) => {
|
||||||
|
if (!quotaInfo || typeof quotaInfo !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
quotaInfo.remainingFraction ??
|
||||||
|
quotaInfo.remaining_fraction ??
|
||||||
|
quotaInfo.remaining ??
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp01(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
|
||||||
|
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||||
|
|
||||||
|
const categoryMap = new Map()
|
||||||
|
|
||||||
|
for (const [modelId, modelDataRaw] of Object.entries(models)) {
|
||||||
|
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
|
||||||
|
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
|
||||||
|
|
||||||
|
const remainingFraction = parseRemainingFraction(quotaInfo)
|
||||||
|
if (remainingFraction === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPercent = round2(remainingFraction * 100)
|
||||||
|
const usedPercent = round2(100 - remainingPercent)
|
||||||
|
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
|
||||||
|
|
||||||
|
const category = normalizeQuotaCategory(displayName, modelId)
|
||||||
|
if (!allowedCategories.has(category)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
category,
|
||||||
|
modelId,
|
||||||
|
displayName: String(displayName || modelId || category),
|
||||||
|
remainingPercent,
|
||||||
|
usedPercent,
|
||||||
|
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = categoryMap.get(category)
|
||||||
|
if (!existing || entry.remainingPercent < existing.remainingPercent) {
|
||||||
|
categoryMap.set(category, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = fixedOrder.map((category) => {
|
||||||
|
const existing = categoryMap.get(category) || null
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
modelId: '',
|
||||||
|
displayName: category,
|
||||||
|
remainingPercent: null,
|
||||||
|
usedPercent: null,
|
||||||
|
resetAt: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const critical = buckets
|
||||||
|
.filter((item) => item.remainingPercent !== null)
|
||||||
|
.reduce((min, item) => {
|
||||||
|
if (!min) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (!critical) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
type: 'antigravity',
|
||||||
|
total: 100,
|
||||||
|
used: critical.usedPercent,
|
||||||
|
remaining: critical.remainingPercent,
|
||||||
|
percentage: critical.usedPercent,
|
||||||
|
resetAt: critical.resetAt,
|
||||||
|
buckets: buckets.map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
remaining: item.remainingPercent,
|
||||||
|
used: item.usedPercent,
|
||||||
|
percentage: item.usedPercent,
|
||||||
|
resetAt: item.resetAt
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: {
|
||||||
|
modelsCount: Object.keys(models).length,
|
||||||
|
bucketCount: buckets.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeminiBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('gemini')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
const oauthProvider = account?.oauthProvider
|
||||||
|
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = String(account?.accessToken || '').trim()
|
||||||
|
const refreshToken = String(account?.refreshToken || '').trim()
|
||||||
|
const proxyConfig = account?.proxyConfig || account?.proxy || null
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Antigravity 账户缺少 accessToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = async (token) =>
|
||||||
|
await antigravityClient.fetchAvailableModels({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await fetch(accessToken)
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status
|
||||||
|
if ((status === 401 || status === 403) && refreshToken) {
|
||||||
|
const refreshed = await geminiAccountService.refreshAccessToken(
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
)
|
||||||
|
const nextToken = String(refreshed?.access_token || '').trim()
|
||||||
|
if (!nextToken) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
data = await fetch(nextToken)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = buildAntigravityQuota(data)
|
||||||
|
if (!mapped) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: data || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GeminiBalanceProvider
|
||||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal 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
|
||||||
25
src/services/balanceProviders/index.js
Normal file
25
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||||
|
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||||
|
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||||
|
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||||
|
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||||
|
|
||||||
|
function registerAllProviders(balanceService) {
|
||||||
|
// Claude
|
||||||
|
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||||
|
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||||
|
|
||||||
|
// OpenAI / Codex
|
||||||
|
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||||
|
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||||
|
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||||
|
|
||||||
|
// 其他平台(降级)
|
||||||
|
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
|
||||||
|
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||||
|
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||||
|
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||||
|
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAllProviders }
|
||||||
@@ -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
|
||||||
210
src/services/balanceScriptService.js
Normal file
210
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
const vm = require('vm')
|
||||||
|
const axios = require('axios')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF防护:检查URL是否访问内网或敏感地址
|
||||||
|
* @param {string} url - 要检查的URL
|
||||||
|
* @returns {boolean} - true表示URL安全
|
||||||
|
*/
|
||||||
|
function isUrlSafe(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const hostname = parsed.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// 禁止的协议
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止访问localhost和私有IP
|
||||||
|
const privatePatterns = [
|
||||||
|
/^localhost$/i,
|
||||||
|
/^127\./,
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
/^169\.254\./, // AWS metadata
|
||||||
|
/^0\./, // 0.0.0.0
|
||||||
|
/^::1$/,
|
||||||
|
/^fc00:/i,
|
||||||
|
/^fe80:/i,
|
||||||
|
/\.local$/i,
|
||||||
|
/\.internal$/i,
|
||||||
|
/\.localhost$/i
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of privatePatterns) {
|
||||||
|
if (pattern.test(hostname)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可配置脚本余额查询执行器
|
||||||
|
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||||
|
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||||
|
*/
|
||||||
|
class BalanceScriptService {
|
||||||
|
/**
|
||||||
|
* 执行脚本:返回标准余额结构 + 原始响应
|
||||||
|
* @param {object} options
|
||||||
|
* - scriptBody: string
|
||||||
|
* - variables: Record<string,string>
|
||||||
|
* - timeoutSeconds: number
|
||||||
|
*/
|
||||||
|
async execute(options = {}) {
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||||
|
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptBody = options.scriptBody?.trim()
|
||||||
|
if (!scriptBody) {
|
||||||
|
throw new Error('脚本内容为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
|
||||||
|
const sandbox = {
|
||||||
|
console,
|
||||||
|
Math,
|
||||||
|
Date
|
||||||
|
}
|
||||||
|
|
||||||
|
let scriptResult
|
||||||
|
try {
|
||||||
|
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||||
|
const script = new vm.Script(wrapped)
|
||||||
|
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`脚本解析失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptResult || typeof scriptResult !== 'object') {
|
||||||
|
throw new Error('脚本返回格式无效(需返回 { request, extractor })')
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = options.variables || {}
|
||||||
|
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||||
|
const { extractor } = scriptResult
|
||||||
|
|
||||||
|
if (!request?.url || typeof request.url !== 'string') {
|
||||||
|
throw new Error('脚本 request.url 不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF防护:验证URL安全性
|
||||||
|
if (!isUrlSafe(request.url)) {
|
||||||
|
throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof extractor !== 'function') {
|
||||||
|
throw new Error('脚本 extractor 必须是函数')
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url: request.url,
|
||||||
|
method: (request.method || 'GET').toUpperCase(),
|
||||||
|
headers: request.headers || {},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params) {
|
||||||
|
axiosConfig.params = request.params
|
||||||
|
}
|
||||||
|
if (request.body || request.data) {
|
||||||
|
axiosConfig.data = request.body || request.data
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResponse
|
||||||
|
try {
|
||||||
|
httpResponse = await axios(axiosConfig)
|
||||||
|
} catch (error) {
|
||||||
|
const { response } = error || {}
|
||||||
|
const { status, data } = response || {}
|
||||||
|
throw new Error(
|
||||||
|
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = httpResponse?.data
|
||||||
|
|
||||||
|
let extracted = {}
|
||||||
|
try {
|
||||||
|
extracted = extractor(responseData) || {}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||||
|
return {
|
||||||
|
mapped,
|
||||||
|
extracted,
|
||||||
|
response: {
|
||||||
|
status: httpResponse?.status,
|
||||||
|
headers: httpResponse?.headers,
|
||||||
|
data: responseData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTemplates(value, variables) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/{{(\w+)}}/g, (_, key) => {
|
||||||
|
const trimmed = key.trim()
|
||||||
|
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => this.applyTemplates(item, variables))
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const result = {}
|
||||||
|
Object.keys(value).forEach((k) => {
|
||||||
|
result[k] = this.applyTemplates(value[k], variables)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
mapExtractorResult(result = {}, responseData) {
|
||||||
|
const isValid = result.isValid !== false
|
||||||
|
const remaining = Number(result.remaining)
|
||||||
|
const total = Number(result.total)
|
||||||
|
const used = Number(result.used)
|
||||||
|
const currency = result.unit || 'USD'
|
||||||
|
|
||||||
|
const quota =
|
||||||
|
Number.isFinite(total) || Number.isFinite(used)
|
||||||
|
? {
|
||||||
|
total: Number.isFinite(total) ? total : null,
|
||||||
|
used: Number.isFinite(used) ? used : null,
|
||||||
|
remaining: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
percentage:
|
||||||
|
Number.isFinite(total) && total > 0 && Number.isFinite(used)
|
||||||
|
? (used / total) * 100
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: isValid ? 'success' : 'error',
|
||||||
|
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
|
||||||
|
balance: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
currency,
|
||||||
|
quota,
|
||||||
|
planName: result.planName || null,
|
||||||
|
extra: result.extra || null,
|
||||||
|
rawData: responseData || result.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new BalanceScriptService()
|
||||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
|||||||
description = '',
|
description = '',
|
||||||
region = process.env.AWS_REGION || 'us-east-1',
|
region = process.env.AWS_REGION || 'us-east-1',
|
||||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||||
|
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
isActive = true,
|
isActive = true,
|
||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -71,8 +72,14 @@ class BedrockAccountService {
|
|||||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加密存储 Bearer Token
|
||||||
|
if (bearerToken) {
|
||||||
|
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||||
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||||
|
await redis.addToIndex('bedrock_account:index', accountId)
|
||||||
|
|
||||||
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
|
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
|
||||||
|
|
||||||
@@ -106,9 +113,85 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = JSON.parse(accountData)
|
const account = JSON.parse(accountData)
|
||||||
|
|
||||||
// 解密AWS凭证用于内部使用
|
// 根据凭证类型解密对应的凭证
|
||||||
if (account.awsCredentials) {
|
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
try {
|
||||||
|
let accessKeyDecrypted = false
|
||||||
|
let bearerTokenDecrypted = false
|
||||||
|
|
||||||
|
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||||
|
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||||
|
// Access Key 模式:解密 AWS 凭证
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||||
|
// Bearer Token 模式:解密 Bearer Token
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||||
|
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||||
|
if (account.awsCredentials) {
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||||
|
)
|
||||||
|
if (account.awsCredentials) {
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证至少解密了一种凭证
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found in account data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (decryptError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||||
|
decryptError
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Credentials decryption failed: ${decryptError.message}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||||
@@ -126,12 +209,18 @@ class BedrockAccountService {
|
|||||||
// 📋 获取所有账户列表
|
// 📋 获取所有账户列表
|
||||||
async getAllAccounts() {
|
async getAllAccounts() {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const _client = redis.getClientSafe()
|
||||||
const keys = await client.keys('bedrock_account:*')
|
const accountIds = await redis.getAllIdsByIndex(
|
||||||
|
'bedrock_account:index',
|
||||||
|
'bedrock_account:*',
|
||||||
|
/^bedrock_account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = accountIds.map((id) => `bedrock_account:${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
const dataList = await redis.batchGetChunked(keys)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const accountData = await client.get(key)
|
const accountData = dataList[i]
|
||||||
if (accountData) {
|
if (accountData) {
|
||||||
const account = JSON.parse(accountData)
|
const account = JSON.parse(accountData)
|
||||||
|
|
||||||
@@ -155,7 +244,11 @@ class BedrockAccountService {
|
|||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
type: 'bedrock',
|
type: 'bedrock',
|
||||||
platform: 'bedrock',
|
platform: 'bedrock',
|
||||||
hasCredentials: !!account.awsCredentials
|
// 根据凭证类型判断是否有凭证
|
||||||
|
hasCredentials:
|
||||||
|
account.credentialType === 'bearer_token'
|
||||||
|
? !!account.bearerToken
|
||||||
|
: !!account.awsCredentials
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +328,15 @@ class BedrockAccountService {
|
|||||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新 Bearer Token
|
||||||
|
if (updates.bearerToken !== undefined) {
|
||||||
|
if (updates.bearerToken) {
|
||||||
|
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||||
|
} else {
|
||||||
|
delete account.bearerToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
if (updates.subscriptionExpiresAt !== undefined) {
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
@@ -280,6 +382,7 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
await client.del(`bedrock_account:${accountId}`)
|
await client.del(`bedrock_account:${accountId}`)
|
||||||
|
await redis.removeFromIndex('bedrock_account:index', accountId)
|
||||||
|
|
||||||
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
|
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
|
||||||
|
|
||||||
@@ -345,13 +448,45 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = accountResult.data
|
const account = accountResult.data
|
||||||
|
|
||||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.info(
|
||||||
|
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
// 尝试获取模型列表来测试连接
|
// 验证凭证是否已解密
|
||||||
|
const hasValidCredentials =
|
||||||
|
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||||
|
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||||
|
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||||
|
|
||||||
|
if (!hasValidCredentials) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found after decryption'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||||
|
try {
|
||||||
|
bedrockRelayService._getBedrockClient(account.region, account)
|
||||||
|
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||||
|
} catch (clientError) {
|
||||||
|
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||||
const models = await bedrockRelayService.getAvailableModels(account)
|
const models = await bedrockRelayService.getAvailableModels(account)
|
||||||
|
|
||||||
if (models && models.length > 0) {
|
if (models && models.length > 0) {
|
||||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
logger.info(
|
||||||
|
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -376,6 +511,135 @@ class BedrockAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {Object} res - Express response 对象
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
*/
|
||||||
|
async testAccountConnection(accountId, res, model = null) {
|
||||||
|
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const accountResult = await this.getAccount(accountId)
|
||||||
|
if (!accountResult.success) {
|
||||||
|
throw new Error(accountResult.error || 'Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accountResult.data
|
||||||
|
|
||||||
|
// 根据账户类型选择合适的测试模型
|
||||||
|
if (!model) {
|
||||||
|
// Access Key 模式使用 Haiku(更快更便宜)
|
||||||
|
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
res.status(200)
|
||||||
|
|
||||||
|
// 发送 test_start 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||||
|
|
||||||
|
// 构造测试请求体(Bedrock 格式)
|
||||||
|
const bedrockPayload = {
|
||||||
|
anthropic_version: 'bedrock-2023-05-31',
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Bedrock 客户端
|
||||||
|
const region = account.region || bedrockRelayService.defaultRegion
|
||||||
|
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||||
|
|
||||||
|
// 创建流式调用命令
|
||||||
|
const command = new InvokeModelWithResponseStreamCommand({
|
||||||
|
modelId: model,
|
||||||
|
body: JSON.stringify(bedrockPayload),
|
||||||
|
contentType: 'application/json',
|
||||||
|
accept: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
const response = await client.send(command)
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
// let responseText = ''
|
||||||
|
for await (const chunk of response.body) {
|
||||||
|
if (chunk.chunk) {
|
||||||
|
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||||
|
|
||||||
|
// 提取文本内容
|
||||||
|
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||||
|
const { text } = chunkData.delta
|
||||||
|
// responseText += text
|
||||||
|
|
||||||
|
// 发送 content 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测错误
|
||||||
|
if (chunkData.type === 'error') {
|
||||||
|
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||||
|
|
||||||
|
// 发送 message_stop 事件(前端兼容)
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||||
|
|
||||||
|
// 发送 test_complete 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
|
||||||
|
// 结束响应
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||||
|
|
||||||
|
// 发送错误事件给前端
|
||||||
|
try {
|
||||||
|
// 检查响应流是否仍然可写
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.status(200)
|
||||||
|
}
|
||||||
|
const errorMsg = error.message || '测试失败'
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Failed to write error to response stream:', writeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再重新抛出错误,避免路由层再次处理
|
||||||
|
// throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查账户订阅是否过期
|
* 检查账户订阅是否过期
|
||||||
* @param {Object} account - 账户对象
|
* @param {Object} account - 账户对象
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class BedrockRelayService {
|
class BedrockRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -47,13 +48,17 @@ class BedrockRelayService {
|
|||||||
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
||||||
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
||||||
}
|
}
|
||||||
|
} else if (bedrockAccount?.bearerToken) {
|
||||||
|
// Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量
|
||||||
|
clientConfig.token = { token: bedrockAccount.bearerToken }
|
||||||
|
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
|
||||||
} else {
|
} else {
|
||||||
// 检查是否有环境变量凭证
|
// 检查是否有环境变量凭证
|
||||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
clientConfig.credentials = fromEnv()
|
clientConfig.credentials = fromEnv()
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +74,68 @@ class BedrockRelayService {
|
|||||||
|
|
||||||
// 处理非流式请求
|
// 处理非流式请求
|
||||||
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -90,6 +156,23 @@ class BedrockRelayService {
|
|||||||
const response = await client.send(command)
|
const response = await client.send(command)
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||||
|
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析响应
|
// 解析响应
|
||||||
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
|
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
|
||||||
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
||||||
@@ -106,12 +189,94 @@ class BedrockRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理流式请求
|
// 处理流式请求
|
||||||
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
res.write(errorEvent)
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
return { success: false, error: errorType }
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -131,11 +296,35 @@ class BedrockRelayService {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const response = await client.send(command)
|
const response = await client.send(command)
|
||||||
|
|
||||||
|
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||||
|
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 设置SSE响应头
|
// 设置SSE响应头
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
|
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
|
||||||
|
if (existingConnection) {
|
||||||
|
logger.debug(
|
||||||
|
`🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}`
|
||||||
|
)
|
||||||
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: existingConnection || 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
})
|
})
|
||||||
@@ -154,8 +343,8 @@ class BedrockRelayService {
|
|||||||
res.write(`event: ${claudeEvent.type}\n`)
|
res.write(`event: ${claudeEvent.type}\n`)
|
||||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||||
|
|
||||||
// 提取使用统计
|
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||||
totalUsage = claudeEvent.data.usage
|
totalUsage = claudeEvent.data.usage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +380,21 @@ class BedrockRelayService {
|
|||||||
res.end()
|
res.end()
|
||||||
|
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +435,18 @@ class BedrockRelayService {
|
|||||||
_mapToBedrockModel(modelName) {
|
_mapToBedrockModel(modelName) {
|
||||||
// 标准Claude模型名到Bedrock模型名的映射表
|
// 标准Claude模型名到Bedrock模型名的映射表
|
||||||
const modelMapping = {
|
const modelMapping = {
|
||||||
|
// Claude 4.5 Opus
|
||||||
|
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Sonnet
|
||||||
|
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Haiku
|
||||||
|
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
|
||||||
// Claude Sonnet 4
|
// Claude Sonnet 4
|
||||||
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
@@ -360,14 +576,28 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_start',
|
type: 'message_start',
|
||||||
data: {
|
data: {
|
||||||
type: 'message',
|
type: 'message_start',
|
||||||
id: `msg_${Date.now()}_bedrock`,
|
message: {
|
||||||
role: 'assistant',
|
id: `msg_${Date.now()}_bedrock`,
|
||||||
content: [],
|
type: 'message',
|
||||||
model: this.defaultModel,
|
role: 'assistant',
|
||||||
stop_reason: null,
|
content: [],
|
||||||
stop_sequence: null,
|
model: this.defaultModel,
|
||||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_start') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: bedrockChunk.index || 0,
|
||||||
|
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,16 +606,28 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
index: bedrockChunk.index || 0,
|
index: bedrockChunk.index || 0,
|
||||||
delta: bedrockChunk.delta || {}
|
delta: bedrockChunk.delta || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_stop') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: bedrockChunk.index || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'message_delta') {
|
if (bedrockChunk.type === 'message_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'message_delta',
|
||||||
delta: bedrockChunk.delta || {},
|
delta: bedrockChunk.delta || {},
|
||||||
usage: bedrockChunk.usage || {}
|
usage: bedrockChunk.usage || {}
|
||||||
}
|
}
|
||||||
@@ -396,7 +638,7 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_stop',
|
type: 'message_stop',
|
||||||
data: {
|
data: {
|
||||||
usage: bedrockChunk.usage || {}
|
type: 'message_stop'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ class BillingEventPublisher {
|
|||||||
// MKSTREAM: 如果 stream 不存在则创建
|
// MKSTREAM: 如果 stream 不存在则创建
|
||||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||||
|
|
||||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
logger.success(`Created consumer group: ${groupName}`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes('BUSYGROUP')) {
|
if (error.message.includes('BUSYGROUP')) {
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
const { v4: uuidv4 } = require('uuid')
|
const { v4: uuidv4 } = require('uuid')
|
||||||
const crypto = require('crypto')
|
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const { createEncryptor } = require('../utils/commonHelper')
|
||||||
const LRUCache = require('../utils/lruCache')
|
|
||||||
|
|
||||||
class CcrAccountService {
|
class CcrAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 加密相关常量
|
|
||||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
|
||||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
|
||||||
|
|
||||||
// Redis键前缀
|
// Redis键前缀
|
||||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||||
|
|
||||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
// 使用 commonHelper 的加密器
|
||||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
this._encryptor = createEncryptor('ccr-account-salt')
|
||||||
this._encryptionKeyCache = null
|
|
||||||
|
|
||||||
// 🔄 解密结果缓存,提高解密性能
|
|
||||||
this._decryptCache = new LRUCache(500)
|
|
||||||
|
|
||||||
// 🧹 定期清理缓存(每10分钟)
|
// 🧹 定期清理缓存(每10分钟)
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
this._decryptCache.cleanup()
|
this._encryptor.clearCache()
|
||||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
|
||||||
},
|
},
|
||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
@@ -106,6 +96,7 @@ class CcrAccountService {
|
|||||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||||
|
|
||||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||||
|
await redis.addToIndex('ccr_account:index', accountId)
|
||||||
|
|
||||||
// 如果是共享账户,添加到共享账户集合
|
// 如果是共享账户,添加到共享账户集合
|
||||||
if (accountType === 'shared') {
|
if (accountType === 'shared') {
|
||||||
@@ -139,12 +130,17 @@ class CcrAccountService {
|
|||||||
// 📋 获取所有CCR账户
|
// 📋 获取所有CCR账户
|
||||||
async getAllAccounts() {
|
async getAllAccounts() {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const accountIds = await redis.getAllIdsByIndex(
|
||||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
'ccr_account:index',
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^ccr_account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
const dataList = await redis.batchHgetallChunked(keys)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const accountData = await client.hgetall(key)
|
const accountData = dataList[i]
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
// 获取限流状态信息
|
// 获取限流状态信息
|
||||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||||
@@ -331,6 +327,9 @@ class CcrAccountService {
|
|||||||
// 从共享账户集合中移除
|
// 从共享账户集合中移除
|
||||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redis.removeFromIndex('ccr_account:index', accountId)
|
||||||
|
|
||||||
// 删除账户数据
|
// 删除账户数据
|
||||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
|
||||||
@@ -403,7 +402,7 @@ class CcrAccountService {
|
|||||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
logger.success(`Removed rate limit for CCR account: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.hmset(accountKey, {
|
await client.hmset(accountKey, {
|
||||||
@@ -488,7 +487,7 @@ class CcrAccountService {
|
|||||||
errorMessage: ''
|
errorMessage: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
logger.success(`Removed overload status for CCR account: ${accountId}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||||
@@ -606,70 +605,12 @@ class CcrAccountService {
|
|||||||
|
|
||||||
// 🔐 加密敏感数据
|
// 🔐 加密敏感数据
|
||||||
_encryptSensitiveData(data) {
|
_encryptSensitiveData(data) {
|
||||||
if (!data) {
|
return this._encryptor.encrypt(data)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const key = this._generateEncryptionKey()
|
|
||||||
const iv = crypto.randomBytes(16)
|
|
||||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
|
||||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
|
||||||
encrypted += cipher.final('hex')
|
|
||||||
return `${iv.toString('hex')}:${encrypted}`
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('❌ CCR encryption error:', error)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔓 解密敏感数据
|
// 🔓 解密敏感数据
|
||||||
_decryptSensitiveData(encryptedData) {
|
_decryptSensitiveData(encryptedData) {
|
||||||
if (!encryptedData) {
|
return this._encryptor.decrypt(encryptedData)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 检查缓存
|
|
||||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
|
||||||
const cached = this._decryptCache.get(cacheKey)
|
|
||||||
if (cached !== undefined) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parts = encryptedData.split(':')
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const key = this._generateEncryptionKey()
|
|
||||||
const iv = Buffer.from(parts[0], 'hex')
|
|
||||||
const encrypted = parts[1]
|
|
||||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
|
||||||
decrypted += decipher.final('utf8')
|
|
||||||
|
|
||||||
// 💾 存入缓存(5分钟过期)
|
|
||||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
} else {
|
|
||||||
logger.error('❌ Invalid CCR encrypted data format')
|
|
||||||
return encryptedData
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('❌ CCR decryption error:', error)
|
|
||||||
return encryptedData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔑 生成加密密钥
|
|
||||||
_generateEncryptionKey() {
|
|
||||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
|
||||||
if (!this._encryptionKeyCache) {
|
|
||||||
this._encryptionKeyCache = crypto.scryptSync(
|
|
||||||
config.security.encryptionKey,
|
|
||||||
this.ENCRYPTION_SALT,
|
|
||||||
32
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return this._encryptionKeyCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 获取限流状态信息
|
// 🔍 获取限流状态信息
|
||||||
@@ -843,7 +784,7 @@ class CcrAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
|
||||||
return { success: true, resetCount }
|
return { success: true, resetCount }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||||
@@ -915,7 +856,7 @@ class CcrAccountService {
|
|||||||
await client.hset(accountKey, updates)
|
await client.hset(accountKey, updates)
|
||||||
await client.hdel(accountKey, ...fieldsToDelete)
|
await client.hdel(accountKey, ...fieldsToDelete)
|
||||||
|
|
||||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
logger.success(`Reset all error status for CCR account ${accountId}`)
|
||||||
|
|
||||||
// 异步发送 Webhook 通知(忽略错误)
|
// 异步发送 Webhook 通知(忽略错误)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const ccrAccountService = require('./ccrAccountService')
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
const { isStreamWritable } = require('../utils/streamHelper')
|
||||||
|
|
||||||
class CcrRelayService {
|
class CcrRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -21,8 +23,67 @@ class CcrRelayService {
|
|||||||
) {
|
) {
|
||||||
let abortController = null
|
let abortController = null
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for CCR account ${accountId}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -162,6 +223,23 @@ class CcrRelayService {
|
|||||||
)
|
)
|
||||||
const response = await axios(requestConfig)
|
const response = await axios(requestConfig)
|
||||||
|
|
||||||
|
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||||
|
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 移除监听器(请求成功完成)
|
// 移除监听器(请求成功完成)
|
||||||
if (clientRequest) {
|
if (clientRequest) {
|
||||||
clientRequest.removeListener('close', handleClientDisconnect)
|
clientRequest.removeListener('close', handleClientDisconnect)
|
||||||
@@ -233,6 +311,21 @@ class CcrRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +341,77 @@ class CcrRelayService {
|
|||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error(
|
||||||
|
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
|
||||||
|
)
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续<E5908E><E7BBAD>计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
|
responseStream.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
responseStream.write(errorEvent)
|
||||||
|
responseStream.write('data: [DONE]\n\n')
|
||||||
|
responseStream.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -296,14 +459,53 @@ class CcrRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer,
|
streamTransformer,
|
||||||
options
|
options,
|
||||||
|
// 📬 回调:在收到响应头时释放队列锁
|
||||||
|
async () => {
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 更新最后使用时间
|
// 更新最后使用时间
|
||||||
await this._updateLastUsedTime(accountId)
|
await this._updateLastUsedTime(accountId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info(
|
||||||
|
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +519,8 @@ class CcrRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
requestOptions = {}
|
requestOptions = {},
|
||||||
|
onResponseHeaderReceived = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let aborted = false
|
let aborted = false
|
||||||
@@ -380,8 +583,11 @@ class CcrRelayService {
|
|||||||
// 发送请求
|
// 发送请求
|
||||||
const request = axios(requestConfig)
|
const request = axios(requestConfig)
|
||||||
|
|
||||||
|
// 注意:使用 .then(async ...) 模式处理响应
|
||||||
|
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||||
|
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||||
request
|
request
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
@@ -404,10 +610,13 @@ class CcrRelayService {
|
|||||||
|
|
||||||
// 设置错误响应的状态码和响应头
|
// 设置错误响应的状态码和响应头
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
const errorHeaders = {
|
const errorHeaders = {
|
||||||
'Content-Type': response.headers['content-type'] || 'application/json',
|
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: existingConnection || 'keep-alive'
|
||||||
}
|
}
|
||||||
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||||
delete errorHeaders['Transfer-Encoding']
|
delete errorHeaders['Transfer-Encoding']
|
||||||
@@ -417,13 +626,13 @@ class CcrRelayService {
|
|||||||
|
|
||||||
// 直接透传错误数据,不进行包装
|
// 直接透传错误数据,不进行包装
|
||||||
response.data.on('data', (chunk) => {
|
response.data.on('data', (chunk) => {
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(chunk)
|
responseStream.write(chunk)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
resolve() // 不抛出异常,正常完成流处理
|
resolve() // 不抛出异常,正常完成流处理
|
||||||
@@ -431,6 +640,19 @@ class CcrRelayService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 收到成功响应头(HTTP 200),调用回调释放队列锁
|
||||||
|
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||||
|
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
|
||||||
|
try {
|
||||||
|
await onResponseHeaderReceived()
|
||||||
|
} catch (callbackError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
|
||||||
|
callbackError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 成功响应,检查并移除错误状态
|
// 成功响应,检查并移除错误状态
|
||||||
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -444,11 +666,20 @@ class CcrRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
|
if (existingConnection) {
|
||||||
|
logger.debug(
|
||||||
|
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
|
||||||
|
)
|
||||||
|
}
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: existingConnection || 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
}
|
}
|
||||||
@@ -487,12 +718,17 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写入到响应流
|
// 写入到响应流
|
||||||
if (outputLine && !responseStream.destroyed) {
|
if (outputLine && isStreamWritable(responseStream)) {
|
||||||
responseStream.write(`${outputLine}\n`)
|
responseStream.write(`${outputLine}\n`)
|
||||||
|
} else if (outputLine) {
|
||||||
|
// 客户端连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 空行也需要传递
|
// 空行也需要传递
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write('\n')
|
responseStream.write('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,10 +739,6 @@ class CcrRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
if (!responseStream.destroyed) {
|
|
||||||
responseStream.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果收集到使用统计数据,调用回调
|
// 如果收集到使用统计数据,调用回调
|
||||||
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -518,12 +750,26 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve()
|
if (isStreamWritable(responseStream)) {
|
||||||
|
// 等待数据完全 flush 到客户端后再 resolve
|
||||||
|
responseStream.end(() => {
|
||||||
|
logger.debug(
|
||||||
|
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('error', (err) => {
|
response.data.on('error', (err) => {
|
||||||
logger.error('❌ Stream data error:', err)
|
logger.error('❌ Stream data error:', err)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
reject(err)
|
reject(err)
|
||||||
@@ -555,7 +801,7 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
// 处理订阅信息更新
|
// 处理订阅信息更新
|
||||||
@@ -1553,7 +1570,7 @@ class ClaudeAccountService {
|
|||||||
'rateLimitAutoStopped'
|
'rateLimitAutoStopped'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2225,7 +2242,7 @@ class ClaudeAccountService {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAccounts: accounts.length,
|
totalAccounts: accounts.length,
|
||||||
@@ -2293,11 +2310,11 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success('✅ Session window initialization completed:')
|
logger.success('Session window initialization completed:')
|
||||||
logger.success(` 📊 Total accounts: ${accounts.length}`)
|
logger.success(` Total accounts: ${accounts.length}`)
|
||||||
logger.success(` ✅ Valid windows: ${validWindowCount}`)
|
logger.success(` Valid windows: ${validWindowCount}`)
|
||||||
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`)
|
logger.success(` Expired windows: ${expiredWindowCount}`)
|
||||||
logger.success(` 📭 No windows: ${noWindowCount}`)
|
logger.success(` No windows: ${noWindowCount}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: accounts.length,
|
total: accounts.length,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const {
|
||||||
|
getCachedConfig,
|
||||||
|
setCachedConfig,
|
||||||
|
deleteCachedConfig
|
||||||
|
} = require('../utils/performanceOptimizer')
|
||||||
|
|
||||||
class ClaudeCodeHeadersService {
|
class ClaudeCodeHeadersService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -41,6 +46,9 @@ class ClaudeCodeHeadersService {
|
|||||||
'sec-fetch-mode',
|
'sec-fetch-mode',
|
||||||
'accept-encoding'
|
'accept-encoding'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Headers 缓存 TTL(60秒)
|
||||||
|
this.headersCacheTtl = 60000
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,6 +155,9 @@ class ClaudeCodeHeadersService {
|
|||||||
|
|
||||||
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
|
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
|
||||||
|
|
||||||
|
// 更新内存缓存,避免延迟
|
||||||
|
setCachedConfig(key, extractedHeaders, this.headersCacheTtl)
|
||||||
|
|
||||||
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
|
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
|
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
|
||||||
@@ -154,18 +165,27 @@ class ClaudeCodeHeadersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取账号的 Claude Code headers
|
* 获取账号的 Claude Code headers(带内存缓存)
|
||||||
*/
|
*/
|
||||||
async getAccountHeaders(accountId) {
|
async getAccountHeaders(accountId) {
|
||||||
|
const cacheKey = `claude_code_headers:${accountId}`
|
||||||
|
|
||||||
|
// 检查内存缓存
|
||||||
|
const cached = getCachedConfig(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = `claude_code_headers:${accountId}`
|
const data = await redis.getClient().get(cacheKey)
|
||||||
const data = await redis.getClient().get(key)
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const parsed = JSON.parse(data)
|
const parsed = JSON.parse(data)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
|
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
|
||||||
)
|
)
|
||||||
|
// 缓存到内存
|
||||||
|
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
|
||||||
return parsed.headers
|
return parsed.headers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +203,10 @@ class ClaudeCodeHeadersService {
|
|||||||
*/
|
*/
|
||||||
async clearAccountHeaders(accountId) {
|
async clearAccountHeaders(accountId) {
|
||||||
try {
|
try {
|
||||||
const key = `claude_code_headers:${accountId}`
|
const cacheKey = `claude_code_headers:${accountId}`
|
||||||
await redis.getClient().del(key)
|
await redis.getClient().del(cacheKey)
|
||||||
|
// 删除内存缓存
|
||||||
|
deleteCachedConfig(cacheKey)
|
||||||
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
|
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
|
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
|
||||||
@@ -192,12 +214,12 @@ class ClaudeCodeHeadersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有账号的 headers 信息
|
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys)
|
||||||
*/
|
*/
|
||||||
async getAllAccountHeaders() {
|
async getAllAccountHeaders() {
|
||||||
try {
|
try {
|
||||||
const pattern = 'claude_code_headers:*'
|
const pattern = 'claude_code_headers:*'
|
||||||
const keys = await redis.getClient().keys(pattern)
|
const keys = await redis.scanKeys(pattern)
|
||||||
|
|
||||||
const results = {}
|
const results = {}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -127,6 +129,7 @@ class ClaudeConsoleAccountService {
|
|||||||
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||||
|
|
||||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||||
|
await redis.addToIndex('claude_console_account:index', accountId)
|
||||||
|
|
||||||
// 如果是共享账户,添加到共享账户集合
|
// 如果是共享账户,添加到共享账户集合
|
||||||
if (accountType === 'shared') {
|
if (accountType === 'shared') {
|
||||||
@@ -156,6 +159,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
disableAutoProtection, // 新增:返回自动防护开关
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
|
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,11 +168,18 @@ class ClaudeConsoleAccountService {
|
|||||||
async getAllAccounts() {
|
async getAllAccounts() {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
const accountIds = await redis.getAllIdsByIndex(
|
||||||
|
'claude_console_account:index',
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^claude_console_account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
const dataList = await redis.batchHgetallChunked(keys)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const accountData = await client.hgetall(key)
|
const key = keys[i]
|
||||||
|
const accountData = dataList[i]
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
if (!accountData.id) {
|
if (!accountData.id) {
|
||||||
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
||||||
@@ -217,7 +228,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 +388,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 刷新逻辑,不会覆盖此字段
|
||||||
@@ -441,6 +457,7 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
// 从Redis删除
|
// 从Redis删除
|
||||||
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
await redis.removeFromIndex('claude_console_account:index', accountId)
|
||||||
|
|
||||||
// 从共享账户集合中移除
|
// 从共享账户集合中移除
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
@@ -569,7 +586,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.hset(accountKey, updateData)
|
await client.hset(accountKey, updateData)
|
||||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
||||||
@@ -577,7 +594,7 @@ class ClaudeConsoleAccountService {
|
|||||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -850,7 +867,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.hset(accountKey, updateData)
|
await client.hset(accountKey, updateData)
|
||||||
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
|
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
||||||
@@ -858,7 +875,7 @@ class ClaudeConsoleAccountService {
|
|||||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
|
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -959,7 +976,7 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
||||||
|
|
||||||
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
|
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1408,7 +1425,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
|
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to reset all daily usage:', error)
|
logger.error('Failed to reset all daily usage:', error)
|
||||||
}
|
}
|
||||||
@@ -1481,7 +1498,7 @@ class ClaudeConsoleAccountService {
|
|||||||
await client.hset(accountKey, updates)
|
await client.hset(accountKey, updates)
|
||||||
await client.hdel(accountKey, ...fieldsToDelete)
|
await client.hdel(accountKey, ...fieldsToDelete)
|
||||||
|
|
||||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
logger.success(`Reset all error status for Claude Console account ${accountId}`)
|
||||||
|
|
||||||
// 发送 Webhook 通知
|
// 发送 Webhook 通知
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const {
|
|||||||
sanitizeErrorMessage,
|
sanitizeErrorMessage,
|
||||||
isAccountDisabledError
|
isAccountDisabledError
|
||||||
} = require('../utils/errorSanitizer')
|
} = require('../utils/errorSanitizer')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
const { isStreamWritable } = require('../utils/streamHelper')
|
||||||
|
const { filterForClaude } = require('../utils/headerFilter')
|
||||||
|
|
||||||
class ClaudeConsoleRelayService {
|
class ClaudeConsoleRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -29,8 +32,68 @@ class ClaudeConsoleRelayService {
|
|||||||
let account = null
|
let account = null
|
||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in console relayRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
apiKeyName: apiKeyData.name,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -203,6 +266,23 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
const response = await axios(requestConfig)
|
const response = await axios(requestConfig)
|
||||||
|
|
||||||
|
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||||
|
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for console account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 移除监听器(请求成功完成)
|
// 移除监听器(请求成功完成)
|
||||||
if (clientRequest) {
|
if (clientRequest) {
|
||||||
clientRequest.removeListener('close', handleClientDisconnect)
|
clientRequest.removeListener('close', handleClientDisconnect)
|
||||||
@@ -366,6 +446,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +479,71 @@ class ClaudeConsoleRelayService {
|
|||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
let leaseRefreshInterval = null // 租约刷新定时器
|
let leaseRefreshInterval = null // 租约刷新定时器
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error(
|
||||||
|
'❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture'
|
||||||
|
)
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
apiKeyName: apiKeyData.name,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
|
responseStream.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n`
|
||||||
|
responseStream.write(errorEvent)
|
||||||
|
responseStream.write('data: [DONE]\n\n')
|
||||||
|
responseStream.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -483,16 +641,40 @@ class ClaudeConsoleRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer,
|
streamTransformer,
|
||||||
options
|
options,
|
||||||
|
// 📬 回调:在收到响应头时释放队列锁
|
||||||
|
async () => {
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock early for console stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 更新最后使用时间
|
// 更新最后使用时间
|
||||||
await this._updateLastUsedTime(accountId)
|
await this._updateLastUsedTime(accountId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
if (error.message === 'Client disconnected') {
|
||||||
error
|
logger.info(
|
||||||
)
|
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
// 🛑 清理租约刷新定时器
|
// 🛑 清理租约刷新定时器
|
||||||
@@ -517,6 +699,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +727,8 @@ class ClaudeConsoleRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
requestOptions = {}
|
requestOptions = {},
|
||||||
|
onResponseHeaderReceived = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let aborted = false
|
let aborted = false
|
||||||
@@ -593,8 +791,11 @@ class ClaudeConsoleRelayService {
|
|||||||
// 发送请求
|
// 发送请求
|
||||||
const request = axios(requestConfig)
|
const request = axios(requestConfig)
|
||||||
|
|
||||||
|
// 注意:使用 .then(async ...) 模式处理响应
|
||||||
|
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||||
|
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||||
request
|
request
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
|
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
|
||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
@@ -682,7 +883,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
|
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(JSON.stringify(sanitizedError))
|
responseStream.write(JSON.stringify(sanitizedError))
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
@@ -690,7 +891,7 @@ class ClaudeConsoleRelayService {
|
|||||||
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
|
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
|
||||||
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
|
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(sanitizedText)
|
responseStream.write(sanitizedText)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
@@ -701,6 +902,19 @@ class ClaudeConsoleRelayService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 收到成功响应头(HTTP 200),调用回调释放队列锁
|
||||||
|
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||||
|
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
|
||||||
|
try {
|
||||||
|
await onResponseHeaderReceived()
|
||||||
|
} catch (callbackError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`,
|
||||||
|
callbackError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 成功响应,检查并移除错误状态
|
// 成功响应,检查并移除错误状态
|
||||||
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -714,11 +928,22 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
|
// 当并发队列功能启用时,auth.js 会设置 Connection: close 来禁用 Keep-Alive
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
|
const connectionHeader = existingConnection || 'keep-alive'
|
||||||
|
if (existingConnection) {
|
||||||
|
logger.debug(
|
||||||
|
`🔌 [Console Stream] Preserving existing Connection header: ${existingConnection}`
|
||||||
|
)
|
||||||
|
}
|
||||||
responseStream.writeHead(200, {
|
responseStream.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: connectionHeader,
|
||||||
'X-Accel-Buffering': 'no'
|
'X-Accel-Buffering': 'no'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -744,20 +969,33 @@ class ClaudeConsoleRelayService {
|
|||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
// 转发数据并解析usage
|
// 转发数据并解析usage
|
||||||
if (lines.length > 0 && !responseStream.destroyed) {
|
if (lines.length > 0) {
|
||||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
// 检查流是否可写(客户端连接是否有效)
|
||||||
|
if (isStreamWritable(responseStream)) {
|
||||||
|
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
||||||
|
|
||||||
// 应用流转换器如果有
|
// 应用流转换器如果有
|
||||||
if (streamTransformer) {
|
let dataToWrite = linesToForward
|
||||||
const transformed = streamTransformer(linesToForward)
|
if (streamTransformer) {
|
||||||
if (transformed) {
|
const transformed = streamTransformer(linesToForward)
|
||||||
responseStream.write(transformed)
|
if (transformed) {
|
||||||
|
dataToWrite = transformed
|
||||||
|
} else {
|
||||||
|
dataToWrite = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataToWrite) {
|
||||||
|
responseStream.write(dataToWrite)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
responseStream.write(linesToForward)
|
// 客户端连接已断开,记录警告(但仍继续解析usage)
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析SSE数据寻找usage信息
|
// 解析SSE数据寻找usage信息(无论连接状态如何)
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data:')) {
|
if (line.startsWith('data:')) {
|
||||||
const jsonStr = line.slice(5).trimStart()
|
const jsonStr = line.slice(5).trimStart()
|
||||||
@@ -865,7 +1103,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -888,7 +1126,7 @@ class ClaudeConsoleRelayService {
|
|||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
try {
|
try {
|
||||||
// 处理缓冲区中剩余的数据
|
// 处理缓冲区中剩余的数据
|
||||||
if (buffer.trim() && !responseStream.destroyed) {
|
if (buffer.trim() && isStreamWritable(responseStream)) {
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
const transformed = streamTransformer(buffer)
|
const transformed = streamTransformer(buffer)
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
@@ -937,12 +1175,33 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保流正确结束
|
// 确保流正确结束
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
// 📊 诊断日志:流结束前状态
|
||||||
}
|
logger.info(
|
||||||
|
`📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` +
|
||||||
|
`socketDestroyed: ${responseStream.socket?.destroyed}, ` +
|
||||||
|
`socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}`
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug('🌊 Claude Console Claude stream response completed')
|
// 禁用 Nagle 算法确保数据立即发送
|
||||||
resolve()
|
if (responseStream.socket && !responseStream.socket.destroyed) {
|
||||||
|
responseStream.socket.setNoDelay(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待数据完全 flush 到客户端后再 resolve
|
||||||
|
responseStream.end(() => {
|
||||||
|
logger.info(
|
||||||
|
`✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Error processing stream end:', error)
|
logger.error('❌ Error processing stream end:', error)
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -954,7 +1213,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -1002,14 +1261,17 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 发送错误响应
|
// 发送错误响应
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
responseStream.writeHead(error.response?.status || 500, {
|
responseStream.writeHead(error.response?.status || 500, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: existingConnection || 'keep-alive'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -1041,30 +1303,9 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 🔧 过滤客户端请求头
|
// 🔧 过滤客户端请求头
|
||||||
_filterClientHeaders(clientHeaders) {
|
_filterClientHeaders(clientHeaders) {
|
||||||
const sensitiveHeaders = [
|
// 使用统一的 headerFilter 工具类(白名单模式)
|
||||||
'content-type',
|
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
|
||||||
'user-agent',
|
return filterForClaude(clientHeaders)
|
||||||
'authorization',
|
|
||||||
'x-api-key',
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'proxy-authorization',
|
|
||||||
'content-encoding',
|
|
||||||
'transfer-encoding',
|
|
||||||
'anthropic-version'
|
|
||||||
]
|
|
||||||
|
|
||||||
const filteredHeaders = {}
|
|
||||||
|
|
||||||
Object.keys(clientHeaders || {}).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!sensitiveHeaders.includes(lowerKey)) {
|
|
||||||
filteredHeaders[key] = clientHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return filteredHeaders
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🕐 更新最后使用时间
|
// 🕐 更新最后使用时间
|
||||||
@@ -1179,7 +1420,7 @@ class ClaudeConsoleRelayService {
|
|||||||
'Cache-Control': 'no-cache'
|
'Cache-Control': 'no-cache'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
||||||
)
|
)
|
||||||
|
|||||||
453
src/services/claudeRelayConfigService.js
Normal file
453
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置服务
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'claude_relay_config'
|
||||||
|
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||||
|
// 用户消息队列配置
|
||||||
|
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||||
|
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||||
|
userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
|
||||||
|
userMessageQueueLockTtlMs: 120000, // 锁TTL(毫秒)
|
||||||
|
// 并发请求排队配置
|
||||||
|
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
|
||||||
|
concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3)
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数(默认0,仅使用固定值)
|
||||||
|
concurrentRequestQueueTimeoutMs: 10000, // 排队超时(毫秒,默认10秒)
|
||||||
|
concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值(默认5次)
|
||||||
|
// 排队健康检查配置
|
||||||
|
concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启)
|
||||||
|
concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值(P90 >= 超时 × 阈值时拒绝新请求)
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存缓存(避免频繁 Redis 查询)
|
||||||
|
let configCache = null
|
||||||
|
let configCacheTime = 0
|
||||||
|
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||||
|
|
||||||
|
class ClaudeRelayConfigService {
|
||||||
|
/**
|
||||||
|
* 从 metadata.user_id 中提取原始 sessionId
|
||||||
|
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {string|null} 原始 sessionId 或 null
|
||||||
|
*/
|
||||||
|
extractOriginalSessionId(requestBody) {
|
||||||
|
if (!requestBody?.metadata?.user_id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = requestBody.metadata.user_id
|
||||||
|
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置(带缓存)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
try {
|
||||||
|
// 检查缓存
|
||||||
|
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||||
|
return configCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('⚠️ Redis not connected, using default config')
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.get(CONFIG_KEY)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||||
|
} else {
|
||||||
|
configCache = { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
return configCache
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
* @param {Object} newConfig - 新配置
|
||||||
|
* @param {string} updatedBy - 更新者
|
||||||
|
* @returns {Promise<Object>} 更新后的配置
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig, updatedBy) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...currentConfig,
|
||||||
|
...newConfig,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
configCache = updatedConfig
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
|
||||||
|
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||||
|
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
|
||||||
|
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局 Claude Code 限制
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isClaudeCodeOnlyEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.claudeCodeOnlyEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局会话绑定
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isGlobalSessionBindingEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.globalSessionBindingEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定错误信息
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingErrorMessage() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||||
|
*/
|
||||||
|
async getOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
const data = await client.get(key)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型
|
||||||
|
* @returns {Promise<Object>} 绑定信息
|
||||||
|
*/
|
||||||
|
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||||
|
if (!originalSessionId || !accountId || !accountType) {
|
||||||
|
throw new Error('Invalid parameters for session binding')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
const binding = {
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return binding
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话绑定的最后使用时间(续期)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async touchOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
if (!binding) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.lastUsedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查原始会话是否已绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isOriginalSessionBound(originalSessionId) {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
return binding !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证绑定的账户是否可用
|
||||||
|
* @param {Object} binding - 绑定信息
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async validateBoundAccount(binding) {
|
||||||
|
if (!binding || !binding.accountId || !binding.accountType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountType } = binding
|
||||||
|
const { accountId } = binding
|
||||||
|
|
||||||
|
let accountService
|
||||||
|
switch (accountType) {
|
||||||
|
case 'claude-official':
|
||||||
|
accountService = require('./claudeAccountService')
|
||||||
|
break
|
||||||
|
case 'claude-console':
|
||||||
|
accountService = require('./claudeConsoleAccountService')
|
||||||
|
break
|
||||||
|
case 'bedrock':
|
||||||
|
accountService = require('./bedrockAccountService')
|
||||||
|
break
|
||||||
|
case 'ccr':
|
||||||
|
accountService = require('./ccrAccountService')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountService.getAccount(accountId)
|
||||||
|
|
||||||
|
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = account
|
||||||
|
|
||||||
|
// 检查账户是否激活
|
||||||
|
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户状态(如果存在)
|
||||||
|
if (accountData.status && accountData.status === 'error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证新会话请求
|
||||||
|
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||||
|
*/
|
||||||
|
async validateNewSession(_requestBody, originalSessionId) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
if (!cfg.globalSessionBindingEnabled) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 如果会话已存在绑定
|
||||||
|
if (existingBinding) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (existingBinding.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||||
|
)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||||
|
|
||||||
|
if (!accountValid) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: cfg.sessionBindingErrorMessage,
|
||||||
|
code: 'SESSION_BINDING_INVALID'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续期
|
||||||
|
await this.touchOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 已有绑定,允许继续(这是正常的会话延续)
|
||||||
|
return { valid: true, binding: existingBinding }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有绑定,是新会话
|
||||||
|
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||||
|
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||||
|
return { valid: true, isNewSession: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async deleteOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
await client.del(key)
|
||||||
|
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定统计
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingStats() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = '0'
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`${SESSION_BINDING_PREFIX}*`,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
count += keys.length
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBindings: count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除配置缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
configCache = null
|
||||||
|
configCacheTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeRelayConfigService()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,65 @@
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const apiKeyService = require('./apiKeyService')
|
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
// HMGET 需要的字段
|
||||||
|
const USAGE_FIELDS = [
|
||||||
|
'totalInputTokens',
|
||||||
|
'inputTokens',
|
||||||
|
'totalOutputTokens',
|
||||||
|
'outputTokens',
|
||||||
|
'totalCacheCreateTokens',
|
||||||
|
'cacheCreateTokens',
|
||||||
|
'totalCacheReadTokens',
|
||||||
|
'cacheReadTokens'
|
||||||
|
]
|
||||||
|
|
||||||
class CostInitService {
|
class CostInitService {
|
||||||
|
/**
|
||||||
|
* 带并发限制的并行执行
|
||||||
|
*/
|
||||||
|
async parallelLimit(items, fn, concurrency = 20) {
|
||||||
|
let index = 0
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (index < items.length) {
|
||||||
|
const currentIndex = index++
|
||||||
|
try {
|
||||||
|
results[currentIndex] = await fn(items[currentIndex], currentIndex)
|
||||||
|
} catch (error) {
|
||||||
|
results[currentIndex] = { error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker))
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 SCAN 获取匹配的 keys(带去重)
|
||||||
|
*/
|
||||||
|
async scanKeysWithDedup(client, pattern, count = 500) {
|
||||||
|
const seen = new Set()
|
||||||
|
const allKeys = []
|
||||||
|
let cursor = '0'
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
allKeys.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return allKeys
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化所有API Key的费用数据
|
* 初始化所有API Key的费用数据
|
||||||
* 扫描历史使用记录并计算费用
|
* 扫描历史使用记录并计算费用
|
||||||
@@ -12,25 +68,57 @@ class CostInitService {
|
|||||||
try {
|
try {
|
||||||
logger.info('💰 Starting cost initialization for all API Keys...')
|
logger.info('💰 Starting cost initialization for all API Keys...')
|
||||||
|
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
// 用 scanApiKeyIds 获取 ID,然后过滤已删除的
|
||||||
|
const allKeyIds = await redis.scanApiKeyIds()
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
|
// 批量检查 isDeleted 状态,过滤已删除的 key
|
||||||
|
const FILTER_BATCH = 100
|
||||||
|
const apiKeyIds = []
|
||||||
|
|
||||||
|
for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) {
|
||||||
|
const batch = allKeyIds.slice(i, i + FILTER_BATCH)
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
for (const keyId of batch) {
|
||||||
|
pipeline.hget(`apikey:${keyId}`, 'isDeleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let j = 0; j < results.length; j++) {
|
||||||
|
const [err, isDeleted] = results[j]
|
||||||
|
if (!err && isDeleted !== 'true') {
|
||||||
|
apiKeyIds.push(batch[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`
|
||||||
|
)
|
||||||
|
|
||||||
let processedCount = 0
|
let processedCount = 0
|
||||||
let errorCount = 0
|
let errorCount = 0
|
||||||
|
|
||||||
for (const apiKey of apiKeys) {
|
// 优化6: 并行处理 + 并发限制
|
||||||
try {
|
await this.parallelLimit(
|
||||||
await this.initializeApiKeyCosts(apiKey.id, client)
|
apiKeyIds,
|
||||||
processedCount++
|
async (apiKeyId) => {
|
||||||
|
try {
|
||||||
|
await this.initializeApiKeyCosts(apiKeyId, client)
|
||||||
|
processedCount++
|
||||||
|
|
||||||
if (processedCount % 10 === 0) {
|
if (processedCount % 100 === 0) {
|
||||||
logger.info(`💰 Processed ${processedCount} API Keys...`)
|
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++
|
||||||
|
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
errorCount++
|
20 // 并发数
|
||||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
|
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
|
||||||
@@ -46,16 +134,55 @@ class CostInitService {
|
|||||||
* 初始化单个API Key的费用数据
|
* 初始化单个API Key的费用数据
|
||||||
*/
|
*/
|
||||||
async initializeApiKeyCosts(apiKeyId, client) {
|
async initializeApiKeyCosts(apiKeyId, client) {
|
||||||
// 获取所有时间的模型使用统计
|
// 优化4: 使用 SCAN 获取 keys(带去重)
|
||||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`)
|
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`)
|
||||||
|
|
||||||
|
if (modelKeys.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化5: 使用 Pipeline + HMGET 批量获取数据
|
||||||
|
const BATCH_SIZE = 100
|
||||||
|
const allData = []
|
||||||
|
|
||||||
|
for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) {
|
||||||
|
const batch = modelKeys.slice(i, i + BATCH_SIZE)
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
for (const key of batch) {
|
||||||
|
pipeline.hmget(key, ...USAGE_FIELDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let j = 0; j < results.length; j++) {
|
||||||
|
const [err, values] = results[j]
|
||||||
|
if (err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将数组转换为对象
|
||||||
|
const data = {}
|
||||||
|
let hasData = false
|
||||||
|
for (let k = 0; k < USAGE_FIELDS.length; k++) {
|
||||||
|
if (values[k] !== null) {
|
||||||
|
data[USAGE_FIELDS[k]] = values[k]
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasData) {
|
||||||
|
allData.push({ key: batch[j], data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 按日期分组统计
|
// 按日期分组统计
|
||||||
const dailyCosts = new Map() // date -> cost
|
const dailyCosts = new Map()
|
||||||
const monthlyCosts = new Map() // month -> cost
|
const monthlyCosts = new Map()
|
||||||
const hourlyCosts = new Map() // hour -> cost
|
const hourlyCosts = new Map()
|
||||||
|
|
||||||
for (const key of modelKeys) {
|
for (const { key, data } of allData) {
|
||||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
|
||||||
const match = key.match(
|
const match = key.match(
|
||||||
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
|
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
|
||||||
)
|
)
|
||||||
@@ -65,13 +192,6 @@ class CostInitService {
|
|||||||
|
|
||||||
const [, , period, model, dateStr] = match
|
const [, , period, model, dateStr] = match
|
||||||
|
|
||||||
// 获取使用数据
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
if (!data || Object.keys(data).length === 0) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算费用
|
|
||||||
const usage = {
|
const usage = {
|
||||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||||
@@ -84,47 +204,34 @@ class CostInitService {
|
|||||||
const costResult = CostCalculator.calculateCost(usage, model)
|
const costResult = CostCalculator.calculateCost(usage, model)
|
||||||
const cost = costResult.costs.total
|
const cost = costResult.costs.total
|
||||||
|
|
||||||
// 根据period分组累加费用
|
|
||||||
if (period === 'daily') {
|
if (period === 'daily') {
|
||||||
const currentCost = dailyCosts.get(dateStr) || 0
|
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
|
||||||
dailyCosts.set(dateStr, currentCost + cost)
|
|
||||||
} else if (period === 'monthly') {
|
} else if (period === 'monthly') {
|
||||||
const currentCost = monthlyCosts.get(dateStr) || 0
|
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
|
||||||
monthlyCosts.set(dateStr, currentCost + cost)
|
|
||||||
} else if (period === 'hourly') {
|
} else if (period === 'hourly') {
|
||||||
const currentCost = hourlyCosts.get(dateStr) || 0
|
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
|
||||||
hourlyCosts.set(dateStr, currentCost + cost)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将计算出的费用写入Redis
|
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的
|
||||||
const promises = []
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
// 写入每日费用
|
// 写入每日费用(只补缺失)
|
||||||
for (const [date, cost] of dailyCosts) {
|
for (const [date, cost] of dailyCosts) {
|
||||||
const key = `usage:cost:daily:${apiKeyId}:${date}`
|
const key = `usage:cost:daily:${apiKeyId}:${date}`
|
||||||
promises.push(
|
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
|
||||||
client.set(key, cost.toString()),
|
|
||||||
client.expire(key, 86400 * 30) // 30天过期
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入每月费用
|
// 写入每月费用(只补缺失)
|
||||||
for (const [month, cost] of monthlyCosts) {
|
for (const [month, cost] of monthlyCosts) {
|
||||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`
|
const key = `usage:cost:monthly:${apiKeyId}:${month}`
|
||||||
promises.push(
|
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
|
||||||
client.set(key, cost.toString()),
|
|
||||||
client.expire(key, 86400 * 90) // 90天过期
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入每小时费用
|
// 写入每小时费用(只补缺失)
|
||||||
for (const [hour, cost] of hourlyCosts) {
|
for (const [hour, cost] of hourlyCosts) {
|
||||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
|
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
|
||||||
promises.push(
|
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
|
||||||
client.set(key, cost.toString()),
|
|
||||||
client.expire(key, 86400 * 7) // 7天过期
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算总费用
|
// 计算总费用
|
||||||
@@ -133,37 +240,25 @@ class CostInitService {
|
|||||||
totalCost += cost
|
totalCost += cost
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
|
// 写入总费用(只补缺失)
|
||||||
if (totalCost > 0) {
|
if (totalCost > 0) {
|
||||||
const totalKey = `usage:cost:total:${apiKeyId}`
|
const totalKey = `usage:cost:total:${apiKeyId}`
|
||||||
// 先检查总费用是否已存在
|
|
||||||
const existingTotal = await client.get(totalKey)
|
const existingTotal = await client.get(totalKey)
|
||||||
|
|
||||||
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
||||||
// 仅在总费用不存在或为0时才初始化
|
pipeline.set(totalKey, totalCost.toString())
|
||||||
promises.push(client.set(totalKey, totalCost.toString()))
|
|
||||||
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
||||||
} else {
|
} else {
|
||||||
// 如果总费用已存在,保持不变,避免覆盖累计值
|
|
||||||
// 注意:这个逻辑防止因每日费用键过期(30天)导致的错误覆盖
|
|
||||||
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
|
|
||||||
const existing = parseFloat(existingTotal)
|
const existing = parseFloat(existingTotal)
|
||||||
const calculated = totalCost
|
if (totalCost > existing * 1.1) {
|
||||||
|
|
||||||
if (calculated > existing * 1.1) {
|
|
||||||
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
|
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
|
||||||
)
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises)
|
await pipeline.exec()
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
|
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
|
||||||
@@ -172,41 +267,70 @@ class CostInitService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否需要初始化费用数据
|
* 检查是否需要初始化费用数据
|
||||||
|
* 使用 SCAN 代替 KEYS,正确处理 cursor
|
||||||
*/
|
*/
|
||||||
async needsInitialization() {
|
async needsInitialization() {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
// 检查是否有任何费用数据
|
// 正确循环 SCAN 检查是否有任何费用数据
|
||||||
const costKeys = await client.keys('usage:cost:*')
|
let cursor = '0'
|
||||||
|
let hasCostData = false
|
||||||
|
|
||||||
// 如果没有费用数据,需要初始化
|
do {
|
||||||
if (costKeys.length === 0) {
|
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100)
|
||||||
|
cursor = newCursor
|
||||||
|
if (keys.length > 0) {
|
||||||
|
hasCostData = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
if (!hasCostData) {
|
||||||
logger.info('💰 No cost data found, initialization needed')
|
logger.info('💰 No cost data found, initialization needed')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有使用数据但没有对应的费用数据
|
// 抽样检查使用数据是否有对应的费用数据
|
||||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*')
|
cursor = '0'
|
||||||
if (sampleKeys.length > 10) {
|
let samplesChecked = 0
|
||||||
// 抽样检查
|
const maxSamples = 10
|
||||||
const sampleSize = Math.min(10, sampleKeys.length)
|
|
||||||
for (let i = 0; i < sampleSize; i++) {
|
do {
|
||||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
|
const [newCursor, usageKeys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
'usage:*:model:daily:*:*',
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
for (const usageKey of usageKeys) {
|
||||||
|
if (samplesChecked >= maxSamples) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
const [, keyId, , date] = match
|
const [, keyId, , date] = match
|
||||||
const costKey = `usage:cost:daily:${keyId}:${date}`
|
const costKey = `usage:cost:daily:${keyId}:${date}`
|
||||||
const hasCost = await client.exists(costKey)
|
const hasCost = await client.exists(costKey)
|
||||||
|
|
||||||
if (!hasCost) {
|
if (!hasCost) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
|
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
samplesChecked++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (samplesChecked >= maxSamples) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
logger.info('💰 Cost data appears to be up to date')
|
logger.info('💰 Cost data appears to be up to date')
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class CostRankService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.isInitialized = true
|
this.isInitialized = true
|
||||||
logger.success('✅ CostRankService initialized')
|
logger.success('CostRankService initialized')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to initialize CostRankService:', error)
|
logger.error('❌ Failed to initialize CostRankService:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -391,17 +391,32 @@ class CostRankService {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = {}
|
// 使用 Pipeline 批量获取
|
||||||
|
const pipeline = client.pipeline()
|
||||||
for (const timeRange of VALID_TIME_RANGES) {
|
for (const timeRange of VALID_TIME_RANGES) {
|
||||||
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
|
pipeline.hgetall(RedisKeys.metaKey(timeRange))
|
||||||
status[timeRange] = {
|
|
||||||
lastUpdate: meta.lastUpdate || null,
|
|
||||||
keyCount: parseInt(meta.keyCount || 0),
|
|
||||||
status: meta.status || 'unknown',
|
|
||||||
updateDuration: parseInt(meta.updateDuration || 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
const status = {}
|
||||||
|
VALID_TIME_RANGES.forEach((timeRange, i) => {
|
||||||
|
const [err, meta] = results[i]
|
||||||
|
if (err || !meta) {
|
||||||
|
status[timeRange] = {
|
||||||
|
lastUpdate: null,
|
||||||
|
keyCount: 0,
|
||||||
|
status: 'unknown',
|
||||||
|
updateDuration: 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status[timeRange] = {
|
||||||
|
lastUpdate: meta.lastUpdate || null,
|
||||||
|
keyCount: parseInt(meta.keyCount || 0),
|
||||||
|
status: meta.status || 'unknown',
|
||||||
|
updateDuration: parseInt(meta.updateDuration || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ const { v4: uuidv4 } = require('uuid')
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const config = require('../../config/config')
|
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { maskToken } = require('../utils/tokenMask')
|
const { maskToken } = require('../utils/tokenMask')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Droid 账户管理服务
|
* Droid 账户管理服务
|
||||||
@@ -26,21 +25,14 @@ class DroidAccountService {
|
|||||||
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
||||||
this.tokenValidHours = 8 // Token 有效期8小时
|
this.tokenValidHours = 8 // Token 有效期8小时
|
||||||
|
|
||||||
// 加密相关常量
|
// 使用 commonHelper 的加密器
|
||||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
this._encryptor = createEncryptor('droid-account-salt')
|
||||||
this.ENCRYPTION_SALT = 'droid-account-salt'
|
|
||||||
|
|
||||||
// 🚀 性能优化:缓存派生的加密密钥
|
|
||||||
this._encryptionKeyCache = null
|
|
||||||
|
|
||||||
// 🔄 解密结果缓存
|
|
||||||
this._decryptCache = new LRUCache(500)
|
|
||||||
|
|
||||||
// 🧹 定期清理缓存(每10分钟)
|
// 🧹 定期清理缓存(每10分钟)
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
this._decryptCache.cleanup()
|
this._encryptor.clearCache()
|
||||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
|
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
|
||||||
},
|
},
|
||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
@@ -69,92 +61,19 @@ class DroidAccountService {
|
|||||||
return 'anthropic'
|
return 'anthropic'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 commonHelper 的 isTruthy
|
||||||
_isTruthy(value) {
|
_isTruthy(value) {
|
||||||
if (value === undefined || value === null) {
|
return isTruthy(value)
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const normalized = value.trim().toLowerCase()
|
|
||||||
if (normalized === 'true') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (normalized === 'false') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
|
|
||||||
}
|
|
||||||
return Boolean(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 加密敏感数据
|
||||||
* 生成加密密钥(缓存优化)
|
|
||||||
*/
|
|
||||||
_generateEncryptionKey() {
|
|
||||||
if (!this._encryptionKeyCache) {
|
|
||||||
this._encryptionKeyCache = crypto.scryptSync(
|
|
||||||
config.security.encryptionKey,
|
|
||||||
this.ENCRYPTION_SALT,
|
|
||||||
32
|
|
||||||
)
|
|
||||||
logger.info('🔑 Droid encryption key derived and cached for performance optimization')
|
|
||||||
}
|
|
||||||
return this._encryptionKeyCache
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加密敏感数据
|
|
||||||
*/
|
|
||||||
_encryptSensitiveData(text) {
|
_encryptSensitiveData(text) {
|
||||||
if (!text) {
|
return this._encryptor.encrypt(text)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = this._generateEncryptionKey()
|
|
||||||
const iv = crypto.randomBytes(16)
|
|
||||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
|
||||||
|
|
||||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
|
||||||
encrypted += cipher.final('hex')
|
|
||||||
|
|
||||||
return `${iv.toString('hex')}:${encrypted}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 解密敏感数据(带缓存)
|
||||||
* 解密敏感数据(带缓存)
|
|
||||||
*/
|
|
||||||
_decryptSensitiveData(encryptedText) {
|
_decryptSensitiveData(encryptedText) {
|
||||||
if (!encryptedText) {
|
return this._encryptor.decrypt(encryptedText)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 检查缓存
|
|
||||||
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
|
|
||||||
const cached = this._decryptCache.get(cacheKey)
|
|
||||||
if (cached !== undefined) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = this._generateEncryptionKey()
|
|
||||||
const parts = encryptedText.split(':')
|
|
||||||
const iv = Buffer.from(parts[0], 'hex')
|
|
||||||
const encrypted = parts[1]
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
|
||||||
decrypted += decipher.final('utf8')
|
|
||||||
|
|
||||||
// 💾 存入缓存(5分钟过期)
|
|
||||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('❌ Failed to decrypt Droid data:', error)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_parseApiKeyEntries(rawEntries) {
|
_parseApiKeyEntries(rawEntries) {
|
||||||
@@ -683,7 +602,7 @@ class DroidAccountService {
|
|||||||
|
|
||||||
lastRefreshAt = new Date().toISOString()
|
lastRefreshAt = new Date().toISOString()
|
||||||
status = 'active'
|
status = 'active'
|
||||||
logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
||||||
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
||||||
@@ -1368,7 +1287,7 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Droid account token refreshed successfully: ${accountId}`)
|
logger.success(`Droid account token refreshed successfully: ${accountId}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class DroidRelayService {
|
|||||||
return normalizedBody
|
return normalizedBody
|
||||||
}
|
}
|
||||||
|
|
||||||
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
|
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) {
|
||||||
if (!rateLimitInfo) {
|
if (!rateLimitInfo) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,9 @@ class DroidRelayService {
|
|||||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||||
rateLimitInfo,
|
rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model
|
model,
|
||||||
|
keyId,
|
||||||
|
'droid'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (totalTokens > 0) {
|
if (totalTokens > 0) {
|
||||||
@@ -336,7 +338,12 @@ class DroidRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info(`🔌 Droid relay ended: Client disconnected`)
|
||||||
|
} else {
|
||||||
|
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||||
|
}
|
||||||
|
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
@@ -398,6 +405,7 @@ class DroidRelayService {
|
|||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(apiUrl)
|
const url = new URL(apiUrl)
|
||||||
|
const keyId = apiKeyData?.id
|
||||||
const bodyString = JSON.stringify(processedBody)
|
const bodyString = JSON.stringify(processedBody)
|
||||||
const contentLength = Buffer.byteLength(bodyString)
|
const contentLength = Buffer.byteLength(bodyString)
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
@@ -601,10 +609,11 @@ class DroidRelayService {
|
|||||||
clientRequest?.rateLimitInfo,
|
clientRequest?.rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model,
|
model,
|
||||||
' [stream]'
|
' [stream]',
|
||||||
|
keyId
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(`✅ Droid stream completed - Account: ${account.name}`)
|
logger.success(`Droid stream completed - Account: ${account.name}`)
|
||||||
} else {
|
} else {
|
||||||
logger.success(
|
logger.success(
|
||||||
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
|
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
|
||||||
@@ -634,7 +643,7 @@ class DroidRelayService {
|
|||||||
// 客户端断开连接时清理
|
// 客户端断开连接时清理
|
||||||
clientResponse.on('close', () => {
|
clientResponse.on('close', () => {
|
||||||
if (req && !req.destroyed) {
|
if (req && !req.destroyed) {
|
||||||
req.destroy()
|
req.destroy(new Error('Client disconnected'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1190,6 +1199,7 @@ class DroidRelayService {
|
|||||||
skipUsageRecord = false
|
skipUsageRecord = false
|
||||||
) {
|
) {
|
||||||
const { data } = response
|
const { data } = response
|
||||||
|
const keyId = apiKeyData?.id
|
||||||
|
|
||||||
// 从响应中提取 usage 数据
|
// 从响应中提取 usage 数据
|
||||||
const usage = data.usage || {}
|
const usage = data.usage || {}
|
||||||
@@ -1220,7 +1230,8 @@ class DroidRelayService {
|
|||||||
clientRequest?.rateLimitInfo,
|
clientRequest?.rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model,
|
model,
|
||||||
endpointLabel
|
endpointLabel,
|
||||||
|
keyId
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
|
|||||||
@@ -2,103 +2,40 @@ const droidAccountService = require('./droidAccountService')
|
|||||||
const accountGroupService = require('./accountGroupService')
|
const accountGroupService = require('./accountGroupService')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const {
|
||||||
|
isTruthy,
|
||||||
|
isAccountHealthy,
|
||||||
|
sortAccountsByPriority,
|
||||||
|
normalizeEndpointType
|
||||||
|
} = require('../utils/commonHelper')
|
||||||
|
|
||||||
class DroidScheduler {
|
class DroidScheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.STICKY_PREFIX = 'droid'
|
this.STICKY_PREFIX = 'droid'
|
||||||
}
|
}
|
||||||
|
|
||||||
_normalizeEndpointType(endpointType) {
|
|
||||||
if (!endpointType) {
|
|
||||||
return 'anthropic'
|
|
||||||
}
|
|
||||||
const normalized = String(endpointType).toLowerCase()
|
|
||||||
if (normalized === 'openai') {
|
|
||||||
return 'openai'
|
|
||||||
}
|
|
||||||
if (normalized === 'comm') {
|
|
||||||
return 'comm'
|
|
||||||
}
|
|
||||||
if (normalized === 'anthropic') {
|
|
||||||
return 'anthropic'
|
|
||||||
}
|
|
||||||
return 'anthropic'
|
|
||||||
}
|
|
||||||
|
|
||||||
_isTruthy(value) {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value.toLowerCase() === 'true'
|
|
||||||
}
|
|
||||||
return Boolean(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
_isAccountActive(account) {
|
|
||||||
if (!account) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const isActive = this._isTruthy(account.isActive)
|
|
||||||
if (!isActive) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = (account.status || 'active').toLowerCase()
|
|
||||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
|
||||||
return !unhealthyStatuses.has(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
_isAccountSchedulable(account) {
|
_isAccountSchedulable(account) {
|
||||||
return this._isTruthy(account?.schedulable ?? true)
|
return isTruthy(account?.schedulable ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
_matchesEndpoint(account, endpointType) {
|
_matchesEndpoint(account, endpointType) {
|
||||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
const accountEndpoint = normalizeEndpointType(account?.endpointType)
|
||||||
if (normalizedEndpoint === accountEndpoint) {
|
if (normalizedEndpoint === accountEndpoint) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// comm 端点可以使用任何类型的账户
|
|
||||||
if (normalizedEndpoint === 'comm') {
|
if (normalizedEndpoint === 'comm') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
_sortCandidates(candidates) {
|
|
||||||
return [...candidates].sort((a, b) => {
|
|
||||||
const priorityA = parseInt(a.priority, 10) || 50
|
|
||||||
const priorityB = parseInt(b.priority, 10) || 50
|
|
||||||
|
|
||||||
if (priorityA !== priorityB) {
|
|
||||||
return priorityA - priorityB
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
|
||||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
|
||||||
|
|
||||||
if (lastUsedA !== lastUsedB) {
|
|
||||||
return lastUsedA - lastUsedB
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
|
||||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
|
||||||
return createdA - createdB
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||||
if (!sessionHash) {
|
if (!sessionHash) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||||
const apiKeyPart = apiKeyId || 'default'
|
const apiKeyPart = apiKeyId || 'default'
|
||||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||||
}
|
}
|
||||||
@@ -121,7 +58,7 @@ class DroidScheduler {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return accounts.filter(
|
return accounts.filter(
|
||||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +82,7 @@ class DroidScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||||
|
|
||||||
let candidates = []
|
let candidates = []
|
||||||
@@ -175,7 +112,7 @@ class DroidScheduler {
|
|||||||
const filtered = candidates.filter(
|
const filtered = candidates.filter(
|
||||||
(account) =>
|
(account) =>
|
||||||
account &&
|
account &&
|
||||||
this._isAccountActive(account) &&
|
isAccountHealthy(account) &&
|
||||||
this._isAccountSchedulable(account) &&
|
this._isAccountSchedulable(account) &&
|
||||||
this._matchesEndpoint(account, normalizedEndpoint)
|
this._matchesEndpoint(account, normalizedEndpoint)
|
||||||
)
|
)
|
||||||
@@ -203,7 +140,7 @@ class DroidScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = this._sortCandidates(filtered)
|
const sorted = sortAccountsByPriority(filtered)
|
||||||
const selected = sorted[0]
|
const selected = sorted[0]
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
const redisClient = require('../models/redis')
|
const redisClient = require('../models/redis')
|
||||||
const { v4: uuidv4 } = require('uuid')
|
const { v4: uuidv4 } = require('uuid')
|
||||||
const crypto = require('crypto')
|
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
const config = require('../../config/config')
|
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { OAuth2Client } = require('google-auth-library')
|
const { OAuth2Client } = require('google-auth-library')
|
||||||
const { maskToken } = require('../utils/tokenMask')
|
const { maskToken } = require('../utils/tokenMask')
|
||||||
@@ -15,12 +13,68 @@ const {
|
|||||||
logRefreshSkipped
|
logRefreshSkipped
|
||||||
} = require('../utils/tokenRefreshLogger')
|
} = require('../utils/tokenRefreshLogger')
|
||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const { createEncryptor } = require('../utils/commonHelper')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
// Gemini 账户键前缀
|
||||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||||
const OAUTH_CLIENT_SECRET = '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'
|
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||||
|
|
||||||
|
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||||
|
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||||
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
|
|
||||||
|
const OAUTH_PROVIDERS = {
|
||||||
|
[OAUTH_PROVIDER_GEMINI_CLI]: {
|
||||||
|
// Gemini CLI OAuth 配置(公开)
|
||||||
|
clientId:
|
||||||
|
process.env.GEMINI_OAUTH_CLIENT_ID ||
|
||||||
|
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
|
||||||
|
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
|
||||||
|
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
},
|
||||||
|
[OAUTH_PROVIDER_ANTIGRAVITY]: {
|
||||||
|
// Antigravity OAuth 配置(参考 gcli2api)
|
||||||
|
clientId:
|
||||||
|
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
|
||||||
|
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
|
||||||
|
clientSecret:
|
||||||
|
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
|
||||||
|
scopes: [
|
||||||
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'https://www.googleapis.com/auth/cclog',
|
||||||
|
'https://www.googleapis.com/auth/experimentsandconfigs'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOauthProvider(oauthProvider) {
|
||||||
|
if (!oauthProvider) {
|
||||||
|
return OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
: OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOauthProviderConfig(oauthProvider) {
|
||||||
|
const normalized = normalizeOauthProvider(oauthProvider)
|
||||||
|
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
|
||||||
|
}
|
||||||
|
|
||||||
// 🌐 TCP Keep-Alive Agent 配置
|
// 🌐 TCP Keep-Alive Agent 配置
|
||||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||||
@@ -34,104 +88,140 @@ const keepAliveAgent = new https.Agent({
|
|||||||
|
|
||||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||||
|
|
||||||
// 加密相关常量
|
// 使用 commonHelper 的加密器
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const encryptor = createEncryptor('gemini-account-salt')
|
||||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
const { encrypt, decrypt } = encryptor
|
||||||
const IV_LENGTH = 16
|
|
||||||
|
|
||||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
|
||||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
|
||||||
let _encryptionKeyCache = null
|
|
||||||
|
|
||||||
// 🔄 解密结果缓存,提高解密性能
|
|
||||||
const decryptCache = new LRUCache(500)
|
|
||||||
|
|
||||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
|
||||||
function generateEncryptionKey() {
|
|
||||||
if (!_encryptionKeyCache) {
|
|
||||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
|
||||||
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
|
|
||||||
}
|
|
||||||
return _encryptionKeyCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini 账户键前缀
|
|
||||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
|
||||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
|
||||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
|
||||||
|
|
||||||
// 加密函数
|
|
||||||
function encrypt(text) {
|
|
||||||
if (!text) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const key = generateEncryptionKey()
|
|
||||||
const iv = crypto.randomBytes(IV_LENGTH)
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
||||||
let encrypted = cipher.update(text)
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
|
||||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解密函数
|
|
||||||
function decrypt(text) {
|
|
||||||
if (!text) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 检查缓存
|
|
||||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
|
||||||
const cached = decryptCache.get(cacheKey)
|
|
||||||
if (cached !== undefined) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function fetchAvailableModelsAntigravity(
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
refreshToken = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const key = generateEncryptionKey()
|
let effectiveToken = accessToken
|
||||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
if (refreshToken) {
|
||||||
const ivHex = text.substring(0, 32)
|
try {
|
||||||
const encryptedHex = text.substring(33) // 跳过冒号
|
const client = await getOauthClient(
|
||||||
|
accessToken,
|
||||||
const iv = Buffer.from(ivHex, 'hex')
|
refreshToken,
|
||||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
proxyConfig,
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
let decrypted = decipher.update(encryptedText)
|
)
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
if (client && client.getAccessToken) {
|
||||||
const result = decrypted.toString()
|
const latest = await client.getAccessToken()
|
||||||
|
if (latest?.token) {
|
||||||
// 💾 存入缓存(5分钟过期)
|
effectiveToken = latest.token
|
||||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
}
|
||||||
|
}
|
||||||
// 📊 定期打印缓存统计
|
} catch (error) {
|
||||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
logger.warn('Failed to refresh Antigravity access token for models list:', {
|
||||||
decryptCache.printStats()
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
const data = await antigravityClient.fetchAvailableModels({
|
||||||
|
accessToken: effectiveToken,
|
||||||
|
proxyConfig
|
||||||
|
})
|
||||||
|
const modelsDict = data?.models
|
||||||
|
const created = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const models = []
|
||||||
|
const seen = new Set()
|
||||||
|
const {
|
||||||
|
getAntigravityModelAlias,
|
||||||
|
getAntigravityModelMetadata,
|
||||||
|
normalizeAntigravityModelInput
|
||||||
|
} = require('../utils/antigravityModel')
|
||||||
|
|
||||||
|
const pushModel = (modelId) => {
|
||||||
|
if (!modelId || seen.has(modelId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(modelId)
|
||||||
|
const metadata = getAntigravityModelMetadata(modelId)
|
||||||
|
const entry = {
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created,
|
||||||
|
owned_by: 'antigravity'
|
||||||
|
}
|
||||||
|
if (metadata?.name) {
|
||||||
|
entry.name = metadata.name
|
||||||
|
}
|
||||||
|
if (metadata?.maxCompletionTokens) {
|
||||||
|
entry.max_completion_tokens = metadata.maxCompletionTokens
|
||||||
|
}
|
||||||
|
if (metadata?.thinking) {
|
||||||
|
entry.thinking = metadata.thinking
|
||||||
|
}
|
||||||
|
models.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelsDict && typeof modelsDict === 'object') {
|
||||||
|
for (const modelId of Object.keys(modelsDict)) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelId)
|
||||||
|
const alias = getAntigravityModelAlias(normalized)
|
||||||
|
if (!alias) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pushModel(alias)
|
||||||
|
|
||||||
|
if (alias.endsWith('-thinking')) {
|
||||||
|
pushModel(alias.replace(/-thinking$/, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias.startsWith('gemini-claude-')) {
|
||||||
|
pushModel(alias.replace(/^gemini-/, ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Decryption error:', error)
|
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
|
||||||
return ''
|
return [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.5-flash',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'antigravity'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const response = await antigravityClient.countTokens({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
contents,
|
||||||
|
model
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
// 🧹 定期清理缓存(每10分钟)
|
// 🧹 定期清理缓存(每10分钟)
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
decryptCache.cleanup()
|
encryptor.clearCache()
|
||||||
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
|
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
|
||||||
},
|
},
|
||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建 OAuth2 客户端(支持代理配置)
|
// 创建 OAuth2 客户端(支持代理配置)
|
||||||
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
|
||||||
// 如果没有提供 redirectUri,使用默认值
|
// 如果没有提供 redirectUri,使用默认值
|
||||||
const uri = redirectUri || 'http://localhost:45462'
|
const uri = redirectUri || 'http://localhost:45462'
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 准备客户端选项
|
// 准备客户端选项
|
||||||
const clientOptions = {
|
const clientOptions = {
|
||||||
clientId: OAUTH_CLIENT_ID,
|
clientId: oauthConfig.clientId,
|
||||||
clientSecret: OAUTH_CLIENT_SECRET,
|
clientSecret: oauthConfig.clientSecret,
|
||||||
redirectUri: uri
|
redirectUri: uri
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +242,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成授权 URL (支持 PKCE 和代理)
|
// 生成授权 URL (支持 PKCE 和代理)
|
||||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
async function generateAuthUrl(
|
||||||
|
state = null,
|
||||||
|
redirectUri = null,
|
||||||
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
|
) {
|
||||||
// 使用新的 redirect URI
|
// 使用新的 redirect URI
|
||||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
||||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
|
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -172,7 +269,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
redirect_uri: finalRedirectUri,
|
redirect_uri: finalRedirectUri,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
scope: OAUTH_SCOPES,
|
scope: oauthConfig.scopes,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
code_challenge: codeVerifier.codeChallenge,
|
code_challenge: codeVerifier.codeChallenge,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
@@ -183,7 +280,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
codeVerifier: codeVerifier.codeVerifier,
|
codeVerifier: codeVerifier.codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
|
oauthProvider: normalizedProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +342,14 @@ async function exchangeCodeForTokens(
|
|||||||
code,
|
code,
|
||||||
redirectUri = null,
|
redirectUri = null,
|
||||||
codeVerifier = null,
|
codeVerifier = null,
|
||||||
proxyConfig = null
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
// 创建带代理配置的 OAuth2Client
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
|
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -274,7 +375,7 @@ async function exchangeCodeForTokens(
|
|||||||
return {
|
return {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
scope: tokens.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: tokens.token_type || 'Bearer',
|
token_type: tokens.token_type || 'Bearer',
|
||||||
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
||||||
}
|
}
|
||||||
@@ -285,9 +386,11 @@ async function exchangeCodeForTokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 刷新访问令牌
|
// 刷新访问令牌
|
||||||
async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||||
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
// 创建带代理配置的 OAuth2Client
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(null, proxyConfig)
|
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 设置 refresh_token
|
// 设置 refresh_token
|
||||||
@@ -319,7 +422,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
return {
|
return {
|
||||||
access_token: credentials.access_token,
|
access_token: credentials.access_token,
|
||||||
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
|
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
|
||||||
scope: credentials.scope || OAUTH_SCOPES.join(' '),
|
scope: credentials.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: credentials.token_type || 'Bearer',
|
token_type: credentials.token_type || 'Bearer',
|
||||||
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
||||||
}
|
}
|
||||||
@@ -339,6 +442,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
async function createAccount(accountData) {
|
async function createAccount(accountData) {
|
||||||
const id = uuidv4()
|
const id = uuidv4()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 处理凭证数据
|
// 处理凭证数据
|
||||||
let geminiOauth = null
|
let geminiOauth = null
|
||||||
@@ -371,7 +476,7 @@ async function createAccount(accountData) {
|
|||||||
geminiOauth = JSON.stringify({
|
geminiOauth = JSON.stringify({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
scope: accountData.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: accountData.tokenType || 'Bearer',
|
token_type: accountData.tokenType || 'Bearer',
|
||||||
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||||
})
|
})
|
||||||
@@ -399,7 +504,8 @@ async function createAccount(accountData) {
|
|||||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||||
// 只有OAuth方式才有scopes,手动添加的没有
|
// 只有OAuth方式才有scopes,手动添加的没有
|
||||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
|
||||||
|
oauthProvider,
|
||||||
|
|
||||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
@@ -426,6 +532,7 @@ async function createAccount(accountData) {
|
|||||||
// 保存到 Redis
|
// 保存到 Redis
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
|
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
|
||||||
|
await redisClient.addToIndex('gemini_account:index', id)
|
||||||
|
|
||||||
// 如果是共享账户,添加到共享账户集合
|
// 如果是共享账户,添加到共享账户集合
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
@@ -508,6 +615,10 @@ async function updateAccount(accountId, updates) {
|
|||||||
updates.schedulable = updates.schedulable.toString()
|
updates.schedulable = updates.schedulable.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.oauthProvider !== undefined) {
|
||||||
|
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
|
||||||
|
}
|
||||||
|
|
||||||
// 加密敏感字段
|
// 加密敏感字段
|
||||||
if (updates.geminiOauth) {
|
if (updates.geminiOauth) {
|
||||||
updates.geminiOauth = encrypt(
|
updates.geminiOauth = encrypt(
|
||||||
@@ -623,19 +734,20 @@ async function deleteAccount(accountId) {
|
|||||||
// 从 Redis 删除
|
// 从 Redis 删除
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
await redisClient.removeFromIndex('gemini_account:index', accountId)
|
||||||
|
|
||||||
// 从共享账户集合中移除
|
// 从共享账户集合中移除
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
|
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理会话映射
|
// 清理会话映射(使用反向索引)
|
||||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
|
||||||
for (const key of sessionMappings) {
|
if (sessionHashes.length > 0) {
|
||||||
const mappedAccountId = await client.get(key)
|
const pipeline = client.pipeline()
|
||||||
if (mappedAccountId === accountId) {
|
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||||
await client.del(key)
|
pipeline.del(`gemini_account_sessions:${accountId}`)
|
||||||
}
|
await pipeline.exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Deleted Gemini account: ${accountId}`)
|
logger.info(`Deleted Gemini account: ${accountId}`)
|
||||||
@@ -644,12 +756,18 @@ async function deleteAccount(accountId) {
|
|||||||
|
|
||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
async function getAllAccounts() {
|
async function getAllAccounts() {
|
||||||
const client = redisClient.getClientSafe()
|
const _client = redisClient.getClientSafe()
|
||||||
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
|
const accountIds = await redisClient.getAllIdsByIndex(
|
||||||
|
'gemini_account:index',
|
||||||
|
`${GEMINI_ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^gemini_account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const accountData = await client.hgetall(key)
|
const accountData = dataList[i]
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
// 获取限流状态信息
|
// 获取限流状态信息
|
||||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||||
@@ -752,6 +870,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
3600, // 1小时过期
|
3600, // 1小时过期
|
||||||
account.id
|
account.id
|
||||||
)
|
)
|
||||||
|
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
|
||||||
|
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
return account
|
return account
|
||||||
@@ -811,6 +931,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
// 创建粘性会话映射
|
// 创建粘性会话映射
|
||||||
if (sessionHash) {
|
if (sessionHash) {
|
||||||
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
|
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
|
||||||
|
await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||||
|
await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedAccount
|
return selectedAccount
|
||||||
@@ -885,12 +1007,13 @@ async function refreshAccountToken(accountId) {
|
|||||||
// 重新获取账户数据(可能已被其他进程刷新)
|
// 重新获取账户数据(可能已被其他进程刷新)
|
||||||
const updatedAccount = await getAccount(accountId)
|
const updatedAccount = await getAccount(accountId)
|
||||||
if (updatedAccount && updatedAccount.accessToken) {
|
if (updatedAccount && updatedAccount.accessToken) {
|
||||||
|
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
|
||||||
const accessToken = decrypt(updatedAccount.accessToken)
|
const accessToken = decrypt(updatedAccount.accessToken)
|
||||||
return {
|
return {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
||||||
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
||||||
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
|
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
|
||||||
token_type: 'Bearer'
|
token_type: 'Bearer'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,7 +1027,11 @@ async function refreshAccountToken(accountId) {
|
|||||||
|
|
||||||
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
||||||
// 传入账户的代理配置
|
// 传入账户的代理配置
|
||||||
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
|
const newTokens = await refreshAccessToken(
|
||||||
|
account.refreshToken,
|
||||||
|
account.proxy,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 更新账户信息
|
// 更新账户信息
|
||||||
const updates = {
|
const updates = {
|
||||||
@@ -1036,14 +1163,15 @@ async function getAccountRateLimitInfo(accountId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||||
const client = createOAuth2Client(null, proxyConfig)
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
|
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope:
|
scope: oauthConfig.scopes.join(' '),
|
||||||
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
|
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expiry_date: 1754269905646
|
expiry_date: 1754269905646
|
||||||
}
|
}
|
||||||
@@ -1509,6 +1637,43 @@ async function generateContent(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用 Antigravity 上游生成内容(非流式)
|
||||||
|
async function generateContentAntigravity(
|
||||||
|
client,
|
||||||
|
requestData,
|
||||||
|
userPromptId,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
proxyConfig = null
|
||||||
|
) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('🪐 Antigravity generateContent API调用开始', {
|
||||||
|
model,
|
||||||
|
userPromptId,
|
||||||
|
projectId,
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
logger.info('✅ Antigravity generateContent API调用成功')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 调用 Code Assist API 生成内容(流式)
|
// 调用 Code Assist API 生成内容(流式)
|
||||||
async function generateContentStream(
|
async function generateContentStream(
|
||||||
client,
|
client,
|
||||||
@@ -1593,6 +1758,46 @@ async function generateContentStream(
|
|||||||
return response.data // 返回流对象
|
return response.data // 返回流对象
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用 Antigravity 上游生成内容(流式)
|
||||||
|
async function generateContentStreamAntigravity(
|
||||||
|
client,
|
||||||
|
requestData,
|
||||||
|
userPromptId,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
signal = null,
|
||||||
|
proxyConfig = null
|
||||||
|
) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
|
||||||
|
model,
|
||||||
|
userPromptId,
|
||||||
|
projectId,
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId,
|
||||||
|
stream: true,
|
||||||
|
signal,
|
||||||
|
params: { alt: 'sse' }
|
||||||
|
})
|
||||||
|
logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 更新账户的临时项目 ID
|
// 更新账户的临时项目 ID
|
||||||
async function updateTempProjectId(accountId, tempProjectId) {
|
async function updateTempProjectId(accountId, tempProjectId) {
|
||||||
if (!tempProjectId) {
|
if (!tempProjectId) {
|
||||||
@@ -1684,13 +1889,14 @@ module.exports = {
|
|||||||
setupUser,
|
setupUser,
|
||||||
encrypt,
|
encrypt,
|
||||||
decrypt,
|
decrypt,
|
||||||
generateEncryptionKey,
|
encryptor, // 暴露加密器以便测试和监控
|
||||||
decryptCache, // 暴露缓存对象以便测试和监控
|
|
||||||
countTokens,
|
countTokens,
|
||||||
|
countTokensAntigravity,
|
||||||
generateContent,
|
generateContent,
|
||||||
generateContentStream,
|
generateContentStream,
|
||||||
|
generateContentAntigravity,
|
||||||
|
generateContentStreamAntigravity,
|
||||||
|
fetchAvailableModelsAntigravity,
|
||||||
updateTempProjectId,
|
updateTempProjectId,
|
||||||
resetAccountStatus,
|
resetAccountStatus
|
||||||
OAUTH_CLIENT_ID,
|
|
||||||
OAUTH_SCOPES
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class GeminiApiAccountService {
|
|||||||
// 保存到 Redis
|
// 保存到 Redis
|
||||||
await this._saveAccount(accountId, accountData)
|
await this._saveAccount(accountId, accountData)
|
||||||
|
|
||||||
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
|
logger.success(`Created Gemini-API account: ${name} (${accountId})`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...accountData,
|
...accountData,
|
||||||
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
|
|||||||
// 从共享账户列表中移除
|
// 从共享账户列表中移除
|
||||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redis.removeFromIndex('gemini_api_account:index', accountId)
|
||||||
|
|
||||||
// 删除账户数据
|
// 删除账户数据
|
||||||
await client.del(key)
|
await client.del(key)
|
||||||
|
|
||||||
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
const allAccountIds = await redis.getAllIdsByIndex(
|
||||||
for (const key of keys) {
|
'gemini_api_account:index',
|
||||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^gemini_api_account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
|
const dataList = await redis.batchHgetallChunked(keys)
|
||||||
|
for (let i = 0; i < allAccountIds.length; i++) {
|
||||||
|
const accountId = allAccountIds[i]
|
||||||
if (!accountIds.includes(accountId)) {
|
if (!accountIds.includes(accountId)) {
|
||||||
const accountData = await client.hgetall(key)
|
const accountData = dataList[i]
|
||||||
if (accountData && accountData.id) {
|
if (accountData && accountData.id) {
|
||||||
// 过滤非活跃账户
|
// 过滤非活跃账户
|
||||||
if (includeInactive || accountData.isActive === 'true') {
|
if (includeInactive || accountData.isActive === 'true') {
|
||||||
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
|
|||||||
// 保存账户数据
|
// 保存账户数据
|
||||||
await client.hset(key, accountData)
|
await client.hset(key, accountData)
|
||||||
|
|
||||||
|
// 添加到索引
|
||||||
|
await redis.addToIndex('gemini_api_account:index', accountId)
|
||||||
|
|
||||||
// 添加到共享账户列表
|
// 添加到共享账户列表
|
||||||
if (accountData.accountType === 'shared') {
|
if (accountData.accountType === 'shared') {
|
||||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null
|
|||||||
0, // cacheCreateTokens (Gemini 没有这个概念)
|
0, // cacheCreateTokens (Gemini 没有这个概念)
|
||||||
0, // cacheReadTokens (Gemini 没有这个概念)
|
0, // cacheReadTokens (Gemini 没有这个概念)
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record Gemini usage:', error)
|
logger.error('❌ Failed to record Gemini usage:', error)
|
||||||
@@ -317,7 +318,8 @@ async function sendGeminiRequest({
|
|||||||
0, // cacheCreateTokens
|
0, // cacheCreateTokens
|
||||||
0, // cacheReadTokens
|
0, // cacheReadTokens
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
'gemini'
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record Gemini usage:', error)
|
logger.error('❌ Failed to record Gemini usage:', error)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ModelService {
|
|||||||
(sum, config) => sum + config.models.length,
|
(sum, config) => sum + config.models.length,
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
logger.success(`✅ Model service initialized with ${totalModels} models`)
|
logger.success(`Model service initialized with ${totalModels} models`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const redisClient = require('../models/redis')
|
const redisClient = require('../models/redis')
|
||||||
const { v4: uuidv4 } = require('uuid')
|
const { v4: uuidv4 } = require('uuid')
|
||||||
const crypto = require('crypto')
|
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
@@ -13,104 +12,23 @@ const {
|
|||||||
logTokenUsage,
|
logTokenUsage,
|
||||||
logRefreshSkipped
|
logRefreshSkipped
|
||||||
} = require('../utils/tokenRefreshLogger')
|
} = require('../utils/tokenRefreshLogger')
|
||||||
const LRUCache = require('../utils/lruCache')
|
|
||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
|
const { createEncryptor } = require('../utils/commonHelper')
|
||||||
|
|
||||||
// 加密相关常量
|
// 使用 commonHelper 的加密器
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const encryptor = createEncryptor('openai-account-salt')
|
||||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
const { encrypt, decrypt } = encryptor
|
||||||
const IV_LENGTH = 16
|
|
||||||
|
|
||||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
|
||||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
|
||||||
let _encryptionKeyCache = null
|
|
||||||
|
|
||||||
// 🔄 解密结果缓存,提高解密性能
|
|
||||||
const decryptCache = new LRUCache(500)
|
|
||||||
|
|
||||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
|
||||||
function generateEncryptionKey() {
|
|
||||||
if (!_encryptionKeyCache) {
|
|
||||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
|
||||||
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
|
|
||||||
}
|
|
||||||
return _encryptionKeyCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAI 账户键前缀
|
// OpenAI 账户键前缀
|
||||||
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
||||||
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
||||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
||||||
|
|
||||||
// 加密函数
|
|
||||||
function encrypt(text) {
|
|
||||||
if (!text) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const key = generateEncryptionKey()
|
|
||||||
const iv = crypto.randomBytes(IV_LENGTH)
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
||||||
let encrypted = cipher.update(text)
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
|
||||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解密函数
|
|
||||||
function decrypt(text) {
|
|
||||||
if (!text || text === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
|
|
||||||
if (text.length < 33 || text.charAt(32) !== ':') {
|
|
||||||
logger.warn('Invalid encrypted text format, returning empty string', {
|
|
||||||
textLength: text ? text.length : 0,
|
|
||||||
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
|
|
||||||
first50: text ? text.substring(0, 50) : 'N/A'
|
|
||||||
})
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 检查缓存
|
|
||||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
|
||||||
const cached = decryptCache.get(cacheKey)
|
|
||||||
if (cached !== undefined) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = generateEncryptionKey()
|
|
||||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
|
||||||
const ivHex = text.substring(0, 32)
|
|
||||||
const encryptedHex = text.substring(33) // 跳过冒号
|
|
||||||
|
|
||||||
const iv = Buffer.from(ivHex, 'hex')
|
|
||||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
||||||
let decrypted = decipher.update(encryptedText)
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
|
||||||
const result = decrypted.toString()
|
|
||||||
|
|
||||||
// 💾 存入缓存(5分钟过期)
|
|
||||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
|
||||||
|
|
||||||
// 📊 定期打印缓存统计
|
|
||||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
|
||||||
decryptCache.printStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Decryption error:', error)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🧹 定期清理缓存(每10分钟)
|
// 🧹 定期清理缓存(每10分钟)
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
decryptCache.cleanup()
|
encryptor.clearCache()
|
||||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
|
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
|
||||||
},
|
},
|
||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
|
|||||||
|
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||||
|
await redisClient.addToIndex('openai:account:index', accountId)
|
||||||
|
|
||||||
// 如果是共享账户,添加到共享账户集合
|
// 如果是共享账户,添加到共享账户集合
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
|
|||||||
// 从 Redis 删除
|
// 从 Redis 删除
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
await redisClient.removeFromIndex('openai:account:index', accountId)
|
||||||
|
|
||||||
// 从共享账户集合中移除
|
// 从共享账户集合中移除
|
||||||
if (account.accountType === 'shared') {
|
if (account.accountType === 'shared') {
|
||||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理会话映射
|
// 清理会话映射(使用反向索引)
|
||||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
|
||||||
for (const key of sessionMappings) {
|
if (sessionHashes.length > 0) {
|
||||||
const mappedAccountId = await client.get(key)
|
const pipeline = client.pipeline()
|
||||||
if (mappedAccountId === accountId) {
|
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||||
await client.del(key)
|
pipeline.del(`openai_account_sessions:${accountId}`)
|
||||||
}
|
await pipeline.exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Deleted OpenAI account: ${accountId}`)
|
logger.info(`Deleted OpenAI account: ${accountId}`)
|
||||||
@@ -746,12 +666,18 @@ async function deleteAccount(accountId) {
|
|||||||
|
|
||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
async function getAllAccounts() {
|
async function getAllAccounts() {
|
||||||
const client = redisClient.getClientSafe()
|
const _client = redisClient.getClientSafe()
|
||||||
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
const accountIds = await redisClient.getAllIdsByIndex(
|
||||||
|
'openai:account:index',
|
||||||
|
`${OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^openai:account:(.+)$/
|
||||||
|
)
|
||||||
|
const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||||
|
|
||||||
for (const key of keys) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const accountData = await client.hgetall(key)
|
const accountData = dataList[i]
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||||
|
|
||||||
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
3600, // 1小时过期
|
3600, // 1小时过期
|
||||||
account.id
|
account.id
|
||||||
)
|
)
|
||||||
|
// 反向索引:accountId -> sessionHash(用于删除账户时快速清理)
|
||||||
|
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
|
||||||
|
await client.expire(`openai_account_sessions:${account.id}`, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
return account
|
return account
|
||||||
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
3600, // 1小时过期
|
3600, // 1小时过期
|
||||||
selectedAccount.id
|
selectedAccount.id
|
||||||
)
|
)
|
||||||
|
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||||
|
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedAccount
|
return selectedAccount
|
||||||
@@ -1278,6 +1209,5 @@ module.exports = {
|
|||||||
updateCodexUsageSnapshot,
|
updateCodexUsageSnapshot,
|
||||||
encrypt,
|
encrypt,
|
||||||
decrypt,
|
decrypt,
|
||||||
generateEncryptionKey,
|
encryptor // 暴露加密器以便测试和监控
|
||||||
decryptCache // 暴露缓存对象以便测试和监控
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
|
|||||||
// 保存到 Redis
|
// 保存到 Redis
|
||||||
await this._saveAccount(accountId, accountData)
|
await this._saveAccount(accountId, accountData)
|
||||||
|
|
||||||
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
|
logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...accountData,
|
...accountData,
|
||||||
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
|
|||||||
// 从共享账户列表中移除
|
// 从共享账户列表中移除
|
||||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redis.removeFromIndex('openai_responses_account:index', accountId)
|
||||||
|
|
||||||
// 删除账户数据
|
// 删除账户数据
|
||||||
await client.del(key)
|
await client.del(key)
|
||||||
|
|
||||||
@@ -191,97 +194,68 @@ class OpenAIResponsesAccountService {
|
|||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
async getAllAccounts(includeInactive = false) {
|
async getAllAccounts(includeInactive = false) {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
|
||||||
|
// 使用索引获取所有账户ID
|
||||||
|
const accountIds = await redis.getAllIdsByIndex(
|
||||||
|
'openai_responses_account:index',
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||||
|
/^openai_responses_account:(.+)$/
|
||||||
|
)
|
||||||
|
if (accountIds.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||||
|
// Pipeline 批量查询所有账户数据
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
const accounts = []
|
const accounts = []
|
||||||
|
results.forEach(([err, accountData]) => {
|
||||||
|
if (err || !accountData || !accountData.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const accountId of accountIds) {
|
// 过滤非活跃账户
|
||||||
const account = await this.getAccount(accountId)
|
if (!includeInactive && accountData.isActive !== 'true') {
|
||||||
if (account) {
|
return
|
||||||
// 过滤非活跃账户
|
}
|
||||||
if (includeInactive || account.isActive === 'true') {
|
|
||||||
// 隐藏敏感信息
|
|
||||||
account.apiKey = '***'
|
|
||||||
|
|
||||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
// 隐藏敏感信息
|
||||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
accountData.apiKey = '***'
|
||||||
|
|
||||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
// 解析 JSON 字段
|
||||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
if (accountData.proxy) {
|
||||||
? {
|
try {
|
||||||
isRateLimited: true,
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
rateLimitedAt: account.rateLimitedAt || null,
|
} catch {
|
||||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
accountData.proxy = null
|
||||||
}
|
|
||||||
: {
|
|
||||||
isRateLimited: false,
|
|
||||||
rateLimitedAt: null,
|
|
||||||
minutesRemaining: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
|
||||||
account.schedulable = account.schedulable !== 'false'
|
|
||||||
// 转换 isActive 字段为布尔值
|
|
||||||
account.isActive = account.isActive === 'true'
|
|
||||||
|
|
||||||
// ✅ 前端显示订阅过期时间(业务字段)
|
|
||||||
account.expiresAt = account.subscriptionExpiresAt || null
|
|
||||||
account.platform = account.platform || 'openai-responses'
|
|
||||||
|
|
||||||
accounts.push(account)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
// 获取限流状态信息
|
||||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||||
for (const key of keys) {
|
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
? {
|
||||||
if (!accountIds.includes(accountId)) {
|
isRateLimited: true,
|
||||||
const accountData = await client.hgetall(key)
|
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||||
if (accountData && accountData.id) {
|
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||||
// 过滤非活跃账户
|
|
||||||
if (includeInactive || accountData.isActive === 'true') {
|
|
||||||
// 隐藏敏感信息
|
|
||||||
accountData.apiKey = '***'
|
|
||||||
// 解析 JSON 字段
|
|
||||||
if (accountData.proxy) {
|
|
||||||
try {
|
|
||||||
accountData.proxy = JSON.parse(accountData.proxy)
|
|
||||||
} catch (e) {
|
|
||||||
accountData.proxy = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
|
||||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
|
||||||
|
|
||||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
|
||||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
|
||||||
? {
|
|
||||||
isRateLimited: true,
|
|
||||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
|
||||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
isRateLimited: false,
|
|
||||||
rateLimitedAt: null,
|
|
||||||
minutesRemaining: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
|
||||||
accountData.schedulable = accountData.schedulable !== 'false'
|
|
||||||
// 转换 isActive 字段为布尔值
|
|
||||||
accountData.isActive = accountData.isActive === 'true'
|
|
||||||
|
|
||||||
// ✅ 前端显示订阅过期时间(业务字段)
|
|
||||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
|
||||||
accountData.platform = accountData.platform || 'openai-responses'
|
|
||||||
|
|
||||||
accounts.push(accountData)
|
|
||||||
}
|
}
|
||||||
}
|
: {
|
||||||
}
|
isRateLimited: false,
|
||||||
}
|
rateLimitedAt: null,
|
||||||
|
minutesRemaining: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换字段类型
|
||||||
|
accountData.schedulable = accountData.schedulable !== 'false'
|
||||||
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
|
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||||
|
accountData.platform = accountData.platform || 'openai-responses'
|
||||||
|
|
||||||
|
accounts.push(accountData)
|
||||||
|
})
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
@@ -644,6 +618,9 @@ class OpenAIResponsesAccountService {
|
|||||||
// 保存账户数据
|
// 保存账户数据
|
||||||
await client.hset(key, accountData)
|
await client.hset(key, accountData)
|
||||||
|
|
||||||
|
// 添加到索引
|
||||||
|
await redis.addToIndex('openai_responses_account:index', accountId)
|
||||||
|
|
||||||
// 添加到共享账户列表
|
// 添加到共享账户列表
|
||||||
if (accountData.accountType === 'shared') {
|
if (accountData.accountType === 'shared') {
|
||||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
|
|||||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
|
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
|
||||||
|
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
|
||||||
|
const LAST_USED_AT_THROTTLE_MS = 60000
|
||||||
|
|
||||||
// 抽取缓存写入 token,兼容多种字段命名
|
// 抽取缓存写入 token,兼容多种字段命名
|
||||||
function extractCacheCreationTokens(usageData) {
|
function extractCacheCreationTokens(usageData) {
|
||||||
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
|
|||||||
this.defaultTimeout = config.requestTimeout || 600000
|
this.defaultTimeout = config.requestTimeout || 600000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 节流更新 lastUsedAt
|
||||||
|
async _throttledUpdateLastUsedAt(accountId) {
|
||||||
|
const now = Date.now()
|
||||||
|
const lastUpdate = lastUsedAtThrottle.get(accountId)
|
||||||
|
|
||||||
|
if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) {
|
||||||
|
return // 跳过更新
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS)
|
||||||
|
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 处理请求转发
|
// 处理请求转发
|
||||||
async handleRequest(req, res, account, apiKeyData) {
|
async handleRequest(req, res, account, apiKeyData) {
|
||||||
let abortController = null
|
let abortController = null
|
||||||
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
|
|||||||
return res.status(response.status).json(errorData)
|
return res.status(response.status).json(errorData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新最后使用时间
|
// 更新最后使用时间(节流)
|
||||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
await this._throttledUpdateLastUsedAt(account.id)
|
||||||
lastUsedAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 处理流式响应
|
// 处理流式响应
|
||||||
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
||||||
@@ -426,9 +444,9 @@ class OpenAIResponsesRelayService {
|
|||||||
const lines = data.split('\n')
|
const lines = data.split('\n')
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data:')) {
|
||||||
try {
|
try {
|
||||||
const jsonStr = line.slice(6)
|
const jsonStr = line.slice(5).trim()
|
||||||
if (jsonStr === '[DONE]') {
|
if (jsonStr === '[DONE]') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -539,7 +557,8 @@ class OpenAIResponsesRelayService {
|
|||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
modelToRecord,
|
modelToRecord,
|
||||||
account.id
|
account.id,
|
||||||
|
'openai-responses'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -667,7 +686,8 @@ class OpenAIResponsesRelayService {
|
|||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
actualModel,
|
actualModel,
|
||||||
account.id
|
account.id,
|
||||||
|
'openai-responses'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class PricingService {
|
|||||||
// 设置文件监听器
|
// 设置文件监听器
|
||||||
this.setupFileWatcher()
|
this.setupFileWatcher()
|
||||||
|
|
||||||
logger.success('💰 Pricing service initialized successfully')
|
logger.success('Pricing service initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to initialize pricing service:', error)
|
logger.error('❌ Failed to initialize pricing service:', error)
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ class PricingService {
|
|||||||
this.pricingData = jsonData
|
this.pricingData = jsonData
|
||||||
this.lastUpdated = new Date()
|
this.lastUpdated = new Date()
|
||||||
|
|
||||||
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||||
|
|
||||||
// 设置或重新设置文件监听器
|
// 设置或重新设置文件监听器
|
||||||
this.setupFileWatcher()
|
this.setupFileWatcher()
|
||||||
@@ -762,7 +762,7 @@ class PricingService {
|
|||||||
this.lastUpdated = new Date()
|
this.lastUpdated = new Date()
|
||||||
|
|
||||||
const modelCount = Object.keys(jsonData).length
|
const modelCount = Object.keys(jsonData).length
|
||||||
logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`)
|
logger.success(`Reloaded pricing data for ${modelCount} models from file`)
|
||||||
|
|
||||||
// 显示一些统计信息
|
// 显示一些统计信息
|
||||||
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
||||||
|
|||||||
698
src/services/quotaCardService.js
Normal file
698
src/services/quotaCardService.js
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
/**
|
||||||
|
* 额度卡/时间卡服务
|
||||||
|
* 管理员生成卡,用户核销,管理员可撤销
|
||||||
|
*/
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
class QuotaCardService {
|
||||||
|
constructor() {
|
||||||
|
this.CARD_PREFIX = 'quota_card:'
|
||||||
|
this.REDEMPTION_PREFIX = 'redemption:'
|
||||||
|
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
|
||||||
|
this.LIMITS_CONFIG_KEY = 'system:quota_card_limits'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取额度卡上限配置
|
||||||
|
*/
|
||||||
|
async getLimitsConfig() {
|
||||||
|
try {
|
||||||
|
const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY)
|
||||||
|
if (configStr) {
|
||||||
|
return JSON.parse(configStr)
|
||||||
|
}
|
||||||
|
// 没有 Redis 配置时,使用 config.js 默认值
|
||||||
|
const config = require('../../config/config')
|
||||||
|
return (
|
||||||
|
config.quotaCardLimits || {
|
||||||
|
enabled: true,
|
||||||
|
maxExpiryDays: 90,
|
||||||
|
maxTotalCostLimit: 1000
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get limits config:', error)
|
||||||
|
return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存额度卡上限配置
|
||||||
|
*/
|
||||||
|
async saveLimitsConfig(config) {
|
||||||
|
try {
|
||||||
|
const parsedDays = parseInt(config.maxExpiryDays)
|
||||||
|
const parsedCost = parseFloat(config.maxTotalCostLimit)
|
||||||
|
const newConfig = {
|
||||||
|
enabled: config.enabled !== false,
|
||||||
|
maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays,
|
||||||
|
maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig))
|
||||||
|
logger.info('✅ Quota card limits config saved')
|
||||||
|
return newConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to save limits config:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX)
|
||||||
|
*/
|
||||||
|
_generateCardCode() {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
|
||||||
|
let code = ''
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
code += chars.charAt(crypto.randomInt(chars.length))
|
||||||
|
}
|
||||||
|
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建额度卡/时间卡
|
||||||
|
* @param {Object} options - 卡配置
|
||||||
|
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
|
||||||
|
* @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填)
|
||||||
|
* @param {number} options.timeAmount - 时间数量(time/combo 类型必填)
|
||||||
|
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
|
||||||
|
* @param {string} options.expiresAt - 卡本身的有效期(可选)
|
||||||
|
* @param {string} options.note - 备注
|
||||||
|
* @param {string} options.createdBy - 创建者 ID
|
||||||
|
* @returns {Object} 创建的卡信息
|
||||||
|
*/
|
||||||
|
async createCard(options = {}) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
type = 'quota',
|
||||||
|
quotaAmount = 0,
|
||||||
|
timeAmount = 0,
|
||||||
|
timeUnit = 'days',
|
||||||
|
expiresAt = null,
|
||||||
|
note = '',
|
||||||
|
createdBy = 'admin'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if (!['quota', 'time', 'combo'].includes(type)) {
|
||||||
|
throw new Error('Invalid card type')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
|
||||||
|
throw new Error('quotaAmount is required for quota/combo cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
|
||||||
|
throw new Error('timeAmount is required for time/combo cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardId = uuidv4()
|
||||||
|
const cardCode = this._generateCardCode()
|
||||||
|
|
||||||
|
const cardData = {
|
||||||
|
id: cardId,
|
||||||
|
code: cardCode,
|
||||||
|
type,
|
||||||
|
quotaAmount: String(quotaAmount || 0),
|
||||||
|
timeAmount: String(timeAmount || 0),
|
||||||
|
timeUnit: timeUnit || 'days',
|
||||||
|
status: 'unused', // unused | redeemed | revoked | expired
|
||||||
|
createdBy,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: expiresAt || '',
|
||||||
|
note: note || '',
|
||||||
|
// 核销信息
|
||||||
|
redeemedBy: '',
|
||||||
|
redeemedByUsername: '',
|
||||||
|
redeemedApiKeyId: '',
|
||||||
|
redeemedApiKeyName: '',
|
||||||
|
redeemedAt: '',
|
||||||
|
// 撤销信息
|
||||||
|
revokedAt: '',
|
||||||
|
revokedBy: '',
|
||||||
|
revokeReason: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存卡数据
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
|
||||||
|
|
||||||
|
// 建立卡号到 ID 的映射(用于快速查找)
|
||||||
|
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
|
||||||
|
|
||||||
|
// 添加到卡列表索引
|
||||||
|
await redis.client.sadd('quota_cards:all', cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cardId,
|
||||||
|
code: cardCode,
|
||||||
|
type,
|
||||||
|
quotaAmount: parseFloat(quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(timeAmount || 0),
|
||||||
|
timeUnit,
|
||||||
|
status: 'unused',
|
||||||
|
createdBy,
|
||||||
|
createdAt: cardData.createdAt,
|
||||||
|
expiresAt: cardData.expiresAt,
|
||||||
|
note
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量创建卡
|
||||||
|
* @param {Object} options - 卡配置
|
||||||
|
* @param {number} count - 创建数量
|
||||||
|
* @returns {Array} 创建的卡列表
|
||||||
|
*/
|
||||||
|
async createCardsBatch(options = {}, count = 1) {
|
||||||
|
const cards = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const card = await this.createCard(options)
|
||||||
|
cards.push(card)
|
||||||
|
}
|
||||||
|
logger.success(`🎫 Batch created ${count} cards`)
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过卡号获取卡信息
|
||||||
|
*/
|
||||||
|
async getCardByCode(code) {
|
||||||
|
try {
|
||||||
|
const cardId = await redis.client.get(`quota_card_code:${code}`)
|
||||||
|
if (!cardId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this.getCardById(cardId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card by code:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 ID 获取卡信息
|
||||||
|
*/
|
||||||
|
async getCardById(cardId) {
|
||||||
|
try {
|
||||||
|
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
|
||||||
|
if (!cardData || Object.keys(cardData).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cardData.id,
|
||||||
|
code: cardData.code,
|
||||||
|
type: cardData.type,
|
||||||
|
quotaAmount: parseFloat(cardData.quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(cardData.timeAmount || 0),
|
||||||
|
timeUnit: cardData.timeUnit,
|
||||||
|
status: cardData.status,
|
||||||
|
createdBy: cardData.createdBy,
|
||||||
|
createdAt: cardData.createdAt,
|
||||||
|
expiresAt: cardData.expiresAt,
|
||||||
|
note: cardData.note,
|
||||||
|
redeemedBy: cardData.redeemedBy,
|
||||||
|
redeemedByUsername: cardData.redeemedByUsername,
|
||||||
|
redeemedApiKeyId: cardData.redeemedApiKeyId,
|
||||||
|
redeemedApiKeyName: cardData.redeemedApiKeyName,
|
||||||
|
redeemedAt: cardData.redeemedAt,
|
||||||
|
revokedAt: cardData.revokedAt,
|
||||||
|
revokedBy: cardData.revokedBy,
|
||||||
|
revokeReason: cardData.revokeReason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有卡列表
|
||||||
|
* @param {Object} options - 查询选项
|
||||||
|
* @param {string} options.status - 按状态筛选
|
||||||
|
* @param {number} options.limit - 限制数量
|
||||||
|
* @param {number} options.offset - 偏移量
|
||||||
|
*/
|
||||||
|
async getAllCards(options = {}) {
|
||||||
|
try {
|
||||||
|
const { status, limit = 100, offset = 0 } = options
|
||||||
|
|
||||||
|
let cardIds
|
||||||
|
if (status) {
|
||||||
|
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
|
||||||
|
} else {
|
||||||
|
cardIds = await redis.client.smembers('quota_cards:all')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序(按创建时间倒序)
|
||||||
|
const cards = []
|
||||||
|
for (const cardId of cardIds) {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (card) {
|
||||||
|
cards.push(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const total = cards.length
|
||||||
|
const paginatedCards = cards.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: paginatedCards,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get all cards:', error)
|
||||||
|
return { cards: [], total: 0, limit: 100, offset: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核销卡
|
||||||
|
* @param {string} code - 卡号
|
||||||
|
* @param {string} apiKeyId - 目标 API Key ID
|
||||||
|
* @param {string} userId - 核销用户 ID
|
||||||
|
* @param {string} username - 核销用户名
|
||||||
|
* @returns {Object} 核销结果
|
||||||
|
*/
|
||||||
|
async redeemCard(code, apiKeyId, userId, username = '') {
|
||||||
|
try {
|
||||||
|
// 获取卡信息
|
||||||
|
const card = await this.getCardByCode(code)
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('卡号不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查卡状态
|
||||||
|
if (card.status !== 'unused') {
|
||||||
|
const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' }
|
||||||
|
throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查卡是否过期
|
||||||
|
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
|
||||||
|
// 更新卡状态为过期
|
||||||
|
await this._updateCardStatus(card.id, 'expired')
|
||||||
|
throw new Error('卡片已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 API Key 信息
|
||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API Key 不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取上限配置
|
||||||
|
const limits = await this.getLimitsConfig()
|
||||||
|
|
||||||
|
// 执行核销
|
||||||
|
const redemptionId = uuidv4()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 记录核销前状态
|
||||||
|
const beforeLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||||
|
const beforeExpiry = keyData.expiresAt || ''
|
||||||
|
|
||||||
|
// 应用卡效果
|
||||||
|
let afterLimit = beforeLimit
|
||||||
|
let afterExpiry = beforeExpiry
|
||||||
|
let quotaAdded = 0
|
||||||
|
let timeAdded = 0
|
||||||
|
let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days)
|
||||||
|
const warnings = [] // 截断警告信息
|
||||||
|
|
||||||
|
if (card.type === 'quota' || card.type === 'combo') {
|
||||||
|
let amountToAdd = card.quotaAmount
|
||||||
|
|
||||||
|
// 上限保护:检查是否超过最大额度限制
|
||||||
|
if (limits.enabled && limits.maxTotalCostLimit > 0) {
|
||||||
|
const maxAllowed = limits.maxTotalCostLimit - beforeLimit
|
||||||
|
if (amountToAdd > maxAllowed) {
|
||||||
|
amountToAdd = Math.max(0, maxAllowed)
|
||||||
|
warnings.push(
|
||||||
|
`额度已达上限,本次仅增加 ${amountToAdd} CC(原卡面 ${card.quotaAmount} CC)`
|
||||||
|
)
|
||||||
|
logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amountToAdd > 0) {
|
||||||
|
const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd)
|
||||||
|
afterLimit = result.newTotalCostLimit
|
||||||
|
quotaAdded = amountToAdd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.type === 'time' || card.type === 'combo') {
|
||||||
|
// 计算新的过期时间
|
||||||
|
let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date()
|
||||||
|
if (baseDate < new Date()) {
|
||||||
|
baseDate = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let newExpiry = new Date(baseDate)
|
||||||
|
switch (card.timeUnit) {
|
||||||
|
case 'hours':
|
||||||
|
newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000)
|
||||||
|
break
|
||||||
|
case 'days':
|
||||||
|
newExpiry.setDate(newExpiry.getDate() + card.timeAmount)
|
||||||
|
break
|
||||||
|
case 'months':
|
||||||
|
newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上限保护:检查是否超过最大有效期
|
||||||
|
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||||
|
const maxExpiry = new Date()
|
||||||
|
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||||
|
if (newExpiry > maxExpiry) {
|
||||||
|
newExpiry = maxExpiry
|
||||||
|
warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`)
|
||||||
|
logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
|
||||||
|
// 如果有上限保护,使用截断后的时间
|
||||||
|
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||||
|
const maxExpiry = new Date()
|
||||||
|
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||||
|
if (new Date(result.newExpiresAt) > maxExpiry) {
|
||||||
|
await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString())
|
||||||
|
afterExpiry = maxExpiry.toISOString()
|
||||||
|
// 计算实际增加的天数,截断时统一用天
|
||||||
|
const actualDays = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))
|
||||||
|
)
|
||||||
|
timeAdded = actualDays
|
||||||
|
actualTimeUnit = 'days'
|
||||||
|
} else {
|
||||||
|
afterExpiry = result.newExpiresAt
|
||||||
|
timeAdded = card.timeAmount
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
afterExpiry = result.newExpiresAt
|
||||||
|
timeAdded = card.timeAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡状态
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
|
||||||
|
status: 'redeemed',
|
||||||
|
redeemedBy: userId,
|
||||||
|
redeemedByUsername: username,
|
||||||
|
redeemedApiKeyId: apiKeyId,
|
||||||
|
redeemedApiKeyName: keyData.name || '',
|
||||||
|
redeemedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:unused`, card.id)
|
||||||
|
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
|
||||||
|
|
||||||
|
// 创建核销记录
|
||||||
|
const redemptionData = {
|
||||||
|
id: redemptionId,
|
||||||
|
cardId: card.id,
|
||||||
|
cardCode: card.code,
|
||||||
|
cardType: card.type,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
apiKeyId,
|
||||||
|
apiKeyName: keyData.name || '',
|
||||||
|
quotaAdded: String(quotaAdded),
|
||||||
|
timeAdded: String(timeAdded),
|
||||||
|
timeUnit: actualTimeUnit,
|
||||||
|
beforeLimit: String(beforeLimit),
|
||||||
|
afterLimit: String(afterLimit),
|
||||||
|
beforeExpiry,
|
||||||
|
afterExpiry,
|
||||||
|
timestamp: now,
|
||||||
|
status: 'active' // active | revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
|
||||||
|
|
||||||
|
// 添加到核销记录索引
|
||||||
|
await redis.client.sadd('redemptions:all', redemptionId)
|
||||||
|
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
|
||||||
|
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
|
||||||
|
|
||||||
|
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
warnings,
|
||||||
|
redemptionId,
|
||||||
|
cardCode: card.code,
|
||||||
|
cardType: card.type,
|
||||||
|
quotaAdded,
|
||||||
|
timeAdded,
|
||||||
|
timeUnit: actualTimeUnit,
|
||||||
|
beforeLimit,
|
||||||
|
afterLimit,
|
||||||
|
beforeExpiry,
|
||||||
|
afterExpiry
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to redeem card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销核销
|
||||||
|
* @param {string} redemptionId - 核销记录 ID
|
||||||
|
* @param {string} revokedBy - 撤销者 ID
|
||||||
|
* @param {string} reason - 撤销原因
|
||||||
|
* @returns {Object} 撤销结果
|
||||||
|
*/
|
||||||
|
async revokeRedemption(redemptionId, revokedBy, reason = '') {
|
||||||
|
try {
|
||||||
|
// 获取核销记录
|
||||||
|
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
|
||||||
|
if (!redemptionData || Object.keys(redemptionData).length === 0) {
|
||||||
|
throw new Error('Redemption record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redemptionData.status !== 'active') {
|
||||||
|
throw new Error('Redemption is already revoked')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 撤销效果
|
||||||
|
let actualDeducted = 0
|
||||||
|
if (parseFloat(redemptionData.quotaAdded) > 0) {
|
||||||
|
const result = await apiKeyService.deductTotalCostLimit(
|
||||||
|
redemptionData.apiKeyId,
|
||||||
|
parseFloat(redemptionData.quotaAdded)
|
||||||
|
)
|
||||||
|
;({ actualDeducted } = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
|
||||||
|
// 如果需要回退时间,可以在这里添加逻辑
|
||||||
|
|
||||||
|
// 更新核销记录状态
|
||||||
|
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: now,
|
||||||
|
revokedBy,
|
||||||
|
revokeReason: reason,
|
||||||
|
actualDeducted: String(actualDeducted)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新卡状态
|
||||||
|
const { cardId } = redemptionData
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: now,
|
||||||
|
revokedBy,
|
||||||
|
revokeReason: reason
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
redemptionId,
|
||||||
|
cardCode: redemptionData.cardCode,
|
||||||
|
actualDeducted,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to revoke redemption:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取核销记录
|
||||||
|
* @param {Object} options - 查询选项
|
||||||
|
* @param {string} options.userId - 按用户筛选
|
||||||
|
* @param {string} options.apiKeyId - 按 API Key 筛选
|
||||||
|
* @param {number} options.limit - 限制数量
|
||||||
|
* @param {number} options.offset - 偏移量
|
||||||
|
*/
|
||||||
|
async getRedemptions(options = {}) {
|
||||||
|
try {
|
||||||
|
const { userId, apiKeyId, limit = 100, offset = 0 } = options
|
||||||
|
|
||||||
|
let redemptionIds
|
||||||
|
if (userId) {
|
||||||
|
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
|
||||||
|
} else if (apiKeyId) {
|
||||||
|
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
|
||||||
|
} else {
|
||||||
|
redemptionIds = await redis.client.smembers('redemptions:all')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redemptions = []
|
||||||
|
for (const id of redemptionIds) {
|
||||||
|
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
redemptions.push({
|
||||||
|
id: data.id,
|
||||||
|
cardId: data.cardId,
|
||||||
|
cardCode: data.cardCode,
|
||||||
|
cardType: data.cardType,
|
||||||
|
userId: data.userId,
|
||||||
|
username: data.username,
|
||||||
|
apiKeyId: data.apiKeyId,
|
||||||
|
apiKeyName: data.apiKeyName,
|
||||||
|
quotaAdded: parseFloat(data.quotaAdded || 0),
|
||||||
|
timeAdded: parseInt(data.timeAdded || 0),
|
||||||
|
timeUnit: data.timeUnit,
|
||||||
|
beforeLimit: parseFloat(data.beforeLimit || 0),
|
||||||
|
afterLimit: parseFloat(data.afterLimit || 0),
|
||||||
|
beforeExpiry: data.beforeExpiry,
|
||||||
|
afterExpiry: data.afterExpiry,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
status: data.status,
|
||||||
|
revokedAt: data.revokedAt,
|
||||||
|
revokedBy: data.revokedBy,
|
||||||
|
revokeReason: data.revokeReason,
|
||||||
|
actualDeducted: parseFloat(data.actualDeducted || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序(按时间倒序)
|
||||||
|
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const total = redemptions.length
|
||||||
|
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
redemptions: paginatedRedemptions,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get redemptions:', error)
|
||||||
|
return { redemptions: [], total: 0, limit: 100, offset: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除未使用的卡
|
||||||
|
*/
|
||||||
|
async deleteCard(cardId) {
|
||||||
|
try {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('Card not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.status !== 'unused') {
|
||||||
|
throw new Error('Only unused cards can be deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除卡数据
|
||||||
|
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
|
||||||
|
await redis.client.del(`quota_card_code:${card.code}`)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redis.client.srem('quota_cards:all', cardId)
|
||||||
|
await redis.client.srem(`quota_cards:status:unused`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🗑️ Deleted card ${card.code}`)
|
||||||
|
|
||||||
|
return { success: true, cardCode: card.code }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新卡状态(内部方法)
|
||||||
|
*/
|
||||||
|
async _updateCardStatus(cardId, newStatus) {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (!card) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = card.status
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取卡统计信息
|
||||||
|
*/
|
||||||
|
async getCardStats() {
|
||||||
|
try {
|
||||||
|
const [unused, redeemed, revoked, expired] = await Promise.all([
|
||||||
|
redis.client.scard('quota_cards:status:unused'),
|
||||||
|
redis.client.scard('quota_cards:status:redeemed'),
|
||||||
|
redis.client.scard('quota_cards:status:revoked'),
|
||||||
|
redis.client.scard('quota_cards:status:expired')
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: unused + redeemed + revoked + expired,
|
||||||
|
unused,
|
||||||
|
redeemed,
|
||||||
|
revoked,
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card stats:', error)
|
||||||
|
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new QuotaCardService()
|
||||||
@@ -72,7 +72,8 @@ class RateLimitCleanupService {
|
|||||||
const results = {
|
const results = {
|
||||||
openai: { checked: 0, cleared: 0, errors: [] },
|
openai: { checked: 0, cleared: 0, errors: [] },
|
||||||
claude: { checked: 0, cleared: 0, errors: [] },
|
claude: { checked: 0, cleared: 0, errors: [] },
|
||||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 OpenAI 账号
|
// 清理 OpenAI 账号
|
||||||
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
|
|||||||
// 清理 Claude Console 账号
|
// 清理 Claude Console 账号
|
||||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||||
|
|
||||||
|
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||||
|
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||||
|
|
||||||
const totalChecked =
|
const totalChecked =
|
||||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||||
const totalCleared =
|
const totalCleared =
|
||||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
if (totalCleared > 0) {
|
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
|
||||||
)
|
)
|
||||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||||
logger.info(
|
logger.info(
|
||||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||||
)
|
)
|
||||||
|
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
|
logger.info(
|
||||||
|
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 发送 webhook 恢复通知
|
// 发送 webhook 恢复通知
|
||||||
if (this.clearedAccounts.length > 0) {
|
if (this.clearedAccounts.length > 0) {
|
||||||
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
|
|||||||
const allErrors = [
|
const allErrors = [
|
||||||
...results.openai.errors,
|
...results.openai.errors,
|
||||||
...results.claude.errors,
|
...results.claude.errors,
|
||||||
...results.claudeConsole.errors
|
...results.claudeConsole.errors,
|
||||||
|
...results.tokenRefresh.errors
|
||||||
]
|
]
|
||||||
if (allErrors.length > 0) {
|
if (allErrors.length > 0) {
|
||||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||||
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||||
|
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||||
|
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||||
|
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||||
|
* 不处理错误状态账户(error/temp_error)
|
||||||
|
*/
|
||||||
|
async proactiveRefreshClaudeTokens(result) {
|
||||||
|
try {
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
const now = Date.now()
|
||||||
|
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
|
||||||
|
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 1. 必须激活
|
||||||
|
if (account.isActive !== 'true') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 必须有 refreshToken
|
||||||
|
if (!account.refreshToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||||
|
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||||
|
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||||
|
const isWaitingForReset =
|
||||||
|
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||||
|
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||||
|
if (!isWaitingForReset) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
|
||||||
|
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
|
||||||
|
if (now - lastRefreshAt < recentRefreshMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查 Token 是否即将过期(30分钟内)
|
||||||
|
const expiresAt = parseInt(account.expiresAt)
|
||||||
|
if (expiresAt && now < expiresAt - refreshAheadMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 符合条件,执行刷新
|
||||||
|
result.checked++
|
||||||
|
try {
|
||||||
|
await claudeAccountService.refreshAccountToken(account.id)
|
||||||
|
result.refreshed++
|
||||||
|
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proactively refresh Claude tokens:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
|
|||||||
'x-stainless-runtime',
|
'x-stainless-runtime',
|
||||||
'x-stainless-runtime-version'
|
'x-stainless-runtime-version'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
|
||||||
|
const STAINLESS_HEADER_CASE_MAP = {
|
||||||
|
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
|
||||||
|
'x-stainless-timeout': 'X-Stainless-Timeout',
|
||||||
|
'x-stainless-lang': 'X-Stainless-Lang',
|
||||||
|
'x-stainless-package-version': 'X-Stainless-Package-Version',
|
||||||
|
'x-stainless-os': 'X-Stainless-OS',
|
||||||
|
'x-stainless-arch': 'X-Stainless-Arch',
|
||||||
|
'x-stainless-runtime': 'X-Stainless-Runtime',
|
||||||
|
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
|
||||||
|
}
|
||||||
const MIN_FINGERPRINT_FIELDS = 4
|
const MIN_FINGERPRINT_FIELDS = 4
|
||||||
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
||||||
|
|
||||||
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeHeaderCaseInsensitive(nextHeaders, key)
|
removeHeaderCaseInsensitive(nextHeaders, key)
|
||||||
nextHeaders[key] = fingerprint[key]
|
// 使用正确的大小写格式返回给上游
|
||||||
|
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
|
||||||
|
nextHeaders[properCaseKey] = fingerprint[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
return nextHeaders
|
return nextHeaders
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user