mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
53cda0fd18 | ||
|
|
151cb7536c | ||
|
|
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 | ||
|
|
0035f8cb4f | ||
|
|
d49cc0cec8 | ||
|
|
c4d6ab97f2 | ||
|
|
7053d5f1ac | ||
|
|
24796fc889 | ||
|
|
201d95c84e | ||
|
|
b978d864e3 | ||
|
|
175c041e5a | ||
|
|
b441506199 | ||
|
|
eb2341fb16 | ||
|
|
e89e2964e7 | ||
|
|
b3e27e9f15 | ||
|
|
d0b397b45a | ||
|
|
195e42e0a5 | ||
|
|
ebecee4c6f | ||
|
|
0607322cc7 | ||
|
|
0828746281 | ||
|
|
e1df90684a | ||
|
|
f74f77ef65 | ||
|
|
b63c3217bc | ||
|
|
d81a16b98d | ||
|
|
30727be92f | ||
|
|
b8a6cc627a | ||
|
|
01c63bf5df | ||
|
|
4317962955 | ||
|
|
b66fd7f655 | ||
|
|
ac280ef563 | ||
|
|
c70070d912 | ||
|
|
849d8e047b | ||
|
|
065aa6d35e | ||
|
|
10a1d61427 | ||
|
|
cfdcc97cc7 | ||
|
|
ea053c6a16 | ||
|
|
84a8fdeaba | ||
|
|
c1c941aa4c | ||
|
|
f78e376dea | ||
|
|
530d38e4a4 | ||
|
|
0bf7bfae04 | ||
|
|
fbb660138c | ||
|
|
9c970fda3b | ||
|
|
bfa3f528a2 | ||
|
|
9b0d0bee96 | ||
|
|
ff30bfab82 | ||
|
|
93497cc13c | ||
|
|
2429bad2b7 | ||
|
|
a03753030c | ||
|
|
94aca4dc22 | ||
|
|
6bfef2525a | ||
|
|
5a636a36f6 | ||
|
|
b61e1062bf | ||
|
|
6ab91c0c75 | ||
|
|
675e7b9111 | ||
|
|
f82db11e7d | ||
|
|
06b18b7186 | ||
|
|
12cb841a64 | ||
|
|
dc868522cf | ||
|
|
b1dc27b5d7 | ||
|
|
b94bd2b822 | ||
|
|
827c0f6207 | ||
|
|
0b3cf5112b | ||
|
|
3db268fff7 | ||
|
|
81971436e6 | ||
|
|
69a1006f4c | ||
|
|
4cf1762467 | ||
|
|
0d64d40654 | ||
|
|
1b18a1226d | ||
|
|
0b2372abab | ||
|
|
8aca1f9dd1 | ||
|
|
95ef04c1a3 | ||
|
|
4919e392a5 | ||
|
|
354d8da13f | ||
|
|
3df0c7c650 | ||
|
|
6a3dce523b | ||
|
|
9fe2918a54 | ||
|
|
92b30e1924 | ||
|
|
b63f2f78fc | ||
|
|
c971d239ff | ||
|
|
01d6e30e82 | ||
|
|
5fd78b6411 | ||
|
|
9ad5c85c2c | ||
|
|
279cd72f23 | ||
|
|
81e89d2dc4 | ||
|
|
c38b3d2a78 | ||
|
|
e8e6f972b4 | ||
|
|
d3155b82ea | ||
|
|
02018e10f3 |
@@ -61,6 +61,9 @@ PROXY_USE_IPV4=true
|
|||||||
# ⏱️ 请求超时配置
|
# ⏱️ 请求超时配置
|
||||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||||
|
|
||||||
|
# 🔧 请求体大小配置
|
||||||
|
REQUEST_MAX_SIZE_MB=60
|
||||||
|
|
||||||
# 📈 使用限制
|
# 📈 使用限制
|
||||||
DEFAULT_TOKEN_LIMIT=1000000
|
DEFAULT_TOKEN_LIMIT=1000000
|
||||||
|
|
||||||
@@ -75,6 +78,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
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
|||||||
|
|
||||||
# Logs directory
|
# Logs directory
|
||||||
logs/
|
logs/
|
||||||
|
logs1/
|
||||||
*.log
|
*.log
|
||||||
startup.log
|
startup.log
|
||||||
app.log
|
app.log
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,4 +1,17 @@
|
|||||||
# 🎯 前端构建阶段
|
# 🎯 后端依赖阶段 (与前端构建并行)
|
||||||
|
FROM node:18-alpine AS backend-deps
|
||||||
|
|
||||||
|
# 📁 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 📦 复制 package 文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --only=production
|
||||||
|
|
||||||
|
# 🎯 前端构建阶段 (与后端依赖并行)
|
||||||
FROM node:18-alpine AS frontend-builder
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
|
|||||||
# 📦 复制前端依赖文件
|
# 📦 复制前端依赖文件
|
||||||
COPY web/admin-spa/package*.json ./
|
COPY web/admin-spa/package*.json ./
|
||||||
|
|
||||||
# 🔽 安装前端依赖
|
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
|
|
||||||
# 📋 复制前端源代码
|
# 📋 复制前端源代码
|
||||||
COPY web/admin-spa/ ./
|
COPY web/admin-spa/ ./
|
||||||
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
|
|||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 📦 复制 package 文件
|
# 📦 复制 package 文件 (用于版本信息等)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 🔽 安装依赖 (生产环境)
|
# 📦 从后端依赖阶段复制 node_modules (已预装好)
|
||||||
RUN npm ci --only=production && \
|
COPY --from=backend-deps /app/node_modules ./node_modules
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# 📋 复制应用代码
|
# 📋 复制应用代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 📦 从构建阶段复制前端产物
|
# 📦 从前端构建阶段复制前端产物
|
||||||
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
||||||
|
|
||||||
# 🔧 复制并设置启动脚本权限
|
# 🔧 复制并设置启动脚本权限
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ 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'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 📬 用户消息队列配置
|
||||||
|
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 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秒足以覆盖请求发送
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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字符的加密密钥
|
||||||
|
|||||||
90
package-lock.json
generated
90
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",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7027,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",
|
||||||
@@ -7598,6 +7609,85 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier-plugin-tailwindcss": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "*",
|
||||||
|
"@prettier/plugin-hermes": "*",
|
||||||
|
"@prettier/plugin-oxc": "*",
|
||||||
|
"@prettier/plugin-pug": "*",
|
||||||
|
"@shopify/prettier-plugin-liquid": "*",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "*",
|
||||||
|
"@zackad/prettier-plugin-twig": "*",
|
||||||
|
"prettier": "^3.0",
|
||||||
|
"prettier-plugin-astro": "*",
|
||||||
|
"prettier-plugin-css-order": "*",
|
||||||
|
"prettier-plugin-jsdoc": "*",
|
||||||
|
"prettier-plugin-marko": "*",
|
||||||
|
"prettier-plugin-multiline-arrays": "*",
|
||||||
|
"prettier-plugin-organize-attributes": "*",
|
||||||
|
"prettier-plugin-organize-imports": "*",
|
||||||
|
"prettier-plugin-sort-imports": "*",
|
||||||
|
"prettier-plugin-svelte": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@prettier/plugin-hermes": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@prettier/plugin-oxc": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@prettier/plugin-pug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@shopify/prettier-plugin-liquid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@trivago/prettier-plugin-sort-imports": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@zackad/prettier-plugin-twig": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-astro": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-css-order": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-jsdoc": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-marko": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-multiline-arrays": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-organize-attributes": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-organize-imports": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-sort-imports": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier-plugin-svelte": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
6428
pnpm-lock.yaml
generated
Normal file
6428
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
scripts/test-official-models.js
Normal file
108
scripts/test-official-models.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 官方模型版本识别测试 - 最终版 v2
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
|
||||||
|
|
||||||
|
// 官方模型
|
||||||
|
const officialModels = [
|
||||||
|
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
|
||||||
|
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
|
||||||
|
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
|
||||||
|
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 非 Opus 模型
|
||||||
|
const nonOpusModels = [
|
||||||
|
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
|
||||||
|
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
|
||||||
|
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
|
||||||
|
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
|
||||||
|
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
|
||||||
|
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 其他格式测试
|
||||||
|
const otherFormats = [
|
||||||
|
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
|
||||||
|
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
|
||||||
|
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
|
||||||
|
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
|
||||||
|
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
|
||||||
|
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
|
||||||
|
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
|
||||||
|
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
|
||||||
|
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
|
||||||
|
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
|
||||||
|
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
|
||||||
|
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
|
||||||
|
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
|
||||||
|
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
|
||||||
|
{ name: 'opus', expected: false, desc: '仅 opus' },
|
||||||
|
{ name: null, expected: false, desc: 'null' },
|
||||||
|
{ name: '', expected: false, desc: '空字符串' }
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log('='.repeat(90))
|
||||||
|
console.log('官方模型版本识别测试 - 最终版 v2')
|
||||||
|
console.log('='.repeat(90))
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
let passed = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
// 测试官方 Opus 模型
|
||||||
|
console.log('📌 官方 Opus 模型:')
|
||||||
|
for (const m of officialModels) {
|
||||||
|
const result = isOpus45OrNewer(m.name)
|
||||||
|
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
|
||||||
|
if (result === m.expectPro) {
|
||||||
|
passed++
|
||||||
|
} else {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
|
||||||
|
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log('📌 非 Opus 模型 (不受此函数影响):')
|
||||||
|
for (const m of nonOpusModels) {
|
||||||
|
const result = isOpus45OrNewer(m.name)
|
||||||
|
console.log(
|
||||||
|
` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
|
||||||
|
)
|
||||||
|
if (result) {
|
||||||
|
failed++ // 非 Opus 模型不应返回 true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log('📌 其他格式测试:')
|
||||||
|
for (const m of otherFormats) {
|
||||||
|
const result = isOpus45OrNewer(m.name)
|
||||||
|
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
|
||||||
|
if (result === m.expected) {
|
||||||
|
passed++
|
||||||
|
} else {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
|
||||||
|
console.log(
|
||||||
|
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log('='.repeat(90))
|
||||||
|
console.log('测试结果:', passed, '通过,', failed, '失败')
|
||||||
|
console.log('='.repeat(90))
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('\n❌ 有测试失败,请检查函数逻辑')
|
||||||
|
process.exit(1)
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ 所有测试通过!函数可以安全使用')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
67
src/app.js
67
src/app.js
@@ -584,6 +584,20 @@ class Application {
|
|||||||
|
|
||||||
// 使用 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(
|
const cleaned = await redis.client.eval(
|
||||||
`
|
`
|
||||||
@@ -625,6 +639,41 @@ class Application {
|
|||||||
}, 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 +710,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,6 +728,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping cost rank service:', error)
|
logger.error('❌ Error stopping cost rank service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止账户定时测试调度器
|
||||||
|
try {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.stop()
|
||||||
|
logger.info('🧪 Account test scheduler service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping account test scheduler service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||||
try {
|
try {
|
||||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||||
|
|||||||
@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
|
|||||||
|
|
||||||
// 添加代理配置
|
// 添加代理配置
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -732,9 +731,8 @@ async function handleModels(req, res) {
|
|||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
}
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
const response = await axios(axiosConfig)
|
const response = await axios(axiosConfig)
|
||||||
models = (response.data.models || []).map((m) => ({
|
models = (response.data.models || []).map((m) => ({
|
||||||
@@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,104 @@ const logger = require('../utils/logger')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||||
const ClientValidator = require('../validators/clientValidator')
|
const ClientValidator = require('../validators/clientValidator')
|
||||||
|
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查排队是否过载,决定是否应该快速失败
|
||||||
|
* 详见 design.md Decision 7: 排队健康检查与快速失败
|
||||||
|
*
|
||||||
|
* @param {string} apiKeyId - API Key ID
|
||||||
|
* @param {number} timeoutMs - 排队超时时间(毫秒)
|
||||||
|
* @param {Object} queueConfig - 队列配置
|
||||||
|
* @param {number} maxQueueSize - 最大排队数
|
||||||
|
* @returns {Promise<Object>} { reject: boolean, reason?: string, estimatedWaitMs?: number, timeoutMs?: number }
|
||||||
|
*/
|
||||||
|
async function shouldRejectDueToOverload(apiKeyId, timeoutMs, queueConfig, maxQueueSize) {
|
||||||
|
try {
|
||||||
|
// 如果健康检查被禁用,直接返回不拒绝
|
||||||
|
if (!queueConfig.concurrentRequestQueueHealthCheckEnabled) {
|
||||||
|
return { reject: false, reason: 'health_check_disabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 先检查当前队列长度
|
||||||
|
const currentQueueCount = await redis.getConcurrencyQueueCount(apiKeyId).catch(() => 0)
|
||||||
|
|
||||||
|
// 队列为空,说明系统已恢复,跳过健康检查
|
||||||
|
if (currentQueueCount === 0) {
|
||||||
|
return { reject: false, reason: 'queue_empty', currentQueueCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 关键改进:只有当队列接近满载时才进行健康检查
|
||||||
|
// 队列长度 <= maxQueueSize * 0.5 时,认为系统有足够余量,跳过健康检查
|
||||||
|
// 这避免了在队列较短时过于保守地拒绝请求
|
||||||
|
// 使用 ceil 确保小队列(如 maxQueueSize=3)时阈值为 2,即队列 <=1 时跳过
|
||||||
|
const queueLoadThreshold = Math.ceil(maxQueueSize * 0.5)
|
||||||
|
if (currentQueueCount <= queueLoadThreshold) {
|
||||||
|
return {
|
||||||
|
reject: false,
|
||||||
|
reason: 'queue_not_loaded',
|
||||||
|
currentQueueCount,
|
||||||
|
queueLoadThreshold,
|
||||||
|
maxQueueSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该 API Key 的等待时间样本
|
||||||
|
const waitTimes = await redis.getQueueWaitTimes(apiKeyId)
|
||||||
|
const stats = calculateWaitTimeStats(waitTimes)
|
||||||
|
|
||||||
|
// 样本不足(< 10),跳过健康检查,避免冷启动误判
|
||||||
|
if (!stats || stats.sampleCount < 10) {
|
||||||
|
return { reject: false, reason: 'insufficient_samples', sampleCount: stats?.sampleCount || 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// P90 不可靠时也跳过(虽然 sampleCount >= 10 时 p90Unreliable 应该是 false)
|
||||||
|
if (stats.p90Unreliable) {
|
||||||
|
return { reject: false, reason: 'p90_unreliable', sampleCount: stats.sampleCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算健康阈值:P90 >= 超时时间 × 阈值 时拒绝
|
||||||
|
const threshold = queueConfig.concurrentRequestQueueHealthThreshold || 0.8
|
||||||
|
const maxAllowedP90 = timeoutMs * threshold
|
||||||
|
|
||||||
|
if (stats.p90 >= maxAllowedP90) {
|
||||||
|
return {
|
||||||
|
reject: true,
|
||||||
|
reason: 'queue_overloaded',
|
||||||
|
estimatedWaitMs: stats.p90,
|
||||||
|
timeoutMs,
|
||||||
|
threshold,
|
||||||
|
sampleCount: stats.sampleCount,
|
||||||
|
currentQueueCount,
|
||||||
|
maxQueueSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reject: false, p90: stats.p90, sampleCount: stats.sampleCount, currentQueueCount }
|
||||||
|
} catch (error) {
|
||||||
|
// 健康检查出错时不阻塞请求,记录警告并继续
|
||||||
|
logger.warn(`Health check failed for ${apiKeyId}:`, error.message)
|
||||||
|
return { reject: false, reason: 'health_check_error', error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队轮询配置常量(可通过配置文件覆盖)
|
||||||
|
// 性能权衡:初始间隔越短响应越快,但 Redis QPS 越高
|
||||||
|
// 当前配置:100 个等待者时约 250-300 QPS(指数退避后)
|
||||||
|
const QUEUE_POLLING_CONFIG = {
|
||||||
|
pollIntervalMs: 200, // 初始轮询间隔(毫秒)- 平衡响应速度和 Redis 压力
|
||||||
|
maxPollIntervalMs: 2000, // 最大轮询间隔(毫秒)- 长时间等待时降低 Redis 压力
|
||||||
|
backoffFactor: 1.5, // 指数退避系数
|
||||||
|
jitterRatio: 0.2, // 抖动比例(±20%)- 防止惊群效应
|
||||||
|
maxRedisFailCount: 5 // 连续 Redis 失败阈值(从 3 提高到 5,提高网络抖动容忍度)
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_CONCURRENCY_CONFIG = {
|
const FALLBACK_CONCURRENCY_CONFIG = {
|
||||||
leaseSeconds: 300,
|
leaseSeconds: 300,
|
||||||
@@ -126,9 +224,223 @@ function isTokenCountRequest(req) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待并发槽位(排队机制核心)
|
||||||
|
*
|
||||||
|
* 采用「先占后检查」模式避免竞态条件:
|
||||||
|
* - 每次轮询时尝试 incrConcurrency 占位
|
||||||
|
* - 如果超限则 decrConcurrency 释放并继续等待
|
||||||
|
* - 成功获取槽位后返回,调用方无需再次 incrConcurrency
|
||||||
|
*
|
||||||
|
* ⚠️ 重要清理责任说明:
|
||||||
|
* - 排队计数:此函数的 finally 块负责调用 decrConcurrencyQueue 清理
|
||||||
|
* - 并发槽位:当返回 acquired=true 时,槽位已被占用(通过 incrConcurrency)
|
||||||
|
* 调用方必须在请求结束时调用 decrConcurrency 释放槽位
|
||||||
|
* (已在 authenticateApiKey 的 finally 块中处理)
|
||||||
|
*
|
||||||
|
* @param {Object} req - Express 请求对象
|
||||||
|
* @param {Object} res - Express 响应对象
|
||||||
|
* @param {string} apiKeyId - API Key ID
|
||||||
|
* @param {Object} queueOptions - 配置参数
|
||||||
|
* @returns {Promise<Object>} { acquired: boolean, reason?: string, waitTimeMs: number }
|
||||||
|
*/
|
||||||
|
async function waitForConcurrencySlot(req, res, apiKeyId, queueOptions) {
|
||||||
|
const {
|
||||||
|
concurrencyLimit,
|
||||||
|
requestId,
|
||||||
|
leaseSeconds,
|
||||||
|
timeoutMs,
|
||||||
|
pollIntervalMs,
|
||||||
|
maxPollIntervalMs,
|
||||||
|
backoffFactor,
|
||||||
|
jitterRatio,
|
||||||
|
maxRedisFailCount: configMaxRedisFailCount
|
||||||
|
} = queueOptions
|
||||||
|
|
||||||
|
let clientDisconnected = false
|
||||||
|
// 追踪轮询过程中是否临时占用了槽位(用于异常时清理)
|
||||||
|
// 工作流程:
|
||||||
|
// 1. incrConcurrency 成功且 count <= limit 时,设置 internalSlotAcquired = true
|
||||||
|
// 2. 统计记录完成后,设置 internalSlotAcquired = false 并返回(所有权转移给调用方)
|
||||||
|
// 3. 如果在步骤 1-2 之间发生异常,finally 块会检测到 internalSlotAcquired = true 并释放槽位
|
||||||
|
let internalSlotAcquired = false
|
||||||
|
|
||||||
|
// 监听客户端断开事件
|
||||||
|
// ⚠️ 重要:必须监听 socket 的事件,而不是 req 的事件!
|
||||||
|
// 原因:对于 POST 请求,当 body-parser 读取完请求体后,req(IncomingMessage 可读流)
|
||||||
|
// 的 'close' 事件会立即触发,但这不代表客户端断开连接!客户端仍在等待响应。
|
||||||
|
// socket 的 'close' 事件才是真正的连接关闭信号。
|
||||||
|
const { socket } = req
|
||||||
|
const onSocketClose = () => {
|
||||||
|
clientDisconnected = true
|
||||||
|
logger.debug(
|
||||||
|
`🔌 [Queue] Socket closed during queue wait for API key ${apiKeyId}, requestId: ${requestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.once('close', onSocketClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 socket 是否在监听器注册前已被销毁(边界情况)
|
||||||
|
if (socket?.destroyed) {
|
||||||
|
clientDisconnected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
let pollInterval = pollIntervalMs
|
||||||
|
let redisFailCount = 0
|
||||||
|
// 优先使用配置中的值,否则使用默认值
|
||||||
|
const maxRedisFailCount = configMaxRedisFailCount || QUEUE_POLLING_CONFIG.maxRedisFailCount
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
// 检测客户端是否断开(双重检查:事件标记 + socket 状态)
|
||||||
|
// socket.destroyed 是同步检查,确保即使事件处理有延迟也能及时检测
|
||||||
|
if (clientDisconnected || socket?.destroyed) {
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(apiKeyId, 'cancelled')
|
||||||
|
.catch((e) => logger.warn('Failed to record cancelled stat:', e))
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
reason: 'client_disconnected',
|
||||||
|
waitTimeMs: Date.now() - startTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取槽位(先占后检查)
|
||||||
|
try {
|
||||||
|
const count = await redis.incrConcurrency(apiKeyId, requestId, leaseSeconds)
|
||||||
|
redisFailCount = 0 // 重置失败计数
|
||||||
|
|
||||||
|
if (count <= concurrencyLimit) {
|
||||||
|
// 成功获取槽位!
|
||||||
|
const waitTimeMs = Date.now() - startTime
|
||||||
|
|
||||||
|
// 槽位所有权转移说明:
|
||||||
|
// 1. 此时槽位已通过 incrConcurrency 获取
|
||||||
|
// 2. 先标记 internalSlotAcquired = true,确保异常时 finally 块能清理
|
||||||
|
// 3. 统计操作完成后,清除标记并返回,所有权转移给调用方
|
||||||
|
// 4. 调用方(authenticateApiKey)负责在请求结束时释放槽位
|
||||||
|
|
||||||
|
// 标记槽位已获取(用于异常时 finally 块清理)
|
||||||
|
internalSlotAcquired = true
|
||||||
|
|
||||||
|
// 记录统计(非阻塞,fire-and-forget 模式)
|
||||||
|
// ⚠️ 设计说明:
|
||||||
|
// - 故意不 await 这些 Promise,因为统计记录不应阻塞请求处理
|
||||||
|
// - 每个 Promise 都有独立的 .catch(),确保单个失败不影响其他
|
||||||
|
// - 外层 .catch() 是防御性措施,处理 Promise.all 本身的异常
|
||||||
|
// - 即使统计记录在函数返回后才完成/失败,也是安全的(仅日志记录)
|
||||||
|
// - 统计数据丢失可接受,不影响核心业务逻辑
|
||||||
|
Promise.all([
|
||||||
|
redis
|
||||||
|
.recordQueueWaitTime(apiKeyId, waitTimeMs)
|
||||||
|
.catch((e) => logger.warn('Failed to record queue wait time:', e)),
|
||||||
|
redis
|
||||||
|
.recordGlobalQueueWaitTime(waitTimeMs)
|
||||||
|
.catch((e) => logger.warn('Failed to record global wait time:', e)),
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(apiKeyId, 'success')
|
||||||
|
.catch((e) => logger.warn('Failed to increment success stats:', e))
|
||||||
|
]).catch((e) => logger.warn('Failed to record queue stats batch:', e))
|
||||||
|
|
||||||
|
// 成功返回前清除标记(所有权转移给调用方,由其负责释放)
|
||||||
|
internalSlotAcquired = false
|
||||||
|
return { acquired: true, waitTimeMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超限,释放槽位继续等待
|
||||||
|
try {
|
||||||
|
await redis.decrConcurrency(apiKeyId, requestId)
|
||||||
|
} catch (decrError) {
|
||||||
|
// 释放失败时记录警告但继续轮询
|
||||||
|
// 下次 incrConcurrency 会自然覆盖同一 requestId 的条目
|
||||||
|
logger.warn(
|
||||||
|
`Failed to release slot during polling for ${apiKeyId}, will retry:`,
|
||||||
|
decrError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (redisError) {
|
||||||
|
redisFailCount++
|
||||||
|
logger.error(
|
||||||
|
`Redis error in queue polling (${redisFailCount}/${maxRedisFailCount}):`,
|
||||||
|
redisError
|
||||||
|
)
|
||||||
|
|
||||||
|
if (redisFailCount >= maxRedisFailCount) {
|
||||||
|
// 连续 Redis 失败,放弃排队
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
reason: 'redis_error',
|
||||||
|
waitTimeMs: Date.now() - startTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避等待
|
||||||
|
await sleep(pollInterval)
|
||||||
|
|
||||||
|
// 计算下一次轮询间隔(指数退避 + 抖动)
|
||||||
|
// 1. 先应用指数退避
|
||||||
|
let nextInterval = pollInterval * backoffFactor
|
||||||
|
// 2. 添加抖动防止惊群效应(±jitterRatio 范围内的随机偏移)
|
||||||
|
// 抖动范围:[-jitterRatio, +jitterRatio],例如 jitterRatio=0.2 时为 ±20%
|
||||||
|
// 这是预期行为:负抖动可使间隔略微缩短,正抖动可使间隔略微延长
|
||||||
|
// 目的是分散多个等待者的轮询时间点,避免同时请求 Redis
|
||||||
|
const jitter = nextInterval * jitterRatio * (Math.random() * 2 - 1)
|
||||||
|
nextInterval = nextInterval + jitter
|
||||||
|
// 3. 确保在合理范围内:最小 1ms,最大 maxPollIntervalMs
|
||||||
|
// Math.max(1, ...) 保证即使负抖动也不会产生 ≤0 的间隔
|
||||||
|
pollInterval = Math.max(1, Math.min(nextInterval, maxPollIntervalMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(apiKeyId, 'timeout')
|
||||||
|
.catch((e) => logger.warn('Failed to record timeout stat:', e))
|
||||||
|
return { acquired: false, reason: 'timeout', waitTimeMs: Date.now() - startTime }
|
||||||
|
} finally {
|
||||||
|
// 确保清理:
|
||||||
|
// 1. 减少排队计数(排队计数在调用方已增加,这里负责减少)
|
||||||
|
try {
|
||||||
|
await redis.decrConcurrencyQueue(apiKeyId)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
// 清理失败记录错误(可能导致计数泄漏,但有 TTL 保护)
|
||||||
|
logger.error(
|
||||||
|
`Failed to decrement queue count in finally block for ${apiKeyId}:`,
|
||||||
|
cleanupError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果内部获取了槽位但未正常返回(异常路径),释放槽位
|
||||||
|
if (internalSlotAcquired) {
|
||||||
|
try {
|
||||||
|
await redis.decrConcurrency(apiKeyId, requestId)
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Released orphaned concurrency slot in finally block for ${apiKeyId}, requestId: ${requestId}`
|
||||||
|
)
|
||||||
|
} catch (slotCleanupError) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to release orphaned concurrency slot for ${apiKeyId}:`,
|
||||||
|
slotCleanupError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 socket 事件监听器
|
||||||
|
if (socket) {
|
||||||
|
socket.removeListener('close', onSocketClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔑 API Key验证中间件(优化版)
|
// 🔑 API Key验证中间件(优化版)
|
||||||
const authenticateApiKey = async (req, res, next) => {
|
const authenticateApiKey = async (req, res, next) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
let authErrored = false
|
||||||
|
let concurrencyCleanup = null
|
||||||
|
let hasConcurrencySlot = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
||||||
@@ -201,6 +513,53 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
|
||||||
|
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
|
||||||
|
if (!skipKeyRestrictions) {
|
||||||
|
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
|
||||||
|
const isClaudeMessagesEndpoint =
|
||||||
|
normalizedPath.includes('/v1/messages') &&
|
||||||
|
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
|
||||||
|
|
||||||
|
if (isClaudeMessagesEndpoint) {
|
||||||
|
try {
|
||||||
|
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
|
||||||
|
|
||||||
|
// API Key 级别的 Claude Code 限制
|
||||||
|
const keyClaudeCodeOnly =
|
||||||
|
validation.keyData.enableClientRestriction &&
|
||||||
|
Array.isArray(validation.keyData.allowedClients) &&
|
||||||
|
validation.keyData.allowedClients.length === 1 &&
|
||||||
|
validation.keyData.allowedClients.includes('claude_code')
|
||||||
|
|
||||||
|
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
|
||||||
|
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
|
||||||
|
const isClaudeCode = ClaudeCodeValidator.validate(req)
|
||||||
|
|
||||||
|
if (!isClaudeCode) {
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
logger.api(
|
||||||
|
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'client_validation_error',
|
||||||
|
message: 'This endpoint only accepts requests from Claude Code CLI'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error checking Claude Code restriction:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||||
@@ -216,29 +575,346 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
const requestId = uuidv4()
|
const requestId = uuidv4()
|
||||||
|
|
||||||
|
// ⚠️ 优化后的 Connection: close 设置策略
|
||||||
|
// 问题背景:HTTP Keep-Alive 使多个请求共用同一个 TCP 连接
|
||||||
|
// 当第一个请求正在处理,第二个请求进入排队时,它们共用同一个 socket
|
||||||
|
// 如果客户端超时关闭连接,两个请求都会受影响
|
||||||
|
// 优化方案:只有在请求实际进入排队时才设置 Connection: close
|
||||||
|
// 未排队的请求保持 Keep-Alive,避免不必要的 TCP 握手开销
|
||||||
|
// 详见 design.md Decision 2: Connection: close 设置时机
|
||||||
|
// 注意:Connection: close 将在下方代码实际进入排队时设置(第 637 行左右)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🔒 并发槽位状态管理说明
|
||||||
|
// ============================================================
|
||||||
|
// 此函数中有两个关键状态变量:
|
||||||
|
// - hasConcurrencySlot: 当前是否持有并发槽位
|
||||||
|
// - concurrencyCleanup: 错误时调用的清理函数
|
||||||
|
//
|
||||||
|
// 状态转换流程:
|
||||||
|
// 1. incrConcurrency 成功 → hasConcurrencySlot=true, 设置临时清理函数
|
||||||
|
// 2. 若超限 → 释放槽位,hasConcurrencySlot=false, concurrencyCleanup=null
|
||||||
|
// 3. 若排队成功 → hasConcurrencySlot=true, 升级为完整清理函数(含 interval 清理)
|
||||||
|
// 4. 请求结束(res.close/req.close)→ 调用 decrementConcurrency 释放
|
||||||
|
// 5. 认证错误 → finally 块调用 concurrencyCleanup 释放
|
||||||
|
//
|
||||||
|
// 为什么需要两种清理函数?
|
||||||
|
// - 临时清理:在排队/认证过程中出错时使用,只释放槽位
|
||||||
|
// - 完整清理:请求正常开始后使用,还需清理 leaseRenewInterval
|
||||||
|
// ============================================================
|
||||||
|
const setTemporaryConcurrencyCleanup = () => {
|
||||||
|
concurrencyCleanup = async () => {
|
||||||
|
if (!hasConcurrencySlot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
try {
|
||||||
|
await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to decrement concurrency after auth error for key ${validation.keyData.id}:`,
|
||||||
|
cleanupError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentConcurrency = await redis.incrConcurrency(
|
const currentConcurrency = await redis.incrConcurrency(
|
||||||
validation.keyData.id,
|
validation.keyData.id,
|
||||||
requestId,
|
requestId,
|
||||||
leaseSeconds
|
leaseSeconds
|
||||||
)
|
)
|
||||||
|
hasConcurrencySlot = true
|
||||||
|
setTemporaryConcurrencyCleanup()
|
||||||
logger.api(
|
logger.api(
|
||||||
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentConcurrency > concurrencyLimit) {
|
if (currentConcurrency > concurrencyLimit) {
|
||||||
// 如果超过限制,立即减少计数
|
// 1. 先释放刚占用的槽位
|
||||||
await redis.decrConcurrency(validation.keyData.id, requestId)
|
try {
|
||||||
logger.security(
|
await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
} catch (error) {
|
||||||
validation.keyData.name
|
logger.error(
|
||||||
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
|
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
concurrencyCleanup = null
|
||||||
|
|
||||||
|
// 2. 获取排队配置
|
||||||
|
const queueConfig = await claudeRelayConfigService.getConfig()
|
||||||
|
|
||||||
|
// 3. 排队功能未启用,直接返回 429(保持现有行为)
|
||||||
|
if (!queueConfig.concurrentRequestQueueEnabled) {
|
||||||
|
logger.security(
|
||||||
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
||||||
|
validation.keyData.name
|
||||||
|
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
|
||||||
|
)
|
||||||
|
// 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放)
|
||||||
|
res.set('Retry-After', '1')
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Concurrency limit exceeded',
|
||||||
|
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
|
||||||
|
currentConcurrency: currentConcurrency - 1,
|
||||||
|
concurrencyLimit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 计算最大排队数
|
||||||
|
const maxQueueSize = Math.max(
|
||||||
|
concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier,
|
||||||
|
queueConfig.concurrentRequestQueueMaxSize
|
||||||
)
|
)
|
||||||
return res.status(429).json({
|
|
||||||
error: 'Concurrency limit exceeded',
|
// 4.5 排队健康检查:过载时快速失败
|
||||||
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
|
// 详见 design.md Decision 7: 排队健康检查与快速失败
|
||||||
currentConcurrency: currentConcurrency - 1,
|
const overloadCheck = await shouldRejectDueToOverload(
|
||||||
concurrencyLimit
|
validation.keyData.id,
|
||||||
})
|
queueConfig.concurrentRequestQueueTimeoutMs,
|
||||||
|
queueConfig,
|
||||||
|
maxQueueSize
|
||||||
|
)
|
||||||
|
if (overloadCheck.reject) {
|
||||||
|
// 使用健康检查返回的当前排队数,避免重复调用 Redis
|
||||||
|
const currentQueueCount = overloadCheck.currentQueueCount || 0
|
||||||
|
logger.api(
|
||||||
|
`🚨 Queue overloaded for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`P90=${overloadCheck.estimatedWaitMs}ms, timeout=${overloadCheck.timeoutMs}ms, ` +
|
||||||
|
`threshold=${overloadCheck.threshold}, samples=${overloadCheck.sampleCount}, ` +
|
||||||
|
`concurrency=${concurrencyLimit}, queue=${currentQueueCount}/${maxQueueSize}`
|
||||||
|
)
|
||||||
|
// 记录被拒绝的过载统计
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(validation.keyData.id, 'rejected_overload')
|
||||||
|
.catch((e) => logger.warn('Failed to record rejected_overload stat:', e))
|
||||||
|
// 返回 429 + Retry-After,让客户端稍后重试
|
||||||
|
const retryAfterSeconds = 30
|
||||||
|
res.set('Retry-After', String(retryAfterSeconds))
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Queue overloaded',
|
||||||
|
message: `Queue is overloaded. Estimated wait time (${overloadCheck.estimatedWaitMs}ms) exceeds threshold. Limit: ${concurrencyLimit} concurrent requests, queue: ${currentQueueCount}/${maxQueueSize}. Please retry later.`,
|
||||||
|
currentConcurrency: concurrencyLimit,
|
||||||
|
concurrencyLimit,
|
||||||
|
queueCount: currentQueueCount,
|
||||||
|
maxQueueSize,
|
||||||
|
estimatedWaitMs: overloadCheck.estimatedWaitMs,
|
||||||
|
timeoutMs: overloadCheck.timeoutMs,
|
||||||
|
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
|
||||||
|
retryAfterSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 尝试进入排队(原子操作:先增加再检查,避免竞态条件)
|
||||||
|
let queueIncremented = false
|
||||||
|
try {
|
||||||
|
const newQueueCount = await redis.incrConcurrencyQueue(
|
||||||
|
validation.keyData.id,
|
||||||
|
queueConfig.concurrentRequestQueueTimeoutMs
|
||||||
|
)
|
||||||
|
queueIncremented = true
|
||||||
|
|
||||||
|
if (newQueueCount > maxQueueSize) {
|
||||||
|
// 超过最大排队数,立即释放并返回 429
|
||||||
|
await redis.decrConcurrencyQueue(validation.keyData.id)
|
||||||
|
queueIncremented = false
|
||||||
|
logger.api(
|
||||||
|
`🚦 Concurrency queue full for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`queue: ${newQueueCount - 1}, maxQueue: ${maxQueueSize}`
|
||||||
|
)
|
||||||
|
// 队列已满,建议客户端在排队超时时间后重试
|
||||||
|
const retryAfterSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
|
||||||
|
res.set('Retry-After', String(retryAfterSeconds))
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Concurrency queue full',
|
||||||
|
message: `Too many requests waiting in queue. Limit: ${concurrencyLimit} concurrent requests, queue: ${newQueueCount - 1}/${maxQueueSize}, timeout: ${retryAfterSeconds}s`,
|
||||||
|
currentConcurrency: concurrencyLimit,
|
||||||
|
concurrencyLimit,
|
||||||
|
queueCount: newQueueCount - 1,
|
||||||
|
maxQueueSize,
|
||||||
|
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
|
||||||
|
retryAfterSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 已成功进入排队,记录统计并开始等待槽位
|
||||||
|
logger.api(
|
||||||
|
`⏳ Request entering queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`queue position: ${newQueueCount}`
|
||||||
|
)
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(validation.keyData.id, 'entered')
|
||||||
|
.catch((e) => logger.warn('Failed to record entered stat:', e))
|
||||||
|
|
||||||
|
// ⚠️ 仅在请求实际进入排队时设置 Connection: close
|
||||||
|
// 详见 design.md Decision 2: Connection: close 设置时机
|
||||||
|
// 未排队的请求保持 Keep-Alive,避免不必要的 TCP 握手开销
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Connection', 'close')
|
||||||
|
logger.api(
|
||||||
|
`🔌 [Queue] Set Connection: close for queued request, key: ${validation.keyData.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 记录排队开始时的 socket 标识,用于排队完成后验证
|
||||||
|
// 问题背景:HTTP Keep-Alive 连接复用时,长时间排队可能导致 socket 被其他请求使用
|
||||||
|
// 验证方法:使用 UUID token + socket 对象引用双重验证
|
||||||
|
// 详见 design.md Decision 1: Socket 身份验证机制
|
||||||
|
req._crService = req._crService || {}
|
||||||
|
req._crService.queueToken = uuidv4()
|
||||||
|
req._crService.originalSocket = req.socket
|
||||||
|
req._crService.startTime = Date.now()
|
||||||
|
const savedToken = req._crService.queueToken
|
||||||
|
const savedSocket = req._crService.originalSocket
|
||||||
|
|
||||||
|
// ⚠️ 重要:在调用前将 queueIncremented 设为 false
|
||||||
|
// 因为 waitForConcurrencySlot 的 finally 块会负责清理排队计数
|
||||||
|
// 如果在调用后设置,当 waitForConcurrencySlot 抛出异常时
|
||||||
|
// 外层 catch 块会重复减少计数(finally 已经减过一次)
|
||||||
|
queueIncremented = false
|
||||||
|
|
||||||
|
const slot = await waitForConcurrencySlot(req, res, validation.keyData.id, {
|
||||||
|
concurrencyLimit,
|
||||||
|
requestId,
|
||||||
|
leaseSeconds,
|
||||||
|
timeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
|
||||||
|
pollIntervalMs: QUEUE_POLLING_CONFIG.pollIntervalMs,
|
||||||
|
maxPollIntervalMs: QUEUE_POLLING_CONFIG.maxPollIntervalMs,
|
||||||
|
backoffFactor: QUEUE_POLLING_CONFIG.backoffFactor,
|
||||||
|
jitterRatio: QUEUE_POLLING_CONFIG.jitterRatio,
|
||||||
|
maxRedisFailCount: queueConfig.concurrentRequestQueueMaxRedisFailCount
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 处理排队结果
|
||||||
|
if (!slot.acquired) {
|
||||||
|
if (slot.reason === 'client_disconnected') {
|
||||||
|
// 客户端已断开,不返回响应(连接已关闭)
|
||||||
|
logger.api(
|
||||||
|
`🔌 Client disconnected while queuing for key: ${validation.keyData.id} (${validation.keyData.name})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot.reason === 'redis_error') {
|
||||||
|
// Redis 连续失败,返回 503
|
||||||
|
logger.error(
|
||||||
|
`❌ Redis error during queue wait for key: ${validation.keyData.id} (${validation.keyData.name})`
|
||||||
|
)
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Service temporarily unavailable',
|
||||||
|
message: 'Failed to acquire concurrency slot due to internal error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 排队超时(使用 api 级别,与其他排队日志保持一致)
|
||||||
|
logger.api(
|
||||||
|
`⏰ Queue timeout for key: ${validation.keyData.id} (${validation.keyData.name}), waited: ${slot.waitTimeMs}ms`
|
||||||
|
)
|
||||||
|
// 已等待超时,建议客户端稍后重试
|
||||||
|
// ⚠️ Retry-After 策略优化:
|
||||||
|
// - 请求已经等了完整的 timeout 时间,说明系统负载较高
|
||||||
|
// - 过早重试(如固定 5 秒)会加剧拥塞,导致更多超时
|
||||||
|
// - 合理策略:使用 timeout 时间的一半作为重试间隔
|
||||||
|
// - 最小值 5 秒,最大值 30 秒,避免极端情况
|
||||||
|
const timeoutSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
|
||||||
|
const retryAfterSeconds = Math.max(5, Math.min(30, Math.ceil(timeoutSeconds / 2)))
|
||||||
|
res.set('Retry-After', String(retryAfterSeconds))
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Queue timeout',
|
||||||
|
message: `Request timed out waiting for concurrency slot. Limit: ${concurrencyLimit} concurrent requests, maxQueue: ${maxQueueSize}, Queue timeout: ${timeoutSeconds}s, waited: ${slot.waitTimeMs}ms`,
|
||||||
|
currentConcurrency: concurrencyLimit,
|
||||||
|
concurrencyLimit,
|
||||||
|
maxQueueSize,
|
||||||
|
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
|
||||||
|
waitTimeMs: slot.waitTimeMs,
|
||||||
|
retryAfterSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 排队成功,slot.acquired 表示已在 waitForConcurrencySlot 中获取到槽位
|
||||||
|
logger.api(
|
||||||
|
`✅ Queue wait completed for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`waited: ${slot.waitTimeMs}ms`
|
||||||
|
)
|
||||||
|
hasConcurrencySlot = true
|
||||||
|
setTemporaryConcurrencyCleanup()
|
||||||
|
|
||||||
|
// 9. ⚠️ 关键检查:排队等待结束后,验证客户端是否还在等待响应
|
||||||
|
// 长时间排队后,客户端可能在应用层已放弃(如 Claude Code 的超时机制),
|
||||||
|
// 但 TCP 连接仍然存活。此时继续处理请求是浪费资源。
|
||||||
|
// 注意:如果发送了心跳,headersSent 会是 true,但这是正常的
|
||||||
|
const postQueueSocket = req.socket
|
||||||
|
// 只检查连接是否真正断开(destroyed/writableEnded/socketDestroyed)
|
||||||
|
// headersSent 在心跳场景下是正常的,不应该作为放弃的依据
|
||||||
|
if (res.destroyed || res.writableEnded || postQueueSocket?.destroyed) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Client no longer waiting after queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`waited: ${slot.waitTimeMs}ms | destroyed: ${res.destroyed}, ` +
|
||||||
|
`writableEnded: ${res.writableEnded}, socketDestroyed: ${postQueueSocket?.destroyed}`
|
||||||
|
)
|
||||||
|
// 释放刚获取的槽位
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
await redis
|
||||||
|
.decrConcurrency(validation.keyData.id, requestId)
|
||||||
|
.catch((e) => logger.error('Failed to release slot after client abandoned:', e))
|
||||||
|
// 不返回响应(客户端已不在等待)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. ⚠️ 关键检查:验证 socket 身份是否改变
|
||||||
|
// HTTP Keep-Alive 连接复用可能导致排队期间 socket 被其他请求使用
|
||||||
|
// 验证方法:UUID token + socket 对象引用双重验证
|
||||||
|
// 详见 design.md Decision 1: Socket 身份验证机制
|
||||||
|
const queueData = req._crService
|
||||||
|
const socketIdentityChanged =
|
||||||
|
!queueData ||
|
||||||
|
queueData.queueToken !== savedToken ||
|
||||||
|
queueData.originalSocket !== savedSocket
|
||||||
|
|
||||||
|
if (socketIdentityChanged) {
|
||||||
|
logger.error(
|
||||||
|
`❌ [Queue] Socket identity changed during queue wait! ` +
|
||||||
|
`key: ${validation.keyData.id} (${validation.keyData.name}), ` +
|
||||||
|
`waited: ${slot.waitTimeMs}ms | ` +
|
||||||
|
`tokenMatch: ${queueData?.queueToken === savedToken}, ` +
|
||||||
|
`socketMatch: ${queueData?.originalSocket === savedSocket}`
|
||||||
|
)
|
||||||
|
// 释放刚获取的槽位
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
await redis
|
||||||
|
.decrConcurrency(validation.keyData.id, requestId)
|
||||||
|
.catch((e) => logger.error('Failed to release slot after socket identity change:', e))
|
||||||
|
// 记录 socket_changed 统计
|
||||||
|
redis
|
||||||
|
.incrConcurrencyQueueStats(validation.keyData.id, 'socket_changed')
|
||||||
|
.catch((e) => logger.warn('Failed to record socket_changed stat:', e))
|
||||||
|
// 不返回响应(socket 已被其他请求使用)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (queueError) {
|
||||||
|
// 异常时清理资源,防止泄漏
|
||||||
|
// 1. 清理排队计数(如果还没被 waitForConcurrencySlot 的 finally 清理)
|
||||||
|
if (queueIncremented) {
|
||||||
|
await redis
|
||||||
|
.decrConcurrencyQueue(validation.keyData.id)
|
||||||
|
.catch((e) => logger.error('Failed to cleanup queue count after error:', e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 防御性清理:如果 waitForConcurrencySlot 内部获取了槽位但在返回前异常
|
||||||
|
// 虽然这种情况极少发生(统计记录的异常会被内部捕获),但为了安全起见
|
||||||
|
// 尝试释放可能已获取的槽位。decrConcurrency 使用 ZREM,即使成员不存在也安全
|
||||||
|
if (hasConcurrencySlot) {
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
await redis
|
||||||
|
.decrConcurrency(validation.keyData.id, requestId)
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error('Failed to cleanup concurrency slot after queue error:', e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw queueError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renewIntervalMs =
|
const renewIntervalMs =
|
||||||
@@ -249,7 +925,38 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
let leaseRenewInterval = null
|
let leaseRenewInterval = null
|
||||||
|
|
||||||
if (renewIntervalMs > 0) {
|
if (renewIntervalMs > 0) {
|
||||||
|
// 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期
|
||||||
|
// 默认最大生存时间为 10 分钟,可通过环境变量配置
|
||||||
|
const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10
|
||||||
|
const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs)
|
||||||
|
let refreshCount = 0
|
||||||
|
|
||||||
leaseRenewInterval = setInterval(() => {
|
leaseRenewInterval = setInterval(() => {
|
||||||
|
refreshCount++
|
||||||
|
|
||||||
|
// 超过最大刷新次数,强制停止并清理
|
||||||
|
if (refreshCount > maxRefreshCount) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes`
|
||||||
|
)
|
||||||
|
// 清理定时器
|
||||||
|
if (leaseRenewInterval) {
|
||||||
|
clearInterval(leaseRenewInterval)
|
||||||
|
leaseRenewInterval = null
|
||||||
|
}
|
||||||
|
// 强制减少并发计数(如果还没减少)
|
||||||
|
if (!concurrencyDecremented) {
|
||||||
|
concurrencyDecremented = true
|
||||||
|
redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
redis
|
redis
|
||||||
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -268,6 +975,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
const decrementConcurrency = async () => {
|
const decrementConcurrency = async () => {
|
||||||
if (!concurrencyDecremented) {
|
if (!concurrencyDecremented) {
|
||||||
concurrencyDecremented = true
|
concurrencyDecremented = true
|
||||||
|
hasConcurrencySlot = false
|
||||||
if (leaseRenewInterval) {
|
if (leaseRenewInterval) {
|
||||||
clearInterval(leaseRenewInterval)
|
clearInterval(leaseRenewInterval)
|
||||||
leaseRenewInterval = null
|
leaseRenewInterval = null
|
||||||
@@ -282,6 +990,11 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑)
|
||||||
|
// 此时请求已通过认证,后续由 res.close/req.close 事件触发清理
|
||||||
|
if (hasConcurrencySlot) {
|
||||||
|
concurrencyCleanup = decrementConcurrency
|
||||||
|
}
|
||||||
|
|
||||||
// 监听最可靠的事件(避免重复监听)
|
// 监听最可靠的事件(避免重复监听)
|
||||||
// res.on('close') 是最可靠的,会在连接关闭时触发
|
// res.on('close') 是最可靠的,会在连接关闭时触发
|
||||||
@@ -607,6 +1320,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
|
|
||||||
return next()
|
return next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
authErrored = true
|
||||||
const authDuration = Date.now() - startTime
|
const authDuration = Date.now() - startTime
|
||||||
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
|
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -620,6 +1334,14 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
error: 'Authentication error',
|
error: 'Authentication error',
|
||||||
message: 'Internal server error during authentication'
|
message: 'Internal server error during authentication'
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
if (authErrored && typeof concurrencyCleanup === 'function') {
|
||||||
|
try {
|
||||||
|
await concurrencyCleanup()
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error('Failed to cleanup concurrency after auth error:', cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,7 +2020,8 @@ const globalRateLimit = async (req, res, next) =>
|
|||||||
|
|
||||||
// 📊 请求大小限制中间件
|
// 📊 请求大小限制中间件
|
||||||
const requestSizeLimit = (req, res, next) => {
|
const requestSizeLimit = (req, res, next) => {
|
||||||
const maxSize = 60 * 1024 * 1024 // 60MB
|
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
|
||||||
|
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
||||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||||
|
|
||||||
if (contentLength > maxSize) {
|
if (contentLength > maxSize) {
|
||||||
|
|||||||
1170
src/models/redis.js
1170
src/models/redis.js
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,17 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取所有被使用过的模型列表
|
||||||
|
router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const models = await redis.getAllUsedModels()
|
||||||
|
return res.json({ success: true, data: models })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get used models:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get used models', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取所有API Keys
|
// 获取所有API Keys
|
||||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
// 筛选参数
|
// 筛选参数
|
||||||
tag = '',
|
tag = '',
|
||||||
isActive = '',
|
isActive = '',
|
||||||
|
models = '', // 模型筛选(逗号分隔)
|
||||||
// 排序参数
|
// 排序参数
|
||||||
sortBy = 'createdAt',
|
sortBy = 'createdAt',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
@@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
timeRange = 'all'
|
timeRange = 'all'
|
||||||
} = req.query
|
} = req.query
|
||||||
|
|
||||||
|
// 解析模型筛选参数
|
||||||
|
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
|
||||||
|
|
||||||
// 验证分页参数
|
// 验证分页参数
|
||||||
const pageNum = Math.max(1, parseInt(page) || 1)
|
const pageNum = Math.max(1, parseInt(page) || 1)
|
||||||
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
|
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
|
||||||
@@ -217,7 +232,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
search,
|
search,
|
||||||
searchMode,
|
searchMode,
|
||||||
tag,
|
tag,
|
||||||
isActive
|
isActive,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
|
|
||||||
costSortStatus = {
|
costSortStatus = {
|
||||||
@@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
search,
|
search,
|
||||||
searchMode,
|
searchMode,
|
||||||
tag,
|
tag,
|
||||||
isActive
|
isActive,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
|
|
||||||
costSortStatus.isRealTimeCalculation = false
|
costSortStatus.isRealTimeCalculation = false
|
||||||
@@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
tag,
|
tag,
|
||||||
isActive,
|
isActive,
|
||||||
sortBy: validSortBy,
|
sortBy: validSortBy,
|
||||||
sortOrder: validSortOrder
|
sortOrder: validSortOrder,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
* 使用预计算索引进行费用排序的分页查询
|
* 使用预计算索引进行费用排序的分页查询
|
||||||
*/
|
*/
|
||||||
async function getApiKeysSortedByCostPrecomputed(options) {
|
async function getApiKeysSortedByCostPrecomputed(options) {
|
||||||
const { page, pageSize, sortOrder, costTimeRange, search, searchMode, tag, isActive } = options
|
const {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortOrder,
|
||||||
|
costTimeRange,
|
||||||
|
search,
|
||||||
|
searchMode,
|
||||||
|
tag,
|
||||||
|
isActive,
|
||||||
|
modelFilter = []
|
||||||
|
} = options
|
||||||
const costRankService = require('../../services/costRankService')
|
const costRankService = require('../../services/costRankService')
|
||||||
|
|
||||||
// 1. 获取排序后的全量 keyId 列表
|
// 1. 获取排序后的全量 keyId 列表
|
||||||
@@ -369,6 +397,15 @@ async function getApiKeysSortedByCostPrecomputed(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型筛选
|
||||||
|
if (modelFilter.length > 0) {
|
||||||
|
const keyIdsWithModels = await redis.getKeyIdsWithModels(
|
||||||
|
orderedKeys.map((k) => k.id),
|
||||||
|
modelFilter
|
||||||
|
)
|
||||||
|
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 收集所有可用标签
|
// 5. 收集所有可用标签
|
||||||
const allTags = new Set()
|
const allTags = new Set()
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) {
|
|||||||
* 使用实时计算进行 custom 时间范围的费用排序
|
* 使用实时计算进行 custom 时间范围的费用排序
|
||||||
*/
|
*/
|
||||||
async function getApiKeysSortedByCostCustom(options) {
|
async function getApiKeysSortedByCostCustom(options) {
|
||||||
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } =
|
const {
|
||||||
options
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortOrder,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search,
|
||||||
|
searchMode,
|
||||||
|
tag,
|
||||||
|
isActive,
|
||||||
|
modelFilter = []
|
||||||
|
} = options
|
||||||
const costRankService = require('../../services/costRankService')
|
const costRankService = require('../../services/costRankService')
|
||||||
|
|
||||||
// 1. 实时计算所有 Keys 的费用
|
// 1. 实时计算所有 Keys 的费用
|
||||||
@@ -427,9 +474,9 @@ async function getApiKeysSortedByCostCustom(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 转换为数组并排序
|
// 2. 转换为数组并排序
|
||||||
const sortedEntries = [...costs.entries()].sort((a, b) => {
|
const sortedEntries = [...costs.entries()].sort((a, b) =>
|
||||||
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
|
sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
|
||||||
})
|
)
|
||||||
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
|
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
|
||||||
|
|
||||||
// 3. 批量获取 API Key 基础数据
|
// 3. 批量获取 API Key 基础数据
|
||||||
@@ -465,6 +512,15 @@ async function getApiKeysSortedByCostCustom(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型筛选
|
||||||
|
if (modelFilter.length > 0) {
|
||||||
|
const keyIdsWithModels = await redis.getKeyIdsWithModels(
|
||||||
|
orderedKeys.map((k) => k.id),
|
||||||
|
modelFilter
|
||||||
|
)
|
||||||
|
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 收集所有可用标签
|
// 6. 收集所有可用标签
|
||||||
const allTags = new Set()
|
const allTags = new Set()
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@@ -863,6 +919,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,
|
||||||
@@ -872,7 +1008,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,12 +1030,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]
|
||||||
@@ -919,8 +1060,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,52 +1118,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)
|
|
||||||
if (apiKey && apiKey.rateLimitWindow > 0) {
|
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
|
||||||
|
|
||||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
|
||||||
|
|
||||||
// 获取窗口开始时间和计算剩余时间
|
|
||||||
const windowStart = await client.get(windowStartKey)
|
|
||||||
if (windowStart) {
|
|
||||||
const now = Date.now()
|
|
||||||
windowStartTime = parseInt(windowStart)
|
|
||||||
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
|
||||||
windowEndTime = windowStartTime + windowDuration
|
|
||||||
|
|
||||||
// 如果窗口还有效
|
|
||||||
if (now < windowEndTime) {
|
|
||||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
|
||||||
} else {
|
|
||||||
// 窗口已过期
|
|
||||||
windowRemainingSeconds = 0
|
|
||||||
currentWindowCost = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -255,6 +256,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 普通OAuth的Cookie自动授权
|
||||||
|
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionKey, proxy } = req.body
|
||||||
|
|
||||||
|
// 验证sessionKey参数
|
||||||
|
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionKey不能为空',
|
||||||
|
message: '请提供有效的sessionKey值'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSessionKey = sessionKey.trim()
|
||||||
|
|
||||||
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
|
hasProxy: !!proxy
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行Cookie自动授权流程
|
||||||
|
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
|
||||||
|
|
||||||
|
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: result.claudeAiOauth,
|
||||||
|
organizationUuid: result.organizationUuid,
|
||||||
|
capabilities: result.capabilities
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Cookie-based OAuth authorization failed:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cookie授权失败',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup Token的Cookie自动授权
|
||||||
|
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionKey, proxy } = req.body
|
||||||
|
|
||||||
|
// 验证sessionKey参数
|
||||||
|
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionKey不能为空',
|
||||||
|
message: '请提供有效的sessionKey值'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSessionKey = sessionKey.trim()
|
||||||
|
|
||||||
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
|
hasProxy: !!proxy
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行Cookie自动授权流程(Setup Token模式)
|
||||||
|
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
|
||||||
|
|
||||||
|
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: result.claudeAiOauth,
|
||||||
|
organizationUuid: result.organizationUuid,
|
||||||
|
capabilities: result.capabilities
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Cookie-based Setup Token authorization failed:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cookie授权失败',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取所有Claude账户
|
// 获取所有Claude账户
|
||||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -481,7 +584,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
useUnifiedClientId,
|
useUnifiedClientId,
|
||||||
unifiedClientId,
|
unifiedClientId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
extInfo
|
extInfo,
|
||||||
|
maxConcurrency
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -526,7 +630,8 @@ 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=强制启用
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
@@ -801,4 +906,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
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
groupId,
|
groupId,
|
||||||
dailyQuota,
|
dailyQuota,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
maxConcurrentTasks
|
maxConcurrentTasks,
|
||||||
|
disableAutoProtection
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name || !apiUrl || !apiKey) {
|
if (!name || !apiUrl || !apiKey) {
|
||||||
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验上游错误自动防护开关
|
||||||
|
const normalizedDisableAutoProtection =
|
||||||
|
disableAutoProtection === true || disableAutoProtection === 'true'
|
||||||
|
|
||||||
// 验证accountType的有效性
|
// 验证accountType的有效性
|
||||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
return res
|
return res
|
||||||
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
maxConcurrentTasks:
|
maxConcurrentTasks:
|
||||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
? Number(maxConcurrentTasks)
|
? Number(maxConcurrentTasks)
|
||||||
: 0
|
: 0,
|
||||||
|
disableAutoProtection: normalizedDisableAutoProtection
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
|
|||||||
return res.status(404).json({ error: 'Account not found' })
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化上游错误自动防护开关
|
||||||
|
if (mappedUpdates.disableAutoProtection !== undefined) {
|
||||||
|
mappedUpdates.disableAutoProtection =
|
||||||
|
mappedUpdates.disableAutoProtection === true ||
|
||||||
|
mappedUpdates.disableAutoProtection === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组的变更
|
// 处理分组的变更
|
||||||
if (mappedUpdates.accountType !== undefined) {
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
// 如果之前是分组类型,需要从所有分组中移除
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
|||||||
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
|
||||||
313
src/routes/admin/concurrency.js
Normal file
313
src/routes/admin/concurrency.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* 并发管理 API 路由
|
||||||
|
* 提供并发状态查看和手动清理功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency
|
||||||
|
* 获取所有并发状态
|
||||||
|
*/
|
||||||
|
router.get('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = await redis.getAllConcurrencyStatus()
|
||||||
|
|
||||||
|
// 为每个 API Key 获取排队计数
|
||||||
|
const statusWithQueue = await Promise.all(
|
||||||
|
status.map(async (s) => {
|
||||||
|
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
queueCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算汇总统计
|
||||||
|
const summary = {
|
||||||
|
totalKeys: statusWithQueue.length,
|
||||||
|
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
|
||||||
|
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
|
||||||
|
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
summary,
|
||||||
|
concurrencyStatus: statusWithQueue
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get concurrency status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get concurrency status',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency-queue/stats
|
||||||
|
* 获取排队统计信息
|
||||||
|
*/
|
||||||
|
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 获取所有有统计数据的 API Key
|
||||||
|
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
|
||||||
|
const queueKeys = await redis.scanConcurrencyQueueKeys()
|
||||||
|
|
||||||
|
// 合并所有相关的 API Key
|
||||||
|
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
|
||||||
|
|
||||||
|
// 获取各 API Key 的详细统计
|
||||||
|
const perKeyStats = await Promise.all(
|
||||||
|
allApiKeyIds.map(async (apiKeyId) => {
|
||||||
|
const [queueCount, stats, waitTimes] = await Promise.all([
|
||||||
|
redis.getConcurrencyQueueCount(apiKeyId),
|
||||||
|
redis.getConcurrencyQueueStats(apiKeyId),
|
||||||
|
redis.getQueueWaitTimes(apiKeyId)
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
currentQueueCount: queueCount,
|
||||||
|
stats,
|
||||||
|
waitTimeStats: calculateWaitTimeStats(waitTimes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取全局等待时间统计
|
||||||
|
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
|
||||||
|
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
|
||||||
|
|
||||||
|
// 计算全局汇总
|
||||||
|
const globalStats = {
|
||||||
|
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
|
||||||
|
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
|
||||||
|
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
|
||||||
|
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
|
||||||
|
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
|
||||||
|
totalRejectedOverload: perKeyStats.reduce(
|
||||||
|
(sum, s) => sum + (s.stats.rejected_overload || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
|
||||||
|
// 队列资源利用率指标
|
||||||
|
peakQueueSize:
|
||||||
|
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
|
||||||
|
avgQueueSize:
|
||||||
|
perKeyStats.length > 0
|
||||||
|
? Math.round(
|
||||||
|
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算成功率
|
||||||
|
if (globalStats.totalEntered > 0) {
|
||||||
|
globalStats.successRate = Math.round(
|
||||||
|
(globalStats.totalSuccess / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
globalStats.timeoutRate = Math.round(
|
||||||
|
(globalStats.totalTimeout / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
globalStats.cancelledRate = Math.round(
|
||||||
|
(globalStats.totalCancelled / globalStats.totalEntered) * 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从全局等待时间统计中提取关键指标
|
||||||
|
if (globalWaitTimeStats) {
|
||||||
|
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
|
||||||
|
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
|
||||||
|
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
|
||||||
|
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
|
||||||
|
// 多实例采样策略标记(详见 design.md Decision 9)
|
||||||
|
// 全局 P90 仅用于可视化和监控,不用于系统决策
|
||||||
|
// 健康检查使用 API Key 级别的 P90(每 Key 独立采样)
|
||||||
|
globalWaitTimeStats.globalP90ForVisualizationOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
globalStats,
|
||||||
|
globalWaitTimeStats,
|
||||||
|
perKeyStats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get queue stats:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get queue stats',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency-queue/:apiKeyId
|
||||||
|
* 清理特定 API Key 的排队计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
await redis.clearConcurrencyQueue(apiKeyId)
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully cleared queue for API key ${apiKeyId}`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear queue',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency-queue
|
||||||
|
* 清理所有排队计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cleared = await redis.clearAllConcurrencyQueues()
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully cleared all queues',
|
||||||
|
cleared
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to clear all queues:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear all queues',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency/:apiKeyId
|
||||||
|
* 获取特定 API Key 的并发状态详情
|
||||||
|
*/
|
||||||
|
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
const status = await redis.getConcurrencyStatus(apiKeyId)
|
||||||
|
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
concurrencyStatus: {
|
||||||
|
...status,
|
||||||
|
queueCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get concurrency status',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency/:apiKeyId
|
||||||
|
* 强制清理特定 API Key 的并发计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully cleared concurrency for API key ${apiKeyId}`,
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency
|
||||||
|
* 强制清理所有并发计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await redis.forceClearAllConcurrency()
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully cleared all concurrency',
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to clear all concurrency:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear all concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/concurrency/cleanup
|
||||||
|
* 清理过期的并发条目(不影响活跃请求)
|
||||||
|
*/
|
||||||
|
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.body
|
||||||
|
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||||
|
|
||||||
|
logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: apiKeyId
|
||||||
|
? `Successfully cleaned up expired concurrency for API key ${apiKeyId}`
|
||||||
|
: 'Successfully cleaned up all expired concurrency',
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to cleanup expired concurrency:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to cleanup expired concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -22,6 +22,8 @@ const droidAccountsRoutes = require('./droidAccounts')
|
|||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
|
const concurrencyRoutes = require('./concurrency')
|
||||||
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,6 +37,8 @@ router.use('/', droidAccountsRoutes)
|
|||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
|
router.use('/', concurrencyRoutes)
|
||||||
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
const claudeAccountService = require('../../services/claudeAccountService')
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
const geminiAccountService = require('../../services/geminiAccountService')
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
|
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
@@ -14,6 +16,65 @@ const pricingService = require('../../services/pricingService')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
const accountTypeNames = {
|
||||||
|
claude: 'Claude官方',
|
||||||
|
'claude-console': 'Claude Console',
|
||||||
|
ccr: 'Claude Console Relay',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
'openai-responses': 'OpenAI Responses',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
'gemini-api': 'Gemini API',
|
||||||
|
droid: 'Droid',
|
||||||
|
unknown: '未知渠道'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAccountByPlatform = async (accountId, platform) => {
|
||||||
|
const serviceMap = {
|
||||||
|
claude: claudeAccountService,
|
||||||
|
'claude-console': claudeConsoleAccountService,
|
||||||
|
gemini: geminiAccountService,
|
||||||
|
'gemini-api': geminiApiAccountService,
|
||||||
|
openai: openaiAccountService,
|
||||||
|
'openai-responses': openaiResponsesAccountService,
|
||||||
|
droid: droidAccountService,
|
||||||
|
ccr: ccrAccountService
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform && serviceMap[platform]) {
|
||||||
|
try {
|
||||||
|
const account = await serviceMap[platform].getAccount(accountId)
|
||||||
|
if (account) {
|
||||||
|
return { ...account, platform }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [platformName, service] of Object.entries(serviceMap)) {
|
||||||
|
try {
|
||||||
|
const account = await service.getAccount(accountId)
|
||||||
|
if (account) {
|
||||||
|
return { ...account, platform: platformName }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApiKeyName = async (keyId) => {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
return keyData?.name || keyData?.label || keyId
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
|
||||||
|
return keyId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 账户使用统计
|
// 📊 账户使用统计
|
||||||
|
|
||||||
// 获取所有账户的使用统计
|
// 获取所有账户的使用统计
|
||||||
@@ -148,7 +209,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
accountData = await geminiAccountService.getAccount(accountId)
|
accountData = await geminiAccountService.getAccount(accountId)
|
||||||
break
|
break
|
||||||
case 'gemini-api': {
|
case 'gemini-api': {
|
||||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
|
||||||
accountData = await geminiApiAccountService.getAccount(accountId)
|
accountData = await geminiApiAccountService.getAccount(accountId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -369,7 +429,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
logger.info(` endDate (raw): ${endDate}`)
|
logger.info(` endDate (raw): ${endDate}`)
|
||||||
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
|
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
|
||||||
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
|
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
|
||||||
logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`)
|
logger.info(
|
||||||
|
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// 默认最近24小时
|
// 默认最近24小时
|
||||||
endTime = new Date()
|
endTime = new Date()
|
||||||
@@ -890,7 +952,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
]
|
]
|
||||||
} else if (group === 'gemini') {
|
} else if (group === 'gemini') {
|
||||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
|
||||||
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
|
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
|
||||||
geminiAccountService.getAllAccounts(),
|
geminiAccountService.getAllAccounts(),
|
||||||
geminiApiAccountService.getAllAccounts(true)
|
geminiApiAccountService.getAllAccounts(true)
|
||||||
@@ -1818,4 +1879,628 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取 API Key 的请求记录时间线
|
||||||
|
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { keyId } = req.params
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 50,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
model,
|
||||||
|
accountId,
|
||||||
|
sortOrder = 'desc'
|
||||||
|
} = req.query
|
||||||
|
|
||||||
|
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
|
||||||
|
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
|
||||||
|
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
|
const startTime = startDate ? new Date(startDate) : null
|
||||||
|
const endTime = endDate ? new Date(endDate) : null
|
||||||
|
|
||||||
|
if (
|
||||||
|
(startDate && Number.isNaN(startTime?.getTime())) ||
|
||||||
|
(endDate && Number.isNaN(endTime?.getTime()))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid date range' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime && endTime && startTime > endTime) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'Start date must be before or equal to end date' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyInfo = await redis.getApiKey(keyId)
|
||||||
|
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'API key not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRecords = await redis.getUsageRecords(keyId, 5000)
|
||||||
|
|
||||||
|
const accountServices = [
|
||||||
|
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
|
||||||
|
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
|
||||||
|
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
|
||||||
|
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
|
||||||
|
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
|
||||||
|
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
|
||||||
|
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
|
||||||
|
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
|
||||||
|
]
|
||||||
|
|
||||||
|
const accountCache = new Map()
|
||||||
|
const resolveAccountInfo = async (id, type) => {
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${type || 'any'}:${id}`
|
||||||
|
if (accountCache.has(cacheKey)) {
|
||||||
|
return accountCache.get(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
let servicesToTry = type
|
||||||
|
? accountServices.filter((svc) => svc.type === type)
|
||||||
|
: accountServices
|
||||||
|
|
||||||
|
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
|
||||||
|
if (!servicesToTry.length) {
|
||||||
|
servicesToTry = accountServices
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const service of servicesToTry) {
|
||||||
|
try {
|
||||||
|
const account = await service.getter(id)
|
||||||
|
if (account) {
|
||||||
|
const info = {
|
||||||
|
id,
|
||||||
|
name: account.name || account.email || id,
|
||||||
|
type: service.type,
|
||||||
|
status: account.status || account.isActive
|
||||||
|
}
|
||||||
|
accountCache.set(cacheKey, info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountCache.set(cacheKey, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUsageObject = (record) => ({
|
||||||
|
input_tokens: record.inputTokens || 0,
|
||||||
|
output_tokens: record.outputTokens || 0,
|
||||||
|
cache_creation_input_tokens: record.cacheCreateTokens || 0,
|
||||||
|
cache_read_input_tokens: record.cacheReadTokens || 0,
|
||||||
|
cache_creation: record.cacheCreation || record.cache_creation || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const withinRange = (record) => {
|
||||||
|
if (!record.timestamp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ts = new Date(record.timestamp)
|
||||||
|
if (Number.isNaN(ts.getTime())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (startTime && ts < startTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (endTime && ts > endTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRecords = rawRecords.filter((record) => {
|
||||||
|
if (!withinRange(record)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (model && record.model !== model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (accountId && record.accountId !== accountId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
filteredRecords.sort((a, b) => {
|
||||||
|
const aTime = new Date(a.timestamp).getTime()
|
||||||
|
const bTime = new Date(b.timestamp).getTime()
|
||||||
|
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalRequests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSet = new Set()
|
||||||
|
const accountOptionMap = new Map()
|
||||||
|
let earliestTimestamp = null
|
||||||
|
let latestTimestamp = null
|
||||||
|
|
||||||
|
for (const record of filteredRecords) {
|
||||||
|
const usage = toUsageObject(record)
|
||||||
|
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||||
|
const computedCost =
|
||||||
|
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||||
|
const totalTokens =
|
||||||
|
record.totalTokens ||
|
||||||
|
usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
|
||||||
|
summary.totalRequests += 1
|
||||||
|
summary.inputTokens += usage.input_tokens
|
||||||
|
summary.outputTokens += usage.output_tokens
|
||||||
|
summary.cacheCreateTokens += usage.cache_creation_input_tokens
|
||||||
|
summary.cacheReadTokens += usage.cache_read_input_tokens
|
||||||
|
summary.totalTokens += totalTokens
|
||||||
|
summary.totalCost += computedCost
|
||||||
|
|
||||||
|
if (record.model) {
|
||||||
|
modelSet.add(record.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.accountId) {
|
||||||
|
const normalizedType = record.accountType || 'unknown'
|
||||||
|
if (!accountOptionMap.has(record.accountId)) {
|
||||||
|
accountOptionMap.set(record.accountId, {
|
||||||
|
id: record.accountId,
|
||||||
|
accountTypes: new Set([normalizedType])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.timestamp) {
|
||||||
|
const ts = new Date(record.timestamp)
|
||||||
|
if (!Number.isNaN(ts.getTime())) {
|
||||||
|
if (!earliestTimestamp || ts < earliestTimestamp) {
|
||||||
|
earliestTimestamp = ts
|
||||||
|
}
|
||||||
|
if (!latestTimestamp || ts > latestTimestamp) {
|
||||||
|
latestTimestamp = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRecords = filteredRecords.length
|
||||||
|
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
|
||||||
|
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
|
||||||
|
const startIndex = (safePage - 1) * pageSizeNumber
|
||||||
|
const pageRecords =
|
||||||
|
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
|
||||||
|
|
||||||
|
const enrichedRecords = []
|
||||||
|
for (const record of pageRecords) {
|
||||||
|
const usage = toUsageObject(record)
|
||||||
|
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||||
|
const computedCost =
|
||||||
|
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||||
|
const totalTokens =
|
||||||
|
record.totalTokens ||
|
||||||
|
usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
|
||||||
|
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
|
||||||
|
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
|
||||||
|
|
||||||
|
enrichedRecords.push({
|
||||||
|
timestamp: record.timestamp,
|
||||||
|
model: record.model || 'unknown',
|
||||||
|
accountId: record.accountId || null,
|
||||||
|
accountName: accountInfo?.name || null,
|
||||||
|
accountStatus: accountInfo?.status ?? null,
|
||||||
|
accountType: resolvedAccountType,
|
||||||
|
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
|
||||||
|
inputTokens: usage.input_tokens,
|
||||||
|
outputTokens: usage.output_tokens,
|
||||||
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
|
ephemeral5mTokens: record.ephemeral5mTokens || 0,
|
||||||
|
ephemeral1hTokens: record.ephemeral1hTokens || 0,
|
||||||
|
totalTokens,
|
||||||
|
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
|
||||||
|
cost: Number(computedCost.toFixed(6)),
|
||||||
|
costFormatted:
|
||||||
|
record.costFormatted ||
|
||||||
|
costData?.formatted?.total ||
|
||||||
|
CostCalculator.formatCost(computedCost),
|
||||||
|
costBreakdown: record.costBreakdown || {
|
||||||
|
input: costData?.costs?.input || 0,
|
||||||
|
output: costData?.costs?.output || 0,
|
||||||
|
cacheCreate: costData?.costs?.cacheWrite || 0,
|
||||||
|
cacheRead: costData?.costs?.cacheRead || 0,
|
||||||
|
total: costData?.costs?.total || computedCost
|
||||||
|
},
|
||||||
|
responseTime: record.responseTime || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountOptions = []
|
||||||
|
for (const option of accountOptionMap.values()) {
|
||||||
|
const types = Array.from(option.accountTypes || [])
|
||||||
|
|
||||||
|
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
|
||||||
|
let resolvedInfo = null
|
||||||
|
for (const type of types) {
|
||||||
|
resolvedInfo = await resolveAccountInfo(option.id, type)
|
||||||
|
if (resolvedInfo && resolvedInfo.name) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!resolvedInfo) {
|
||||||
|
resolvedInfo = await resolveAccountInfo(option.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
|
||||||
|
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
|
||||||
|
|
||||||
|
if (!resolvedInfo) {
|
||||||
|
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountOptions.push({
|
||||||
|
id: option.id,
|
||||||
|
name: resolvedInfo?.name || option.id,
|
||||||
|
accountType: chosenType,
|
||||||
|
accountTypeName: chosenTypeName,
|
||||||
|
rawTypes: types
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
records: enrichedRecords,
|
||||||
|
pagination: {
|
||||||
|
currentPage: safePage,
|
||||||
|
pageSize: pageSizeNumber,
|
||||||
|
totalRecords,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: totalPages > 0 && safePage < totalPages,
|
||||||
|
hasPreviousPage: totalPages > 0 && safePage > 1
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
startDate: startTime ? startTime.toISOString() : null,
|
||||||
|
endDate: endTime ? endTime.toISOString() : null,
|
||||||
|
model: model || null,
|
||||||
|
accountId: accountId || null,
|
||||||
|
sortOrder: normalizedSortOrder
|
||||||
|
},
|
||||||
|
apiKeyInfo: {
|
||||||
|
id: keyId,
|
||||||
|
name: apiKeyInfo.name || apiKeyInfo.label || keyId
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
...summary,
|
||||||
|
totalCost: Number(summary.totalCost.toFixed(6)),
|
||||||
|
avgCost:
|
||||||
|
summary.totalRequests > 0
|
||||||
|
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
|
||||||
|
: 0
|
||||||
|
},
|
||||||
|
availableFilters: {
|
||||||
|
models: Array.from(modelSet),
|
||||||
|
accounts: accountOptions,
|
||||||
|
dateRange: {
|
||||||
|
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
|
||||||
|
latest: latestTimestamp ? latestTimestamp.toISOString() : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get API key usage records:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get API key usage records', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取账户的请求记录时间线
|
||||||
|
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 50,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
model,
|
||||||
|
apiKeyId,
|
||||||
|
sortOrder = 'desc'
|
||||||
|
} = req.query
|
||||||
|
|
||||||
|
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
|
||||||
|
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
|
||||||
|
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
|
const startTime = startDate ? new Date(startDate) : null
|
||||||
|
const endTime = endDate ? new Date(endDate) : null
|
||||||
|
|
||||||
|
if (
|
||||||
|
(startDate && Number.isNaN(startTime?.getTime())) ||
|
||||||
|
(endDate && Number.isNaN(endTime?.getTime()))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid date range' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime && endTime && startTime > endTime) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'Start date must be before or equal to end date' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountInfo = await resolveAccountByPlatform(accountId, platform)
|
||||||
|
if (!accountInfo) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApiKeys = await apiKeyService.getAllApiKeys(true)
|
||||||
|
const apiKeyNameCache = new Map(
|
||||||
|
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
|
||||||
|
if (apiKeyId && keysToUse.length === 0) {
|
||||||
|
keysToUse = [{ id: apiKeyId }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUsageObject = (record) => ({
|
||||||
|
input_tokens: record.inputTokens || 0,
|
||||||
|
output_tokens: record.outputTokens || 0,
|
||||||
|
cache_creation_input_tokens: record.cacheCreateTokens || 0,
|
||||||
|
cache_read_input_tokens: record.cacheReadTokens || 0,
|
||||||
|
cache_creation: record.cacheCreation || record.cache_creation || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const withinRange = (record) => {
|
||||||
|
if (!record.timestamp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ts = new Date(record.timestamp)
|
||||||
|
if (Number.isNaN(ts.getTime())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (startTime && ts < startTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (endTime && ts > endTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRecords = []
|
||||||
|
const modelSet = new Set()
|
||||||
|
const apiKeyOptionMap = new Map()
|
||||||
|
let earliestTimestamp = null
|
||||||
|
let latestTimestamp = null
|
||||||
|
|
||||||
|
const batchSize = 10
|
||||||
|
for (let i = 0; i < keysToUse.length; i += batchSize) {
|
||||||
|
const batch = keysToUse.slice(i, i + batchSize)
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(async (key) => {
|
||||||
|
try {
|
||||||
|
const records = await redis.getUsageRecords(key.id, 5000)
|
||||||
|
return { keyId: key.id, records: records || [] }
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
|
||||||
|
return { keyId: key.id, records: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { keyId, records } of batchResults) {
|
||||||
|
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.accountId !== accountId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!withinRange(record)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (model && record.model !== model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountType = record.accountType || accountInfo.platform || 'unknown'
|
||||||
|
const normalizedModel = record.model || 'unknown'
|
||||||
|
|
||||||
|
modelSet.add(normalizedModel)
|
||||||
|
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
|
||||||
|
|
||||||
|
if (record.timestamp) {
|
||||||
|
const ts = new Date(record.timestamp)
|
||||||
|
if (!Number.isNaN(ts.getTime())) {
|
||||||
|
if (!earliestTimestamp || ts < earliestTimestamp) {
|
||||||
|
earliestTimestamp = ts
|
||||||
|
}
|
||||||
|
if (!latestTimestamp || ts > latestTimestamp) {
|
||||||
|
latestTimestamp = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredRecords.push({
|
||||||
|
...record,
|
||||||
|
model: normalizedModel,
|
||||||
|
accountType,
|
||||||
|
apiKeyId: keyId,
|
||||||
|
apiKeyName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredRecords.sort((a, b) => {
|
||||||
|
const aTime = new Date(a.timestamp).getTime()
|
||||||
|
const bTime = new Date(b.timestamp).getTime()
|
||||||
|
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalRequests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of filteredRecords) {
|
||||||
|
const usage = toUsageObject(record)
|
||||||
|
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||||
|
const computedCost =
|
||||||
|
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||||
|
const totalTokens =
|
||||||
|
record.totalTokens ||
|
||||||
|
usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
|
||||||
|
summary.totalRequests += 1
|
||||||
|
summary.inputTokens += usage.input_tokens
|
||||||
|
summary.outputTokens += usage.output_tokens
|
||||||
|
summary.cacheCreateTokens += usage.cache_creation_input_tokens
|
||||||
|
summary.cacheReadTokens += usage.cache_read_input_tokens
|
||||||
|
summary.totalTokens += totalTokens
|
||||||
|
summary.totalCost += computedCost
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRecords = filteredRecords.length
|
||||||
|
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
|
||||||
|
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
|
||||||
|
const startIndex = (safePage - 1) * pageSizeNumber
|
||||||
|
const pageRecords =
|
||||||
|
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
|
||||||
|
|
||||||
|
const enrichedRecords = []
|
||||||
|
for (const record of pageRecords) {
|
||||||
|
const usage = toUsageObject(record)
|
||||||
|
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||||
|
const computedCost =
|
||||||
|
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||||
|
const totalTokens =
|
||||||
|
record.totalTokens ||
|
||||||
|
usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
|
||||||
|
enrichedRecords.push({
|
||||||
|
timestamp: record.timestamp,
|
||||||
|
model: record.model || 'unknown',
|
||||||
|
apiKeyId: record.apiKeyId,
|
||||||
|
apiKeyName: record.apiKeyName,
|
||||||
|
accountId,
|
||||||
|
accountName: accountInfo.name || accountInfo.email || accountId,
|
||||||
|
accountType: record.accountType,
|
||||||
|
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
|
||||||
|
inputTokens: usage.input_tokens,
|
||||||
|
outputTokens: usage.output_tokens,
|
||||||
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
|
ephemeral5mTokens: record.ephemeral5mTokens || 0,
|
||||||
|
ephemeral1hTokens: record.ephemeral1hTokens || 0,
|
||||||
|
totalTokens,
|
||||||
|
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
|
||||||
|
cost: Number(computedCost.toFixed(6)),
|
||||||
|
costFormatted:
|
||||||
|
record.costFormatted ||
|
||||||
|
costData?.formatted?.total ||
|
||||||
|
CostCalculator.formatCost(computedCost),
|
||||||
|
costBreakdown: record.costBreakdown || {
|
||||||
|
input: costData?.costs?.input || 0,
|
||||||
|
output: costData?.costs?.output || 0,
|
||||||
|
cacheCreate: costData?.costs?.cacheWrite || 0,
|
||||||
|
cacheRead: costData?.costs?.cacheRead || 0,
|
||||||
|
total: costData?.costs?.total || computedCost
|
||||||
|
},
|
||||||
|
responseTime: record.responseTime || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
records: enrichedRecords,
|
||||||
|
pagination: {
|
||||||
|
currentPage: safePage,
|
||||||
|
pageSize: pageSizeNumber,
|
||||||
|
totalRecords,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: totalPages > 0 && safePage < totalPages,
|
||||||
|
hasPreviousPage: totalPages > 0 && safePage > 1
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
startDate: startTime ? startTime.toISOString() : null,
|
||||||
|
endDate: endTime ? endTime.toISOString() : null,
|
||||||
|
model: model || null,
|
||||||
|
apiKeyId: apiKeyId || null,
|
||||||
|
platform: accountInfo.platform,
|
||||||
|
sortOrder: normalizedSortOrder
|
||||||
|
},
|
||||||
|
accountInfo: {
|
||||||
|
id: accountId,
|
||||||
|
name: accountInfo.name || accountInfo.email || accountId,
|
||||||
|
platform: accountInfo.platform || platform || 'unknown',
|
||||||
|
status: accountInfo.status ?? accountInfo.isActive ?? null
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
...summary,
|
||||||
|
totalCost: Number(summary.totalCost.toFixed(6)),
|
||||||
|
avgCost:
|
||||||
|
summary.totalRequests > 0
|
||||||
|
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
|
||||||
|
: 0
|
||||||
|
},
|
||||||
|
availableFilters: {
|
||||||
|
models: Array.from(modelSet),
|
||||||
|
apiKeys: Array.from(apiKeyOptionMap.values()),
|
||||||
|
dateRange: {
|
||||||
|
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
|
||||||
|
latest: latestTimestamp ? latestTimestamp.toISOString() : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account usage records:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get account usage records', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
|
|||||||
if ('expiresAt' in mappedUpdates) {
|
if ('expiresAt' in mappedUpdates) {
|
||||||
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
|
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
|
||||||
delete mappedUpdates.expiresAt
|
delete mappedUpdates.expiresAt
|
||||||
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
|
logger.info(
|
||||||
|
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return mappedUpdates
|
return mappedUpdates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
|
|||||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -37,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为旧会话(污染的会话)
|
||||||
|
* Claude Code 发送的请求特点:
|
||||||
|
* - messages 数组通常只有 1 个元素
|
||||||
|
* - 历史对话记录嵌套在单个 message 的 content 数组中
|
||||||
|
* - content 数组中包含 <system-reminder> 开头的系统注入内容
|
||||||
|
*
|
||||||
|
* 污染会话的特征:
|
||||||
|
* 1. messages.length > 1
|
||||||
|
* 2. messages.length === 1 但 content 中有多个用户输入
|
||||||
|
* 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools)
|
||||||
|
*
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean} 是否为旧会话
|
||||||
|
*/
|
||||||
|
function isOldSession(body) {
|
||||||
|
const messages = body?.messages
|
||||||
|
const tools = body?.tools
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 多条消息 = 旧会话
|
||||||
|
if (messages.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 单条消息,分析 content
|
||||||
|
const firstMessage = messages[0]
|
||||||
|
const content = firstMessage?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是字符串,只有一条输入,需要检查 tools
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 有 tools = 正常新会话,无 tools = 可疑
|
||||||
|
return !tools || tools.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是数组,统计非 system-reminder 的元素
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const userInputs = content.filter((item) => {
|
||||||
|
if (item.type !== 'text') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const text = item.text || ''
|
||||||
|
// 剔除以 <system-reminder> 开头的
|
||||||
|
return !text.trimStart().startsWith('<system-reminder>')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 多个用户输入 = 旧会话
|
||||||
|
if (userInputs.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup 检测:单个消息 + 无 tools = 旧会话
|
||||||
|
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 共享的消息处理函数
|
// 🔧 共享的消息处理函数
|
||||||
async function handleMessagesRequest(req, res) {
|
async function handleMessagesRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -122,12 +190,42 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
|
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||||
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Client disconnected before stream response could start for key: ${req.apiKey?.name || 'unknown'}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 流式响应 - 只使用官方真实usage数据
|
// 流式响应 - 只使用官方真实usage数据
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
res.setHeader('Cache-Control', 'no-cache')
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
res.setHeader('Connection', 'keep-alive')
|
res.setHeader('Connection', 'keep-alive')
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
|
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
|
||||||
|
// ⚠️ 检查 headers 是否已发送(可能在排队心跳时已设置)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
|
// 当并发队列功能启用时,auth.js 会设置 Connection: close 来禁用 Keep-Alive
|
||||||
|
// 这里只在没有设置过 Connection 头时才设置 keep-alive
|
||||||
|
const existingConnection = res.getHeader('Connection')
|
||||||
|
if (!existingConnection) {
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
} else {
|
||||||
|
logger.api(
|
||||||
|
`🔌 [STREAM] Preserving existing Connection header: ${existingConnection} for key: ${req.apiKey?.name || 'unknown'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`📤 [STREAM] Headers already sent, skipping setHeader for key: ${req.apiKey?.name || 'unknown'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 禁用 Nagle 算法,确保数据立即发送
|
// 禁用 Nagle 算法,确保数据立即发送
|
||||||
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
||||||
@@ -141,6 +239,56 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证
|
||||||
|
let forcedAccount = null
|
||||||
|
let needSessionBinding = false
|
||||||
|
let originalSessionIdForBinding = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有绑定,使用绑定的账户
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccount = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记需要在调度成功后建立绑定
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBinding = true
|
||||||
|
originalSessionIdForBinding = originalSessionId
|
||||||
|
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -149,10 +297,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccount
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 处理会话绑定账户不可用的错误
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -170,6 +329,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBinding &&
|
||||||
|
originalSessionIdForBinding &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBinding,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
@@ -494,15 +687,113 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}, 1000) // 1秒后检查
|
}, 1000) // 1秒后检查
|
||||||
} else {
|
} else {
|
||||||
|
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||||
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 非流式响应 - 只使用官方真实usage数据
|
// 非流式响应 - 只使用官方真实usage数据
|
||||||
logger.info('📄 Starting non-streaming request', {
|
logger.info('📄 Starting non-streaming request', {
|
||||||
apiKeyId: req.apiKey.id,
|
apiKeyId: req.apiKey.id,
|
||||||
apiKeyName: req.apiKey.name
|
apiKeyName: req.apiKey.name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 📊 监听 socket 事件以追踪连接状态变化
|
||||||
|
const nonStreamSocket = res.socket
|
||||||
|
let _clientClosedConnection = false
|
||||||
|
let _socketCloseTime = null
|
||||||
|
|
||||||
|
if (nonStreamSocket) {
|
||||||
|
const onSocketEnd = () => {
|
||||||
|
_clientClosedConnection = true
|
||||||
|
_socketCloseTime = Date.now()
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [NON-STREAM] Socket 'end' event - client sent FIN | key: ${req.apiKey?.name}, ` +
|
||||||
|
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const onSocketClose = () => {
|
||||||
|
_clientClosedConnection = true
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [NON-STREAM] Socket 'close' event | key: ${req.apiKey?.name}, ` +
|
||||||
|
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms, ` +
|
||||||
|
`hadError: ${nonStreamSocket.destroyed}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const onSocketError = (err) => {
|
||||||
|
logger.error(
|
||||||
|
`❌ [NON-STREAM] Socket error | key: ${req.apiKey?.name}, ` +
|
||||||
|
`requestId: ${req.requestId}, error: ${err.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonStreamSocket.once('end', onSocketEnd)
|
||||||
|
nonStreamSocket.once('close', onSocketClose)
|
||||||
|
nonStreamSocket.once('error', onSocketError)
|
||||||
|
|
||||||
|
// 清理监听器(在响应结束后)
|
||||||
|
res.once('finish', () => {
|
||||||
|
nonStreamSocket.removeListener('end', onSocketEnd)
|
||||||
|
nonStreamSocket.removeListener('close', onSocketClose)
|
||||||
|
nonStreamSocket.removeListener('error', onSocketError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证(非流式)
|
||||||
|
let forcedAccountNonStream = null
|
||||||
|
let needSessionBindingNonStream = false
|
||||||
|
let originalSessionIdForBindingNonStream = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccountNonStream = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBindingNonStream = true
|
||||||
|
originalSessionIdForBindingNonStream = originalSessionId
|
||||||
|
logger.api(
|
||||||
|
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check (non-stream):', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -511,10 +802,20 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccountNonStream
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -527,6 +828,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBindingNonStream &&
|
||||||
|
originalSessionIdForBindingNonStream &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBindingNonStream,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务
|
// 根据账号类型选择对应的转发服务
|
||||||
let response
|
let response
|
||||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||||
@@ -611,6 +946,15 @@ async function handleMessagesRequest(req, res) {
|
|||||||
bodyLength: response.body ? response.body.length : 0
|
bodyLength: response.body ? response.body.length : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 🔍 检查客户端连接是否仍然有效
|
||||||
|
// 在长时间请求过程中,客户端可能已经断开连接(超时、用户取消等)
|
||||||
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Client disconnected before non-stream response could be sent for key: ${req.apiKey?.name || 'unknown'}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
res.status(response.statusCode)
|
res.status(response.statusCode)
|
||||||
|
|
||||||
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
|
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
|
||||||
@@ -676,10 +1020,12 @@ async function handleMessagesRequest(req, res) {
|
|||||||
logger.warn('⚠️ No usage data found in Claude API JSON response')
|
logger.warn('⚠️ No usage data found in Claude API JSON response')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 Express 内建的 res.json() 发送响应(简单可靠)
|
||||||
res.json(jsonData)
|
res.json(jsonData)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
|
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
|
||||||
logger.info('📄 Raw response body:', response.body)
|
logger.info('📄 Raw response body:', response.body)
|
||||||
|
// 使用 Express 内建的 res.send() 发送响应(简单可靠)
|
||||||
res.send(response.body)
|
res.send(response.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +1170,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
// 可选:根据 API Key 的模型限制过滤
|
// 可选:根据 API Key 的模型限制过滤
|
||||||
let filteredModels = models
|
let filteredModels = models
|
||||||
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||||
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id))
|
// 将 restrictedModels 视为黑名单:过滤掉受限模型
|
||||||
|
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -965,6 +1312,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!sessionValidation.valid) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: sessionValidation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
|
||||||
|
if (sessionValidation.isNewSession && originalSessionId) {
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||||
|
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
@@ -972,6 +1354,9 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
const maxAttempts = 2
|
const maxAttempts = 2
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
|
|
||||||
|
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||||
|
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||||
|
|
||||||
const processRequest = async () => {
|
const processRequest = async () => {
|
||||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
@@ -1003,6 +1388,17 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId)
|
||||||
|
if (isUnavailable) {
|
||||||
|
logger.info(
|
||||||
|
`⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response`
|
||||||
|
)
|
||||||
|
return { fallbackResponse: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const relayOptions = {
|
const relayOptions = {
|
||||||
skipUsageRecord: true,
|
skipUsageRecord: true,
|
||||||
customPath: '/v1/messages/count_tokens'
|
customPath: '/v1/messages/count_tokens'
|
||||||
@@ -1028,6 +1424,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
relayOptions
|
relayOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🔍 claude-console 账户:检测上游 404 响应并标记
|
||||||
|
if (accountType === 'claude-console' && response.statusCode === 404) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable`
|
||||||
|
)
|
||||||
|
// 标记失败不应影响 fallback 响应
|
||||||
|
try {
|
||||||
|
await claudeConsoleAccountService.markCountTokensUnavailable(accountId)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`,
|
||||||
|
markError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { fallbackResponse: true }
|
||||||
|
}
|
||||||
|
|
||||||
res.status(response.statusCode)
|
res.status(response.statusCode)
|
||||||
|
|
||||||
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
|
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
|
||||||
@@ -1050,11 +1463,21 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
|
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
|
||||||
|
return { fallbackResponse: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
while (attempt < maxAttempts) {
|
while (attempt < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
await processRequest()
|
const result = await processRequest()
|
||||||
|
|
||||||
|
// 🔍 处理 fallback 响应(claude-console 账户 count_tokens 不可用)
|
||||||
|
if (result && result.fallbackResponse) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(200).json({ input_tokens: 0 })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
|
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -205,74 +206,85 @@ 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 allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||||
|
const modelUsageMap = new Map()
|
||||||
|
|
||||||
const model = modelMatch[1]
|
for (const key of allModelKeys) {
|
||||||
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
|
const data = await client.hgetall(key)
|
||||||
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
|
||||||
@@ -792,8 +804,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
|
|
||||||
// 🧪 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 axios = require('axios')
|
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
|
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' } = req.body
|
||||||
@@ -805,7 +817,6 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本格式验证
|
|
||||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid API key format',
|
error: 'Invalid API key format',
|
||||||
@@ -813,7 +824,6 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首先验证API Key是否有效(不触发激活)
|
|
||||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -824,244 +834,29 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
|
|
||||||
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
||||||
|
|
||||||
// 设置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.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
|
||||||
|
|
||||||
// 构建测试请求,模拟 Claude CLI 客户端
|
|
||||||
const port = config.server.port || 3000
|
const port = config.server.port || 3000
|
||||||
const baseURL = `http://127.0.0.1:${port}`
|
const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
|
||||||
|
|
||||||
const testPayload = {
|
await sendStreamTestRequest({
|
||||||
model,
|
apiUrl,
|
||||||
messages: [
|
authorization: apiKey,
|
||||||
{
|
responseStream: res,
|
||||||
role: 'user',
|
payload: createClaudeTestPayload(model, { stream: true }),
|
||||||
content: 'hi'
|
timeout: 60000,
|
||||||
}
|
extraHeaders: { 'x-api-key': apiKey }
|
||||||
],
|
|
||||||
system: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
max_tokens: 32000,
|
|
||||||
temperature: 1,
|
|
||||||
stream: true
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
'anthropic-version': config.claude.apiVersion || '2023-06-01'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向自身服务发起测试请求
|
|
||||||
// 使用 validateStatus 允许所有状态码通过,以便我们可以处理流式错误响应
|
|
||||||
const response = await axios.post(`${baseURL}/api/v1/messages`, testPayload, {
|
|
||||||
headers,
|
|
||||||
responseType: 'stream',
|
|
||||||
timeout: 60000, // 60秒超时
|
|
||||||
validateStatus: () => true // 接受所有状态码,自行处理错误
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查响应状态码,如果不是2xx,尝试读取错误信息
|
|
||||||
if (response.status >= 400) {
|
|
||||||
logger.error(
|
|
||||||
`🧪 API Key test received error status ${response.status} for: ${validation.keyData.name}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 尝试从流中读取错误信息
|
|
||||||
let errorBody = ''
|
|
||||||
for await (const chunk of response.data) {
|
|
||||||
errorBody += chunk.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
let errorMessage = `HTTP ${response.status}`
|
|
||||||
try {
|
|
||||||
// 尝试解析SSE格式的错误
|
|
||||||
const lines = errorBody.split('\n')
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const dataStr = line.substring(6).trim()
|
|
||||||
if (dataStr && dataStr !== '[DONE]') {
|
|
||||||
const data = JSON.parse(dataStr)
|
|
||||||
if (data.error?.message) {
|
|
||||||
errorMessage = data.error.message
|
|
||||||
break
|
|
||||||
} else if (data.message) {
|
|
||||||
errorMessage = data.message
|
|
||||||
break
|
|
||||||
} else if (typeof data.error === 'string') {
|
|
||||||
errorMessage = data.error
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果不是SSE格式,尝试直接解析JSON
|
|
||||||
if (errorMessage === `HTTP ${response.status}`) {
|
|
||||||
const jsonError = JSON.parse(errorBody)
|
|
||||||
errorMessage =
|
|
||||||
jsonError.error?.message || jsonError.message || jsonError.error || errorMessage
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 解析失败,使用原始错误体或默认消息
|
|
||||||
if (errorBody && errorBody.length < 500) {
|
|
||||||
errorMessage = errorBody
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`)
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMessage })}\n\n`
|
|
||||||
)
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let receivedContent = ''
|
|
||||||
let testSuccess = false
|
|
||||||
let upstreamError = null
|
|
||||||
|
|
||||||
// 处理流式响应
|
|
||||||
response.data.on('data', (chunk) => {
|
|
||||||
const lines = chunk.toString().split('\n')
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const dataStr = line.substring(6).trim()
|
|
||||||
if (dataStr === '[DONE]') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(dataStr)
|
|
||||||
|
|
||||||
// 检查上游返回的错误事件
|
|
||||||
if (data.type === 'error' || data.error) {
|
|
||||||
let errorMsg = 'Unknown upstream error'
|
|
||||||
|
|
||||||
// 优先从 data.error 提取(如果是对象,获取其 message)
|
|
||||||
if (typeof data.error === 'object' && data.error?.message) {
|
|
||||||
errorMsg = data.error.message
|
|
||||||
} else if (typeof data.error === 'string' && data.error !== 'Claude API error') {
|
|
||||||
// 如果 error 是字符串且不是通用错误,直接使用
|
|
||||||
errorMsg = data.error
|
|
||||||
} else if (data.details) {
|
|
||||||
// 尝试从 details 字段解析详细错误(claudeRelayService 格式)
|
|
||||||
try {
|
|
||||||
const details =
|
|
||||||
typeof data.details === 'string' ? JSON.parse(data.details) : data.details
|
|
||||||
if (details.error?.message) {
|
|
||||||
errorMsg = details.error.message
|
|
||||||
} else if (details.message) {
|
|
||||||
errorMsg = details.message
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// details 不是有效 JSON,尝试直接使用
|
|
||||||
if (typeof data.details === 'string' && data.details.length < 500) {
|
|
||||||
errorMsg = data.details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.message) {
|
|
||||||
errorMsg = data.message
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加状态码信息(如果有)
|
|
||||||
if (data.status && errorMsg !== 'Unknown upstream error') {
|
|
||||||
errorMsg = `[${data.status}] ${errorMsg}`
|
|
||||||
}
|
|
||||||
|
|
||||||
upstreamError = errorMsg
|
|
||||||
logger.error(`🧪 Upstream error in test for: ${validation.keyData.name}:`, errorMsg)
|
|
||||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取文本内容
|
|
||||||
if (data.type === 'content_block_delta' && data.delta?.text) {
|
|
||||||
receivedContent += data.delta.text
|
|
||||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消息结束
|
|
||||||
if (data.type === 'message_stop') {
|
|
||||||
testSuccess = true
|
|
||||||
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('end', () => {
|
|
||||||
// 如果有上游错误,标记为失败
|
|
||||||
if (upstreamError) {
|
|
||||||
testSuccess = false
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.api(
|
|
||||||
`🧪 API Key test completed for: ${validation.keyData.name}, success: ${testSuccess}, content length: ${receivedContent.length}${upstreamError ? `, error: ${upstreamError}` : ''}`
|
|
||||||
)
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({
|
|
||||||
type: 'test_complete',
|
|
||||||
success: testSuccess,
|
|
||||||
contentLength: receivedContent.length,
|
|
||||||
error: upstreamError || undefined
|
|
||||||
})}\n\n`
|
|
||||||
)
|
|
||||||
res.end()
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('error', (err) => {
|
|
||||||
logger.error(`🧪 API Key test stream error for: ${validation.keyData.name}`, err)
|
|
||||||
|
|
||||||
// 如果已经捕获了上游错误,优先使用那个
|
|
||||||
let errorMsg = upstreamError || err.message || 'Stream error'
|
|
||||||
|
|
||||||
// 如果错误消息是通用的 "Claude API error: xxx",提供更友好的提示
|
|
||||||
if (errorMsg.startsWith('Claude API error:') && upstreamError) {
|
|
||||||
errorMsg = upstreamError
|
|
||||||
}
|
|
||||||
|
|
||||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
|
||||||
)
|
|
||||||
res.end()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 处理客户端断开连接
|
|
||||||
req.on('close', () => {
|
|
||||||
if (!res.writableEnded) {
|
|
||||||
response.data.destroy()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ API Key test failed:', error)
|
logger.error('❌ API Key test failed:', error)
|
||||||
|
|
||||||
// 如果还未发送响应头,返回JSON错误
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'Test failed',
|
error: 'Test failed',
|
||||||
message: error.response?.data?.error?.message || error.message || 'Internal server error'
|
message: error.message || 'Internal server error'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已经是SSE流,发送错误事件
|
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({ type: 'error', error: error.response?.data?.error?.message || error.message || 'Test failed' })}\n\n`
|
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||||
)
|
)
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
|||||||
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')
|
||||||
|
const { getEffectiveModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
// 🔧 辅助函数:检查 API Key 权限
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 如果启用了模型限制,过滤模型列表
|
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查模型限制
|
// 模型限制(黑名单):命中则直接拒绝
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
if (apiKeyData.restrictedModels.includes(modelId)) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Model '${modelId}' not found`,
|
message: `Model '${modelId}' not found`,
|
||||||
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
// 转换 OpenAI 请求为 Claude 格式
|
// 转换 OpenAI 请求为 Claude 格式
|
||||||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||||||
|
|
||||||
// 检查模型限制
|
// 模型限制(黑名单):命中受限模型则拒绝
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
const effectiveModel = getEffectiveModel(claudeRequest.model || '')
|
||||||
|
if (apiKeyData.restrictedModels.includes(effectiveModel)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||||
|
|||||||
@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
|
|||||||
|
|
||||||
// 从请求体中提取模型和流式标志
|
// 从请求体中提取模型和流式标志
|
||||||
let requestedModel = req.body?.model || null
|
let requestedModel = req.body?.model || null
|
||||||
|
const isCodexModel =
|
||||||
|
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
|
||||||
|
|
||||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5
|
||||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
|
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
|
||||||
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
||||||
requestedModel = 'gpt-5'
|
requestedModel = 'gpt-5'
|
||||||
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
||||||
|
|||||||
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
|
||||||
@@ -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() {
|
||||||
@@ -69,7 +70,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 +152,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 +185,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 +292,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'
|
||||||
})
|
})
|
||||||
@@ -191,6 +376,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ const {
|
|||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
|
const { isOpus45OrNewer } = require('../utils/modelHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is Pro (not Max)
|
||||||
|
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||||
|
* @param {Object} info - Subscription info object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isProAccount(info) {
|
||||||
|
// API real-time status takes priority
|
||||||
|
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Local configured account type
|
||||||
|
return info.accountType === 'claude_pro'
|
||||||
|
}
|
||||||
|
|
||||||
class ClaudeAccountService {
|
class ClaudeAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -75,7 +91,8 @@ class ClaudeAccountService {
|
|||||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||||
unifiedClientId = '', // 统一的客户端标识
|
unifiedClientId = '', // 统一的客户端标识
|
||||||
expiresAt = null, // 账户订阅到期时间
|
expiresAt = null, // 账户订阅到期时间
|
||||||
extInfo = null // 额外扩展信息
|
extInfo = null, // 额外扩展信息
|
||||||
|
maxConcurrency = 0 // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -120,7 +137,9 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容旧格式
|
// 兼容旧格式
|
||||||
@@ -152,7 +171,9 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +579,9 @@ class ClaudeAccountService {
|
|||||||
// 添加停止原因
|
// 添加停止原因
|
||||||
stoppedReason: account.stoppedReason || null,
|
stoppedReason: account.stoppedReason || null,
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: parsedExtInfo
|
extInfo: parsedExtInfo,
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: parseInt(account.maxConcurrency || '0', 10)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -650,7 +673,8 @@ class ClaudeAccountService {
|
|||||||
'useUnifiedClientId',
|
'useUnifiedClientId',
|
||||||
'unifiedClientId',
|
'unifiedClientId',
|
||||||
'subscriptionExpiresAt',
|
'subscriptionExpiresAt',
|
||||||
'extInfo'
|
'extInfo',
|
||||||
|
'maxConcurrency'
|
||||||
]
|
]
|
||||||
const updatedData = { ...accountData }
|
const updatedData = { ...accountData }
|
||||||
let shouldClearAutoStopFields = false
|
let shouldClearAutoStopFields = false
|
||||||
@@ -665,7 +689,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') {
|
||||||
// 处理订阅信息更新
|
// 处理订阅信息更新
|
||||||
@@ -852,31 +876,39 @@ class ClaudeAccountService {
|
|||||||
!this.isSubscriptionExpired(account)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// Filter Opus models based on account type and model version
|
||||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(modelName)
|
||||||
|
|
||||||
activeAccounts = activeAccounts.filter((account) => {
|
activeAccounts = activeAccounts.filter((account) => {
|
||||||
// 检查账号的订阅信息
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(account.subscriptionInfo)
|
const info = JSON.parse(account.subscriptionInfo)
|
||||||
// Pro 和 Free 账号不支持 Opus
|
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
// Free account: does not support any Opus model
|
||||||
return false // Claude Pro 不支持 Opus
|
if (info.accountType === 'free') {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
|
||||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
// Pro account: only supports Opus 4.5+
|
||||||
|
if (isProAccount(info)) {
|
||||||
|
return isNewOpus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max account: supports all Opus versions
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (activeAccounts.length === 0) {
|
if (activeAccounts.length === 0) {
|
||||||
throw new Error('No Claude accounts available that support Opus model')
|
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||||
|
throw new Error(`No Claude accounts available that support ${modelDesc} model`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,31 +1002,39 @@ class ClaudeAccountService {
|
|||||||
!this.isSubscriptionExpired(account)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// Filter Opus models based on account type and model version
|
||||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(modelName)
|
||||||
|
|
||||||
sharedAccounts = sharedAccounts.filter((account) => {
|
sharedAccounts = sharedAccounts.filter((account) => {
|
||||||
// 检查账号的订阅信息
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(account.subscriptionInfo)
|
const info = JSON.parse(account.subscriptionInfo)
|
||||||
// Pro 和 Free 账号不支持 Opus
|
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
// Free account: does not support any Opus model
|
||||||
return false // Claude Pro 不支持 Opus
|
if (info.accountType === 'free') {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
|
||||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
// Pro account: only supports Opus 4.5+
|
||||||
|
if (isProAccount(info)) {
|
||||||
|
return isNewOpus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max account: supports all Opus versions
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sharedAccounts.length === 0) {
|
if (sharedAccounts.length === 0) {
|
||||||
throw new Error('No shared Claude accounts available that support Opus model')
|
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||||
|
throw new Error(`No shared Claude accounts available that support ${modelDesc} model`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
|
|||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
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 不自动禁用)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
|
|||||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '', // 因额度停用的时间
|
||||||
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数,0表示无限制
|
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||||
|
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
// 并发控制相关
|
// 并发控制相关
|
||||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||||
activeTaskCount
|
activeTaskCount,
|
||||||
|
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
accountData.isActive = accountData.isActive === 'true'
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||||
|
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||||
|
|
||||||
if (accountData.proxy) {
|
if (accountData.proxy) {
|
||||||
accountData.proxy = JSON.parse(accountData.proxy)
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
|
|||||||
if (updates.maxConcurrentTasks !== undefined) {
|
if (updates.maxConcurrentTasks !== undefined) {
|
||||||
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
||||||
}
|
}
|
||||||
|
if (updates.disableAutoProtection !== undefined) {
|
||||||
|
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
@@ -1510,6 +1518,71 @@ class ClaudeConsoleAccountService {
|
|||||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
return expiryDate <= new Date()
|
return expiryDate <= new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户的 count_tokens 端点不可用
|
||||||
|
async markCountTokensUnavailable(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
// 检查账户是否存在
|
||||||
|
const exists = await client.exists(accountKey)
|
||||||
|
if (!exists) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
|
||||||
|
)
|
||||||
|
return { success: false, reason: 'Account not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hset(accountKey, {
|
||||||
|
countTokensUnavailable: 'true',
|
||||||
|
countTokensUnavailableAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 移除账户的 count_tokens 不可用标记
|
||||||
|
async removeCountTokensUnavailable(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户的 count_tokens 端点是否不可用
|
||||||
|
async isCountTokensUnavailable(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
const value = await client.hget(accountKey, 'countTokensUnavailable')
|
||||||
|
return value === 'true'
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
|
||||||
|
return false // 出错时默认返回可用,避免误阻断
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeConsoleAccountService()
|
module.exports = new ClaudeConsoleAccountService()
|
||||||
|
|||||||
@@ -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,14 +32,76 @@ 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) {
|
||||||
throw new Error('Claude Console Claude account not found')
|
throw new Error('Claude Console Claude account not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
||||||
)
|
)
|
||||||
@@ -201,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)
|
||||||
@@ -248,27 +330,41 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 检查错误状态并相应处理
|
// 检查错误状态并相应处理
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
|
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||||
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 200 || response.status === 201) {
|
} else if (response.status === 200 || response.status === 201) {
|
||||||
// 如果请求成功,检查并移除错误状态
|
// 如果请求成功,检查并移除错误状态
|
||||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||||
@@ -350,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,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) {
|
||||||
@@ -467,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 {
|
||||||
// 🛑 清理租约刷新定时器
|
// 🛑 清理租约刷新定时器
|
||||||
@@ -501,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,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
|
||||||
@@ -577,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}`)
|
||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
@@ -597,6 +814,7 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', async () => {
|
response.data.on('end', async () => {
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
||||||
logger.error(
|
logger.error(
|
||||||
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
||||||
@@ -609,24 +827,41 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
if (!autoProtectionDisabled) {
|
||||||
accountId,
|
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
||||||
errorDataForCheck
|
accountId,
|
||||||
)
|
errorDataForCheck
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 检查是否因为超过每日额度
|
// 检查是否因为超过每日额度
|
||||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
@@ -648,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()
|
||||||
}
|
}
|
||||||
@@ -656,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()
|
||||||
}
|
}
|
||||||
@@ -667,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) {
|
||||||
@@ -680,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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -710,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()
|
||||||
@@ -831,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(
|
||||||
@@ -854,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) {
|
||||||
@@ -903,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)
|
||||||
@@ -920,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(
|
||||||
@@ -968,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(
|
||||||
@@ -1007,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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🕐 更新最后使用时间
|
// 🕐 更新最后使用时间
|
||||||
@@ -1113,55 +1388,11 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧪 测试账号连接(供Admin API使用,独立处理以确保错误时也返回SSE格式)
|
// 🧪 测试账号连接(供Admin API使用)
|
||||||
async testAccountConnection(accountId, responseStream) {
|
async testAccountConnection(accountId, responseStream) {
|
||||||
const testRequestBody = {
|
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||||
model: 'claude-sonnet-4-5-20250929',
|
|
||||||
max_tokens: 32000,
|
|
||||||
stream: true,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: 'hi'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
system: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:发送 SSE 事件
|
|
||||||
const sendSSEEvent = (type, data) => {
|
|
||||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
|
||||||
try {
|
|
||||||
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
|
||||||
} catch {
|
|
||||||
// 忽略写入错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:结束测试并关闭流
|
|
||||||
const endTest = (success, error = null) => {
|
|
||||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
|
||||||
try {
|
|
||||||
if (success) {
|
|
||||||
sendSSEEvent('test_complete', { success: true })
|
|
||||||
} else {
|
|
||||||
sendSSEEvent('test_complete', { success: false, error: error || '测试失败' })
|
|
||||||
}
|
|
||||||
responseStream.end()
|
|
||||||
} catch {
|
|
||||||
// 忽略写入错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户信息
|
|
||||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found')
|
throw new Error('Account not found')
|
||||||
@@ -1169,178 +1400,32 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
|
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
|
||||||
|
|
||||||
// 创建代理agent
|
|
||||||
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy)
|
|
||||||
|
|
||||||
// 设置响应头
|
|
||||||
if (!responseStream.headersSent) {
|
|
||||||
responseStream.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送测试开始事件
|
|
||||||
sendSSEEvent('test_start', {})
|
|
||||||
|
|
||||||
// 构建完整的API URL
|
|
||||||
const cleanUrl = account.apiUrl.replace(/\/$/, '')
|
const cleanUrl = account.apiUrl.replace(/\/$/, '')
|
||||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
const apiUrl = cleanUrl.endsWith('/v1/messages')
|
||||||
|
? cleanUrl
|
||||||
|
: `${cleanUrl}/v1/messages?beta=true`
|
||||||
|
|
||||||
// 决定使用的 User-Agent
|
await sendStreamTestRequest({
|
||||||
const userAgent = account.userAgent || this.defaultUserAgent
|
apiUrl,
|
||||||
|
authorization: `Bearer ${account.apiKey}`,
|
||||||
// 准备请求配置
|
responseStream,
|
||||||
const requestConfig = {
|
proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
|
||||||
method: 'POST',
|
extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
|
||||||
url: apiEndpoint,
|
|
||||||
data: testRequestBody,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'User-Agent': userAgent
|
|
||||||
},
|
|
||||||
timeout: 30000, // 测试请求使用较短超时
|
|
||||||
responseType: 'stream',
|
|
||||||
validateStatus: () => true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proxyAgent) {
|
|
||||||
requestConfig.httpAgent = proxyAgent
|
|
||||||
requestConfig.httpsAgent = proxyAgent
|
|
||||||
requestConfig.proxy = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置认证方式
|
|
||||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
|
||||||
requestConfig.headers['x-api-key'] = account.apiKey
|
|
||||||
} else {
|
|
||||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送请求
|
|
||||||
const response = await axios(requestConfig)
|
|
||||||
|
|
||||||
logger.debug(`🌊 Claude Console test response status: ${response.status}`)
|
|
||||||
|
|
||||||
// 处理非200响应
|
|
||||||
if (response.status !== 200) {
|
|
||||||
logger.error(
|
|
||||||
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 收集错误响应数据
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const errorChunks = []
|
|
||||||
|
|
||||||
response.data.on('data', (chunk) => {
|
|
||||||
errorChunks.push(chunk)
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('end', () => {
|
|
||||||
try {
|
|
||||||
const fullErrorData = Buffer.concat(errorChunks).toString()
|
|
||||||
logger.error(
|
|
||||||
`📝 [Test] Upstream error response from ${account?.name || accountId}: ${fullErrorData.substring(0, 500)}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 尝试解析错误信息
|
|
||||||
let errorMessage = `API Error: ${response.status}`
|
|
||||||
try {
|
|
||||||
const errorJson = JSON.parse(fullErrorData)
|
|
||||||
// 直接提取所有可能的错误信息字段
|
|
||||||
errorMessage =
|
|
||||||
errorJson.message ||
|
|
||||||
errorJson.error?.message ||
|
|
||||||
errorJson.statusMessage ||
|
|
||||||
errorJson.error ||
|
|
||||||
(typeof errorJson === 'string' ? errorJson : JSON.stringify(errorJson))
|
|
||||||
} catch {
|
|
||||||
errorMessage = fullErrorData.substring(0, 200) || `API Error: ${response.status}`
|
|
||||||
}
|
|
||||||
|
|
||||||
endTest(false, errorMessage)
|
|
||||||
resolve()
|
|
||||||
} catch {
|
|
||||||
endTest(false, `API Error: ${response.status}`)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('error', (err) => {
|
|
||||||
endTest(false, err.message || '流读取错误')
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理成功的流式响应
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
response.data.on('data', (chunk) => {
|
|
||||||
try {
|
|
||||||
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(6).trim()
|
|
||||||
if (!jsonStr || jsonStr === '[DONE]') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(jsonStr)
|
|
||||||
|
|
||||||
// 转换 content_block_delta 为 content
|
|
||||||
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
|
|
||||||
sendSSEEvent('content', { text: data.delta.text })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理消息完成
|
|
||||||
if (data.type === 'message_stop') {
|
|
||||||
endTest(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理错误事件
|
|
||||||
if (data.type === 'error') {
|
|
||||||
const errorMsg = data.error?.message || data.message || '未知错误'
|
|
||||||
endTest(false, errorMsg)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略处理错误
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('end', () => {
|
|
||||||
logger.info(`✅ Test request completed for account: ${account.name}`)
|
|
||||||
// 如果还没结束,发送完成事件
|
|
||||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
|
||||||
endTest(true)
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
response.data.on('error', (err) => {
|
|
||||||
logger.error(`❌ Test stream error:`, err)
|
|
||||||
endTest(false, err.message || '流处理错误')
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Test account connection failed:`, error)
|
logger.error(`❌ Test account connection failed:`, error)
|
||||||
endTest(false, error.message || '测试失败')
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isStreamWritable(responseStream)) {
|
||||||
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
||||||
|
)
|
||||||
|
responseStream.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||||
|
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长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
@@ -556,7 +556,8 @@ class DroidAccountService {
|
|||||||
tokenType = 'Bearer',
|
tokenType = 'Bearer',
|
||||||
authenticationMethod = '',
|
authenticationMethod = '',
|
||||||
expiresIn = null,
|
expiresIn = null,
|
||||||
apiKeys = []
|
apiKeys = [],
|
||||||
|
userAgent = '' // 自定义 User-Agent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -832,7 +833,8 @@ class DroidAccountService {
|
|||||||
: '',
|
: '',
|
||||||
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
||||||
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
||||||
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
|
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
|
||||||
|
userAgent: userAgent || '' // 自定义 User-Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setDroidAccount(accountId, accountData)
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
@@ -931,6 +933,11 @@ class DroidAccountService {
|
|||||||
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 userAgent 字段
|
||||||
|
if (typeof sanitizedUpdates.userAgent === 'string') {
|
||||||
|
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
|
||||||
|
}
|
||||||
|
|
||||||
const parseProxyConfig = (value) => {
|
const parseProxyConfig = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DroidRelayService {
|
|||||||
comm: '/o/v1/chat/completions'
|
comm: '/o/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userAgent = 'factory-cli/0.19.12'
|
this.userAgent = 'factory-cli/0.32.1'
|
||||||
this.systemPrompt = SYSTEM_PROMPT
|
this.systemPrompt = SYSTEM_PROMPT
|
||||||
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,8 @@ class DroidRelayService {
|
|||||||
accessToken,
|
accessToken,
|
||||||
normalizedRequestBody,
|
normalizedRequestBody,
|
||||||
normalizedEndpoint,
|
normalizedEndpoint,
|
||||||
clientHeaders
|
clientHeaders,
|
||||||
|
account
|
||||||
)
|
)
|
||||||
|
|
||||||
if (selectedApiKey) {
|
if (selectedApiKey) {
|
||||||
@@ -335,7 +336,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) {
|
||||||
@@ -633,7 +639,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'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -737,6 +743,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
data.usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +772,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI Response API format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
@@ -966,11 +988,13 @@ class DroidRelayService {
|
|||||||
/**
|
/**
|
||||||
* 构建请求头
|
* 构建请求头
|
||||||
*/
|
*/
|
||||||
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) {
|
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) {
|
||||||
|
// 使用账户配置的 userAgent 或默认值
|
||||||
|
const userAgent = account?.userAgent || this.userAgent
|
||||||
const headers = {
|
const headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${accessToken}`,
|
authorization: `Bearer ${accessToken}`,
|
||||||
'user-agent': this.userAgent,
|
'user-agent': userAgent,
|
||||||
'x-factory-client': 'cli',
|
'x-factory-client': 'cli',
|
||||||
connection: 'keep-alive'
|
connection: 'keep-alive'
|
||||||
}
|
}
|
||||||
@@ -987,9 +1011,15 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI 特定头
|
// OpenAI 特定头 - 根据模型动态选择 provider
|
||||||
if (endpointType === 'openai') {
|
if (endpointType === 'openai') {
|
||||||
headers['x-api-provider'] = 'azure_openai'
|
const model = (requestBody?.model || '').toLowerCase()
|
||||||
|
// -max 模型使用 openai provider,其他使用 azure_openai
|
||||||
|
if (model.includes('-max')) {
|
||||||
|
headers['x-api-provider'] = 'openai'
|
||||||
|
} else {
|
||||||
|
headers['x-api-provider'] = 'azure_openai'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comm 端点根据模型动态设置 provider
|
// Comm 端点根据模型动态设置 provider
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const { filterForOpenAI } = require('../utils/headerFilter')
|
||||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||||
const apiKeyService = require('./apiKeyService')
|
const apiKeyService = require('./apiKeyService')
|
||||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||||
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
|
|||||||
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
||||||
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
||||||
|
|
||||||
// 构建请求头
|
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
|
||||||
const headers = {
|
const headers = {
|
||||||
...this._filterRequestHeaders(req.headers),
|
...filterForOpenAI(req.headers),
|
||||||
Authorization: `Bearer ${fullAccount.apiKey}`,
|
Authorization: `Bearer ${fullAccount.apiKey}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -425,9 +426,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
|
||||||
}
|
}
|
||||||
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
|
|||||||
return { resetsInSeconds, errorData }
|
return { resetsInSeconds, errorData }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤请求头
|
// 过滤请求头 - 已迁移到 headerFilter 工具类
|
||||||
|
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
|
||||||
_filterRequestHeaders(headers) {
|
_filterRequestHeaders(headers) {
|
||||||
const filtered = {}
|
return filterForOpenAI(headers)
|
||||||
const skipHeaders = [
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'authorization',
|
|
||||||
'x-api-key',
|
|
||||||
'x-cr-api-key',
|
|
||||||
'connection',
|
|
||||||
'upgrade',
|
|
||||||
'sec-websocket-key',
|
|
||||||
'sec-websocket-version',
|
|
||||||
'sec-websocket-extensions'
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
|
||||||
filtered[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
|
|||||||
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 { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is Pro (not Max)
|
||||||
|
*
|
||||||
|
* ACCOUNT TYPE LOGIC (as of 2025-12-05):
|
||||||
|
* Pro accounts can be identified by either:
|
||||||
|
* 1. API real-time data: hasClaudePro=true && hasClaudeMax=false
|
||||||
|
* 2. Local config data: accountType='claude_pro'
|
||||||
|
*
|
||||||
|
* Account type restrictions for Opus models:
|
||||||
|
* - Free account: No Opus access at all
|
||||||
|
* - Pro account: Only Opus 4.5+ (new versions)
|
||||||
|
* - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+)
|
||||||
|
*
|
||||||
|
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||||
|
* @param {Object} info - Subscription info object
|
||||||
|
* @returns {boolean} - true if Pro account (not Free, not Max)
|
||||||
|
*/
|
||||||
|
function isProAccount(info) {
|
||||||
|
// API real-time status takes priority
|
||||||
|
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Local configured account type
|
||||||
|
return info.accountType === 'claude_pro'
|
||||||
|
}
|
||||||
|
|
||||||
class UnifiedClaudeScheduler {
|
class UnifiedClaudeScheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Opus 模型的订阅级别检查
|
// 2. Opus model subscription level check
|
||||||
|
// VERSION RESTRICTION LOGIC:
|
||||||
|
// - Free: No Opus models
|
||||||
|
// - Pro: Only Opus 4.5+ (isOpus45OrNewer = true)
|
||||||
|
// - Max: All Opus versions
|
||||||
if (requestedModel.toLowerCase().includes('opus')) {
|
if (requestedModel.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(requestedModel)
|
||||||
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info =
|
const info =
|
||||||
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
|
|||||||
? JSON.parse(account.subscriptionInfo)
|
? JSON.parse(account.subscriptionInfo)
|
||||||
: account.subscriptionInfo
|
: account.subscriptionInfo
|
||||||
|
|
||||||
// Pro 和 Free 账号不支持 Opus
|
// Free account: does not support any Opus model
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
if (info.accountType === 'free') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
|
`🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}`
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
|
||||||
logger.info(
|
// Pro account: only supports Opus 4.5+
|
||||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
|
// Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+)
|
||||||
)
|
if (isProAccount(info)) {
|
||||||
return false
|
if (!isNewOpus) {
|
||||||
|
logger.info(
|
||||||
|
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Opus 4.5+ supported
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max account: supports all Opus versions (no restriction)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +180,56 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(
|
||||||
|
apiKeyData,
|
||||||
|
sessionHash = null,
|
||||||
|
requestedModel = null,
|
||||||
|
forcedAccount = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||||
|
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (forcedAccount.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||||
|
)
|
||||||
|
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||||
|
} else {
|
||||||
|
// claude-official 类型需要检查可用性并强制使用
|
||||||
|
logger.info(
|
||||||
|
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||||
|
forcedAccount.accountId,
|
||||||
|
forcedAccount.accountType,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: forcedAccount.accountId,
|
||||||
|
accountType: forcedAccount.accountType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||||
|
logger.warn(
|
||||||
|
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
const error = new Error('Session binding account unavailable')
|
||||||
|
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||||
|
error.accountId = forcedAccount.accountId
|
||||||
|
error.accountType = forcedAccount.accountType
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析供应商前缀
|
// 解析供应商前缀
|
||||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||||
@@ -177,30 +266,41 @@ class UnifiedClaudeScheduler {
|
|||||||
// 普通专属账户
|
// 普通专属账户
|
||||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
// 检查是否临时不可用
|
||||||
if (isRateLimited) {
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
boundAccount.id,
|
||||||
const error = new Error('Dedicated Claude account is rate limited')
|
'claude-official'
|
||||||
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
|
)
|
||||||
error.accountId = boundAccount.id
|
if (isTempUnavailable) {
|
||||||
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
`⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (isOpusRequest) {
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||||
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
|
if (isRateLimited) {
|
||||||
|
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||||
|
const error = new Error('Dedicated Claude account is rate limited')
|
||||||
|
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
|
||||||
|
error.accountId = boundAccount.id
|
||||||
|
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
logger.info(
|
|
||||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||||
)
|
logger.warn(
|
||||||
return {
|
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||||
accountId: apiKeyData.claudeAccountId,
|
)
|
||||||
accountType: 'claude-official'
|
} else {
|
||||||
|
if (isOpusRequest) {
|
||||||
|
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: apiKeyData.claudeAccountId,
|
||||||
|
accountType: 'claude-official'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -221,12 +321,23 @@ class UnifiedClaudeScheduler {
|
|||||||
boundConsoleAccount.status === 'active' &&
|
boundConsoleAccount.status === 'active' &&
|
||||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||||
) {
|
) {
|
||||||
logger.info(
|
// 检查是否临时不可用
|
||||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
boundConsoleAccount.id,
|
||||||
|
'claude-console'
|
||||||
)
|
)
|
||||||
return {
|
if (isTempUnavailable) {
|
||||||
accountId: apiKeyData.claudeConsoleAccountId,
|
logger.warn(
|
||||||
accountType: 'claude-console'
|
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: apiKeyData.claudeConsoleAccountId,
|
||||||
|
accountType: 'claude-console'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -245,12 +356,23 @@ class UnifiedClaudeScheduler {
|
|||||||
boundBedrockAccountResult.data.isActive === true &&
|
boundBedrockAccountResult.data.isActive === true &&
|
||||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||||
) {
|
) {
|
||||||
logger.info(
|
// 检查是否临时不可用
|
||||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
apiKeyData.bedrockAccountId,
|
||||||
|
'bedrock'
|
||||||
)
|
)
|
||||||
return {
|
if (isTempUnavailable) {
|
||||||
accountId: apiKeyData.bedrockAccountId,
|
logger.warn(
|
||||||
accountType: 'bedrock'
|
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: apiKeyData.bedrockAccountId,
|
||||||
|
accountType: 'bedrock'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -496,6 +618,18 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
account.id,
|
||||||
|
'claude-official'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.debug(
|
||||||
|
`⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被限流
|
// 检查是否被限流
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -584,6 +718,18 @@ class UnifiedClaudeScheduler {
|
|||||||
// 继续处理该账号
|
// 继续处理该账号
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
currentAccount.id,
|
||||||
|
'claude-console'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.debug(
|
||||||
|
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被限流
|
// 检查是否被限流
|
||||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||||
currentAccount.id
|
currentAccount.id
|
||||||
@@ -682,7 +828,15 @@ class UnifiedClaudeScheduler {
|
|||||||
account.accountType === 'shared' &&
|
account.accountType === 'shared' &&
|
||||||
this._isSchedulable(account.schedulable)
|
this._isSchedulable(account.schedulable)
|
||||||
) {
|
) {
|
||||||
// 检查是否可调度
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
account.id,
|
||||||
|
'bedrock'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
availableAccounts.push({
|
availableAccounts.push({
|
||||||
...account,
|
...account,
|
||||||
@@ -731,6 +885,13 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr')
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被限流
|
// 检查是否被限流
|
||||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||||
@@ -1099,6 +1260,42 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复)
|
||||||
|
async markAccountTemporarilyUnavailable(
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash = null,
|
||||||
|
ttlSeconds = 300
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||||
|
await client.setex(key, ttlSeconds, '1')
|
||||||
|
if (sessionHash) {
|
||||||
|
await this._deleteSessionMapping(sessionHash)
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户是否临时不可用
|
||||||
|
async isAccountTemporarilyUnavailable(accountId, accountType) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||||
|
return (await client.exists(key)) === 1
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🚫 标记账户为限流状态
|
// 🚫 标记账户为限流状态
|
||||||
async markAccountRateLimited(
|
async markAccountRateLimited(
|
||||||
accountId,
|
accountId,
|
||||||
@@ -1562,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||||
|
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||||
|
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||||
|
try {
|
||||||
|
// 此方法仅处理 claude-official 类型
|
||||||
|
if (accountType !== 'claude-official') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||||
|
)
|
||||||
|
return true // 非 claude-official 类型不受限制
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = account.isActive === 'true' || account.isActive === true
|
||||||
|
const { status } = account
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' || status === 'temp_error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查临时不可用
|
||||||
|
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new UnifiedClaudeScheduler()
|
module.exports = new UnifiedClaudeScheduler()
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ class UnifiedOpenAIScheduler {
|
|||||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
|
||||||
|
_sortAccountsByPriority(accounts) {
|
||||||
|
return accounts.sort((a, b) => {
|
||||||
|
const aPriority = Number.parseInt(a.priority, 10)
|
||||||
|
const bPriority = Number.parseInt(b.priority, 10)
|
||||||
|
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
|
||||||
|
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
|
||||||
|
|
||||||
|
// 首先按优先级排序(数字越小优先级越高)
|
||||||
|
if (normalizedAPriority !== normalizedBPriority) {
|
||||||
|
return normalizedAPriority - normalizedBPriority
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||||
|
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||||
|
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||||
|
return aLastUsed - bLastUsed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||||
_isSchedulable(schedulable) {
|
_isSchedulable(schedulable) {
|
||||||
// 如果是 undefined 或 null,默认为可调度
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
@@ -244,13 +264,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
if (accountType === 'openai') {
|
await this.updateAccountLastUsed(boundAccount.id, accountType)
|
||||||
await openaiAccountService.recordUsage(boundAccount.id, 0)
|
|
||||||
} else {
|
|
||||||
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
|
|
||||||
lastUsedAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
accountId: boundAccount.id,
|
accountId: boundAccount.id,
|
||||||
accountType
|
accountType
|
||||||
@@ -292,7 +306,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||||
)
|
)
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
|
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
|
||||||
return mappedAccount
|
return mappedAccount
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -321,12 +335,8 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
|
||||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
|
||||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
|
||||||
})
|
|
||||||
|
|
||||||
// 选择第一个账户
|
// 选择第一个账户
|
||||||
const selectedAccount = sortedAccounts[0]
|
const selectedAccount = sortedAccounts[0]
|
||||||
@@ -344,11 +354,11 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}`
|
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
|
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId: selectedAccount.accountId,
|
accountId: selectedAccount.accountId,
|
||||||
@@ -494,21 +504,6 @@ class UnifiedOpenAIScheduler {
|
|||||||
return availableAccounts
|
return availableAccounts
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序)
|
|
||||||
// _sortAccountsByPriority(accounts) {
|
|
||||||
// return accounts.sort((a, b) => {
|
|
||||||
// // 首先按优先级排序(数字越小优先级越高)
|
|
||||||
// if (a.priority !== b.priority) {
|
|
||||||
// return a.priority - b.priority
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
|
||||||
// const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
|
||||||
// const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
|
||||||
// return aLastUsed - bLastUsed
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 🔍 检查账户是否可用
|
// 🔍 检查账户是否可用
|
||||||
async _isAccountAvailable(accountId, accountType) {
|
async _isAccountAvailable(accountId, accountType) {
|
||||||
try {
|
try {
|
||||||
@@ -817,7 +812,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
||||||
)
|
)
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
|
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
|
||||||
return mappedAccount
|
return mappedAccount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,12 +904,8 @@ class UnifiedOpenAIScheduler {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
|
||||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
|
||||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
|
||||||
})
|
|
||||||
|
|
||||||
// 选择第一个账户
|
// 选择第一个账户
|
||||||
const selectedAccount = sortedAccounts[0]
|
const selectedAccount = sortedAccounts[0]
|
||||||
@@ -932,11 +923,11 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})`
|
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50})`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
|
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId: selectedAccount.accountId,
|
accountId: selectedAccount.accountId,
|
||||||
@@ -958,9 +949,12 @@ class UnifiedOpenAIScheduler {
|
|||||||
async updateAccountLastUsed(accountId, accountType) {
|
async updateAccountLastUsed(accountId, accountType) {
|
||||||
try {
|
try {
|
||||||
if (accountType === 'openai') {
|
if (accountType === 'openai') {
|
||||||
await openaiAccountService.updateAccount(accountId, {
|
await openaiAccountService.recordUsage(accountId, 0)
|
||||||
lastUsedAt: new Date().toISOString()
|
return
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (accountType === 'openai-responses') {
|
||||||
|
await openaiResponsesAccountService.recordUsage(accountId, 0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
|
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
|
||||||
|
|||||||
359
src/services/userMessageQueueService.js
Normal file
359
src/services/userMessageQueueService.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务
|
||||||
|
* 为 Claude 账户实现基于消息类型的串行排队机制
|
||||||
|
*
|
||||||
|
* 当请求的最后一条消息是用户输入(role: user)时,
|
||||||
|
* 同一账户的此类请求需要串行等待,并在请求之间添加延迟
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
// 清理任务间隔
|
||||||
|
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||||
|
|
||||||
|
// 轮询等待配置
|
||||||
|
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||||
|
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||||
|
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||||
|
|
||||||
|
class UserMessageQueueService {
|
||||||
|
constructor() {
|
||||||
|
this.cleanupTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测请求是否为真正的用户消息请求
|
||||||
|
* 区分真正的用户输入和 tool_result 消息
|
||||||
|
*
|
||||||
|
* Claude API 消息格式:
|
||||||
|
* - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] }
|
||||||
|
* - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] }
|
||||||
|
*
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {boolean} - 是否为真正的用户消息(排除 tool_result)
|
||||||
|
*/
|
||||||
|
isUserMessageRequest(requestBody) {
|
||||||
|
const messages = requestBody?.messages
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
|
||||||
|
// 检查 role 是否为 user
|
||||||
|
if (lastMessage?.role !== 'user') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 content 是否包含 tool_result 类型
|
||||||
|
const { content } = lastMessage
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
// 如果 content 数组中任何元素是 tool_result,则不是真正的用户消息
|
||||||
|
const hasToolResult = content.some(
|
||||||
|
(block) => block?.type === 'tool_result' || block?.type === 'tool_use_result'
|
||||||
|
)
|
||||||
|
if (hasToolResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// role 是 user 且不包含 tool_result,是真正的用户消息
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前配置(支持 Web 界面配置优先)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||||
|
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||||
|
const queueConfig = config.userMessageQueue || {}
|
||||||
|
const defaults = {
|
||||||
|
enabled: queueConfig.enabled ?? false,
|
||||||
|
delayMs: queueConfig.delayMs ?? 200,
|
||||||
|
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||||
|
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||||
|
try {
|
||||||
|
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||||
|
const webConfig = await claudeRelayConfigService.getConfig()
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled:
|
||||||
|
webConfig.userMessageQueueEnabled !== undefined
|
||||||
|
? webConfig.userMessageQueueEnabled
|
||||||
|
: defaults.enabled,
|
||||||
|
delayMs:
|
||||||
|
webConfig.userMessageQueueDelayMs !== undefined
|
||||||
|
? webConfig.userMessageQueueDelayMs
|
||||||
|
: defaults.delayMs,
|
||||||
|
timeoutMs:
|
||||||
|
webConfig.userMessageQueueTimeoutMs !== undefined
|
||||||
|
? webConfig.userMessageQueueTimeoutMs
|
||||||
|
: defaults.timeoutMs,
|
||||||
|
lockTtlMs:
|
||||||
|
webConfig.userMessageQueueLockTtlMs !== undefined
|
||||||
|
? webConfig.userMessageQueueLockTtlMs
|
||||||
|
: defaults.lockTtlMs
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回退到环境变量配置
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查功能是否启用
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.enabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户队列锁(阻塞等待)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||||
|
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||||
|
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
|
||||||
|
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置
|
||||||
|
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
// 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关
|
||||||
|
let queueEnabled = cfg.enabled
|
||||||
|
if (accountConfig && accountConfig.maxConcurrency > 0) {
|
||||||
|
queueEnabled = true
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueEnabled) {
|
||||||
|
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqId = requestId || uuidv4()
|
||||||
|
const timeout = timeoutMs || cfg.timeoutMs
|
||||||
|
const startTime = Date.now()
|
||||||
|
let retryCount = 0
|
||||||
|
|
||||||
|
logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, {
|
||||||
|
requestId: reqId,
|
||||||
|
timeoutMs: timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const result = await redis.acquireUserMessageLock(
|
||||||
|
accountId,
|
||||||
|
reqId,
|
||||||
|
cfg.lockTtlMs,
|
||||||
|
cfg.delayMs
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检测 Redis 错误,立即返回系统错误而非继续轮询
|
||||||
|
if (result.redisError) {
|
||||||
|
logger.error(`📬 User message queue: Redis error while acquiring lock`, {
|
||||||
|
accountId,
|
||||||
|
requestId: reqId,
|
||||||
|
errorMessage: result.errorMessage
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
requestId: reqId,
|
||||||
|
error: 'queue_backend_error',
|
||||||
|
errorMessage: result.errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.acquired) {
|
||||||
|
logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, {
|
||||||
|
requestId: reqId,
|
||||||
|
waitedMs: Date.now() - startTime,
|
||||||
|
retries: retryCount
|
||||||
|
})
|
||||||
|
return { acquired: true, requestId: reqId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要等待
|
||||||
|
if (result.waitMs > 0) {
|
||||||
|
// 需要延迟(上一个请求刚完成)
|
||||||
|
await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime)))
|
||||||
|
} else {
|
||||||
|
// 锁被占用,使用指数退避轮询等待
|
||||||
|
const basePollInterval = Math.min(
|
||||||
|
POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount),
|
||||||
|
POLL_INTERVAL_MAX_MS
|
||||||
|
)
|
||||||
|
// 添加 ±15% 随机抖动,避免高并发下的周期性碰撞
|
||||||
|
const jitter = basePollInterval * (0.85 + Math.random() * 0.3)
|
||||||
|
const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS)
|
||||||
|
await this._sleep(pollInterval)
|
||||||
|
retryCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
logger.warn(`📬 User message queue: timeout waiting for lock`, {
|
||||||
|
accountId,
|
||||||
|
requestId: reqId,
|
||||||
|
timeoutMs: timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
requestId: reqId,
|
||||||
|
error: 'queue_timeout'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放账户队列锁
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} requestId - 请求ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async releaseQueueLock(accountId, requestId) {
|
||||||
|
if (!accountId || !requestId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const released = await redis.releaseUserMessageLock(accountId, requestId)
|
||||||
|
|
||||||
|
if (released) {
|
||||||
|
logger.debug(`📬 User message queue: lock released for account ${accountId}`, {
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn(`📬 User message queue: failed to release lock (not owner?)`, {
|
||||||
|
accountId,
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return released
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取队列统计信息
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getQueueStats(accountId) {
|
||||||
|
return await redis.getUserMessageQueueStats(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务启动时清理所有残留的队列锁
|
||||||
|
* 防止服务重启后旧锁阻塞新请求
|
||||||
|
* @returns {Promise<number>} 清理的锁数量
|
||||||
|
*/
|
||||||
|
async cleanupStaleLocks() {
|
||||||
|
try {
|
||||||
|
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||||
|
let cleanedCount = 0
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
try {
|
||||||
|
await redis.forceReleaseUserMessageLock(accountId)
|
||||||
|
cleanedCount++
|
||||||
|
logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`📬 User message queue: failed to clean lock for account ${accountId}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedCount
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动定时清理任务
|
||||||
|
* 始终启动,每次执行时检查配置以支持运行时动态启用/禁用
|
||||||
|
*/
|
||||||
|
startCleanupTask() {
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupTimer = setInterval(async () => {
|
||||||
|
// 每次运行时检查配置,以便在运行时动态启用/禁用
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
if (!currentConfig.enabled) {
|
||||||
|
logger.debug('📬 User message queue: cleanup skipped (feature disabled)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this._cleanupOrphanLocks()
|
||||||
|
}, CLEANUP_INTERVAL_MS)
|
||||||
|
|
||||||
|
logger.info('📬 User message queue: cleanup task started')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止定时清理任务
|
||||||
|
*/
|
||||||
|
stopCleanupTask() {
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
clearInterval(this.cleanupTimer)
|
||||||
|
this.cleanupTimer = null
|
||||||
|
logger.info('📬 User message queue: cleanup task stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理孤儿锁
|
||||||
|
* 检测异常情况:锁存在但没有设置过期时间(lockTtlRaw === -1)
|
||||||
|
* 正常情况下所有锁都应该有 TTL,Redis 会自动过期
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _cleanupOrphanLocks() {
|
||||||
|
try {
|
||||||
|
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const stats = await redis.getUserMessageQueueStats(accountId)
|
||||||
|
|
||||||
|
// 检测异常情况:锁存在(isLocked=true)但没有过期时间(lockTtlRaw=-1)
|
||||||
|
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
|
||||||
|
if (stats.isLocked && stats.lockTtlRaw === -1) {
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
|
||||||
|
{ lockHolder: stats.lockHolder }
|
||||||
|
)
|
||||||
|
await redis.forceReleaseUserMessageLock(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: cleanup task error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠辅助函数
|
||||||
|
* @param {number} ms - 毫秒
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new UserMessageQueueService()
|
||||||
@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
|
|||||||
title: 'Claude Code Compact System Prompt',
|
title: 'Claude Code Compact System Prompt',
|
||||||
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
||||||
},
|
},
|
||||||
|
exploreAgentSystemPrompt: {
|
||||||
|
category: 'system',
|
||||||
|
title: 'Claude Code Explore Agent System Prompt',
|
||||||
|
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
},
|
||||||
outputStyleInsightsPrompt: {
|
outputStyleInsightsPrompt: {
|
||||||
category: 'output_style',
|
category: 'output_style',
|
||||||
title: 'Output Style Insights Addendum',
|
title: 'Output Style Insights Addendum',
|
||||||
|
|||||||
121
src/utils/headerFilter.js
Normal file
121
src/utils/headerFilter.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 统一的 CDN Headers 过滤列表
|
||||||
|
*
|
||||||
|
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
|
||||||
|
* 避免触发上游 API(如 88code)的安全检查
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Cloudflare CDN headers(橙色云代理模式会添加这些)
|
||||||
|
const cdnHeaders = [
|
||||||
|
'x-real-ip',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-forwarded-proto',
|
||||||
|
'x-forwarded-host',
|
||||||
|
'x-forwarded-port',
|
||||||
|
'x-accel-buffering',
|
||||||
|
'cf-ray',
|
||||||
|
'cf-connecting-ip',
|
||||||
|
'cf-ipcountry',
|
||||||
|
'cf-visitor',
|
||||||
|
'cf-request-id',
|
||||||
|
'cdn-loop',
|
||||||
|
'true-client-ip'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 OpenAI/Responses API 过滤 headers
|
||||||
|
* 在原有 skipHeaders 基础上添加 CDN headers
|
||||||
|
*/
|
||||||
|
function filterForOpenAI(headers) {
|
||||||
|
const skipHeaders = [
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'x-cr-api-key',
|
||||||
|
'connection',
|
||||||
|
'upgrade',
|
||||||
|
'sec-websocket-key',
|
||||||
|
'sec-websocket-version',
|
||||||
|
'sec-websocket-extensions',
|
||||||
|
...cdnHeaders // 添加 CDN headers
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 Claude/Anthropic API 过滤 headers
|
||||||
|
* 使用白名单模式,只允许指定的 headers 通过
|
||||||
|
*/
|
||||||
|
function filterForClaude(headers) {
|
||||||
|
// 白名单模式:只允许以下 headers
|
||||||
|
const allowedHeaders = [
|
||||||
|
'accept',
|
||||||
|
'x-stainless-retry-count',
|
||||||
|
'x-stainless-timeout',
|
||||||
|
'x-stainless-lang',
|
||||||
|
'x-stainless-package-version',
|
||||||
|
'x-stainless-os',
|
||||||
|
'x-stainless-arch',
|
||||||
|
'x-stainless-runtime',
|
||||||
|
'x-stainless-runtime-version',
|
||||||
|
'x-stainless-helper-method',
|
||||||
|
'anthropic-dangerous-direct-browser-access',
|
||||||
|
'anthropic-version',
|
||||||
|
'x-app',
|
||||||
|
'anthropic-beta',
|
||||||
|
'accept-language',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'accept-encoding',
|
||||||
|
'user-agent',
|
||||||
|
'content-type',
|
||||||
|
'connection'
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
Object.keys(headers || {}).forEach((key) => {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
|
filtered[key] = headers[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 Gemini API 过滤 headers(如果需要转发客户端 headers 时使用)
|
||||||
|
* 目前 Gemini 服务不转发客户端 headers,仅提供此方法备用
|
||||||
|
*/
|
||||||
|
function filterForGemini(headers) {
|
||||||
|
const skipHeaders = [
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'connection',
|
||||||
|
...cdnHeaders // 添加 CDN headers
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cdnHeaders,
|
||||||
|
filterForOpenAI,
|
||||||
|
filterForClaude,
|
||||||
|
filterForGemini
|
||||||
|
}
|
||||||
@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
|
|||||||
|
|
||||||
const logFormat = createLogFormat(false)
|
const logFormat = createLogFormat(false)
|
||||||
const consoleFormat = createLogFormat(true)
|
const consoleFormat = createLogFormat(true)
|
||||||
|
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
|
||||||
|
|
||||||
// 📁 确保日志目录存在并设置权限
|
// 📁 确保日志目录存在并设置权限
|
||||||
if (!fs.existsSync(config.logging.dirname)) {
|
if (!fs.existsSync(config.logging.dirname)) {
|
||||||
@@ -159,18 +160,20 @@ const createRotateTransport = (filename, level = null) => {
|
|||||||
transport.level = level
|
transport.level = level
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听轮转事件
|
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
|
||||||
transport.on('rotate', (oldFilename, newFilename) => {
|
if (!isTestEnv) {
|
||||||
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
transport.on('rotate', (oldFilename, newFilename) => {
|
||||||
})
|
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
||||||
|
})
|
||||||
|
|
||||||
transport.on('new', (newFilename) => {
|
transport.on('new', (newFilename) => {
|
||||||
console.log(`📄 New log file created: ${newFilename}`)
|
console.log(`📄 New log file created: ${newFilename}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
transport.on('archive', (zipFilename) => {
|
transport.on('archive', (zipFilename) => {
|
||||||
console.log(`🗜️ Log archived: ${zipFilename}`)
|
console.log(`🗜️ Log archived: ${zipFilename}`)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,119 @@ function getVendorType(modelStr) {
|
|||||||
return vendor
|
return vendor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the model is Opus 4.5 or newer.
|
||||||
|
*
|
||||||
|
* VERSION LOGIC (as of 2025-12-05):
|
||||||
|
* - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible)
|
||||||
|
* - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only)
|
||||||
|
*
|
||||||
|
* Supported naming formats:
|
||||||
|
* - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101
|
||||||
|
* - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5
|
||||||
|
* - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229
|
||||||
|
* - Special: opus-latest, claude-opus-latest → always returns true
|
||||||
|
*
|
||||||
|
* @param {string} modelName - Model name
|
||||||
|
* @returns {boolean} - Whether the model is Opus 4.5 or newer
|
||||||
|
*/
|
||||||
|
function isOpus45OrNewer(modelName) {
|
||||||
|
if (!modelName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerModel = modelName.toLowerCase()
|
||||||
|
if (!lowerModel.includes('opus')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 'latest' special case
|
||||||
|
if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format: claude-{version}-opus (version before opus)
|
||||||
|
// e.g., claude-3-opus-20240229, claude-3.5-opus
|
||||||
|
const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/)
|
||||||
|
if (oldFormatMatch) {
|
||||||
|
const majorVersion = parseInt(oldFormatMatch[1], 10)
|
||||||
|
const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0
|
||||||
|
|
||||||
|
// Old format version refers to Claude major version
|
||||||
|
// majorVersion > 4: 5.x, 6.x, ... → true
|
||||||
|
// majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true
|
||||||
|
// Others (3.x, 4.0-4.4): → false
|
||||||
|
if (majorVersion > 4) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (majorVersion === 4 && minorVersion >= 5) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format 1: opus-{major}.{minor} (dot-separated)
|
||||||
|
// e.g., claude-opus-4.5, opus-4.5
|
||||||
|
const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/)
|
||||||
|
if (dotFormatMatch) {
|
||||||
|
const majorVersion = parseInt(dotFormatMatch[1], 10)
|
||||||
|
const minorVersion = parseInt(dotFormatMatch[2], 10)
|
||||||
|
|
||||||
|
// Same version logic as old format
|
||||||
|
// opus-5.0, opus-6.0 → true
|
||||||
|
// opus-4.5, opus-4.6 → true
|
||||||
|
// opus-4.0, opus-4.4 → false
|
||||||
|
if (majorVersion > 4) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (majorVersion === 4 && minorVersion >= 5) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format 2: opus-{major}[-{minor}][-date] (hyphen-separated)
|
||||||
|
// e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805
|
||||||
|
// If opus-{major} is followed by 8-digit date, there's no minor version
|
||||||
|
|
||||||
|
// Extract content after 'opus'
|
||||||
|
const opusIndex = lowerModel.indexOf('opus')
|
||||||
|
const afterOpus = lowerModel.substring(opusIndex + 4)
|
||||||
|
|
||||||
|
// Match: -{major}-{minor}-{date} or -{major}-{date} or -{major}
|
||||||
|
// IMPORTANT: Minor version regex is (\d{1,2}) not (\d+)
|
||||||
|
// This prevents matching 8-digit dates as minor version
|
||||||
|
// Example: opus-4-20250514 → major=4, minor=undefined (not 20250514)
|
||||||
|
// Example: opus-4-5-20251101 → major=4, minor=5
|
||||||
|
// Future-proof: Supports up to 2-digit minor versions (0-99)
|
||||||
|
const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/)
|
||||||
|
|
||||||
|
if (versionMatch) {
|
||||||
|
const majorVersion = parseInt(versionMatch[1], 10)
|
||||||
|
const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0
|
||||||
|
|
||||||
|
// Same version logic: >= 4.5 returns true
|
||||||
|
// opus-5-0-date, opus-6-date → true
|
||||||
|
// opus-4-5-date, opus-4-10-date → true (supports 2-digit minor)
|
||||||
|
// opus-4-date (no minor, treated as 4.0) → false
|
||||||
|
// opus-4-1-date, opus-4-4-date → false
|
||||||
|
if (majorVersion > 4) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (majorVersion === 4 && minorVersion >= 5) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other cases containing 'opus' but cannot parse version, assume legacy
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseVendorPrefixedModel,
|
parseVendorPrefixedModel,
|
||||||
hasVendorPrefix,
|
hasVendorPrefix,
|
||||||
getEffectiveModel,
|
getEffectiveModel,
|
||||||
getVendorType
|
getVendorType,
|
||||||
|
isOpus45OrNewer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
|
|||||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权配置常量
|
||||||
|
const COOKIE_OAUTH_CONFIG = {
|
||||||
|
CLAUDE_AI_URL: 'https://claude.ai',
|
||||||
|
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
|
||||||
|
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机的 state 参数
|
* 生成随机的 state 参数
|
||||||
* @returns {string} 随机生成的 state (base64url编码)
|
* @returns {string} 随机生成的 state (base64url编码)
|
||||||
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
|
|||||||
return Object.keys(ext).length > 0 ? ext : null
|
return Object.keys(ext).length > 0 ? ext : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cookie自动授权相关方法 (基于Clove项目实现)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建带Cookie的请求头
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @returns {object} 请求头对象
|
||||||
|
*/
|
||||||
|
function buildCookieHeaders(sessionKey) {
|
||||||
|
return {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Cookie: `sessionKey=${sessionKey}`,
|
||||||
|
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
|
||||||
|
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Cookie获取组织UUID和能力列表
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
|
||||||
|
*/
|
||||||
|
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
|
||||||
|
const headers = buildCookieHeaders(sessionKey)
|
||||||
|
const agent = createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agent) {
|
||||||
|
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('🔄 Fetching organization info with Cookie', {
|
||||||
|
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
headers,
|
||||||
|
timeout: 30000,
|
||||||
|
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
axiosConfig.httpAgent = agent
|
||||||
|
axiosConfig.httpsAgent = agent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
|
||||||
|
|
||||||
|
if (!response.data || !Array.isArray(response.data)) {
|
||||||
|
throw new Error('获取组织信息失败:响应格式无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到具有chat能力且能力最多的组织
|
||||||
|
let bestOrg = null
|
||||||
|
let maxCapabilities = []
|
||||||
|
|
||||||
|
for (const org of response.data) {
|
||||||
|
const capabilities = org.capabilities || []
|
||||||
|
|
||||||
|
// 必须有chat能力
|
||||||
|
if (!capabilities.includes('chat')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择能力最多的组织
|
||||||
|
if (capabilities.length > maxCapabilities.length) {
|
||||||
|
bestOrg = org
|
||||||
|
maxCapabilities = capabilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestOrg || !bestOrg.uuid) {
|
||||||
|
throw new Error('未找到具有chat能力的组织')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('✅ Found organization', {
|
||||||
|
uuid: bestOrg.uuid,
|
||||||
|
capabilities: maxCapabilities
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationUuid: bestOrg.uuid,
|
||||||
|
capabilities: maxCapabilities
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
const { status } = error.response
|
||||||
|
|
||||||
|
if (status === 403 || status === 401) {
|
||||||
|
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 302) {
|
||||||
|
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`获取组织信息失败:HTTP ${status}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error('获取组织信息失败:网络错误或超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Cookie自动获取授权code
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {string} organizationUuid - 组织UUID
|
||||||
|
* @param {string} scope - 授权scope
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
|
||||||
|
*/
|
||||||
|
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
|
||||||
|
// 生成PKCE参数
|
||||||
|
const codeVerifier = generateCodeVerifier()
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
const state = generateState()
|
||||||
|
|
||||||
|
// 构建授权URL
|
||||||
|
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
|
||||||
|
'{organization_uuid}',
|
||||||
|
organizationUuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// 构建请求payload
|
||||||
|
const payload = {
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: OAUTH_CONFIG.CLIENT_ID,
|
||||||
|
organization_uuid: organizationUuid,
|
||||||
|
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...buildCookieHeaders(sessionKey),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agent) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('🔄 Requesting authorization with Cookie', {
|
||||||
|
url: authorizeUrl,
|
||||||
|
scope,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
headers,
|
||||||
|
timeout: 30000,
|
||||||
|
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
axiosConfig.httpAgent = agent
|
||||||
|
axiosConfig.httpsAgent = agent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(authorizeUrl, payload, axiosConfig)
|
||||||
|
|
||||||
|
// 从响应中获取redirect_uri
|
||||||
|
const redirectUri = response.data?.redirect_uri
|
||||||
|
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error('授权响应中未找到redirect_uri')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
|
||||||
|
|
||||||
|
// 解析redirect_uri获取authorization code
|
||||||
|
const url = new URL(redirectUri)
|
||||||
|
const authorizationCode = url.searchParams.get('code')
|
||||||
|
const responseState = url.searchParams.get('state')
|
||||||
|
|
||||||
|
if (!authorizationCode) {
|
||||||
|
throw new Error('redirect_uri中未找到授权码')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的授权码(包含state,如果有的话)
|
||||||
|
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
|
||||||
|
|
||||||
|
logger.success('✅ Got authorization code via Cookie', {
|
||||||
|
codeLength: authorizationCode.length,
|
||||||
|
codePrefix: `${authorizationCode.substring(0, 10)}...`
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorizationCode: fullCode,
|
||||||
|
codeVerifier,
|
||||||
|
state
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
const { status } = error.response
|
||||||
|
|
||||||
|
if (status === 403 || status === 401) {
|
||||||
|
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 302) {
|
||||||
|
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = error.response.data
|
||||||
|
let errorMessage = `HTTP ${status}`
|
||||||
|
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'string') {
|
||||||
|
errorMessage += `: ${errorData}`
|
||||||
|
} else if (errorData.error) {
|
||||||
|
errorMessage += `: ${errorData.error}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`授权请求失败:${errorMessage}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error('授权请求失败:网络错误或超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的Cookie自动授权流程
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @param {boolean} isSetupToken - 是否为Setup Token模式
|
||||||
|
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
|
||||||
|
*/
|
||||||
|
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
|
||||||
|
logger.info('🍪 Starting Cookie-based OAuth flow', {
|
||||||
|
isSetupToken,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
// 步骤1:获取组织信息
|
||||||
|
logger.debug('Step 1/3: Fetching organization info...')
|
||||||
|
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
|
||||||
|
|
||||||
|
// 步骤2:确定scope并获取授权code
|
||||||
|
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
|
||||||
|
|
||||||
|
logger.debug('Step 2/3: Getting authorization code...', { scope })
|
||||||
|
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
|
||||||
|
sessionKey,
|
||||||
|
organizationUuid,
|
||||||
|
scope,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// 步骤3:交换token
|
||||||
|
logger.debug('Step 3/3: Exchanging token...')
|
||||||
|
const tokenData = isSetupToken
|
||||||
|
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
|
||||||
|
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
|
||||||
|
|
||||||
|
logger.success('✅ Cookie-based OAuth flow completed', {
|
||||||
|
isSetupToken,
|
||||||
|
organizationUuid,
|
||||||
|
hasAccessToken: !!tokenData.accessToken,
|
||||||
|
hasRefreshToken: !!tokenData.refreshToken
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
claudeAiOauth: tokenData,
|
||||||
|
organizationUuid,
|
||||||
|
capabilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
OAUTH_CONFIG,
|
OAUTH_CONFIG,
|
||||||
|
COOKIE_OAUTH_CONFIG,
|
||||||
generateOAuthParams,
|
generateOAuthParams,
|
||||||
generateSetupTokenParams,
|
generateSetupTokenParams,
|
||||||
exchangeCodeForTokens,
|
exchangeCodeForTokens,
|
||||||
@@ -584,5 +882,10 @@ module.exports = {
|
|||||||
generateCodeChallenge,
|
generateCodeChallenge,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
generateSetupTokenAuthUrl,
|
generateSetupTokenAuthUrl,
|
||||||
createProxyAgent
|
createProxyAgent,
|
||||||
|
// Cookie自动授权相关方法
|
||||||
|
buildCookieHeaders,
|
||||||
|
getOrganizationInfo,
|
||||||
|
authorizeWithCookie,
|
||||||
|
oauthWithCookie
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/utils/statsHelper.js
Normal file
105
src/utils/statsHelper.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 统计计算工具函数
|
||||||
|
* 提供百分位数计算、等待时间统计等通用统计功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算百分位数(使用 nearest-rank 方法)
|
||||||
|
* @param {number[]} sortedArray - 已排序的数组(升序)
|
||||||
|
* @param {number} percentile - 百分位数 (0-100)
|
||||||
|
* @returns {number} 百分位值
|
||||||
|
*
|
||||||
|
* 边界情况说明:
|
||||||
|
* - percentile=0: 返回最小值 (index=0)
|
||||||
|
* - percentile=100: 返回最大值 (index=len-1)
|
||||||
|
* - percentile=50 且 len=2: 返回第一个元素(nearest-rank 向下取)
|
||||||
|
*
|
||||||
|
* 算法说明(nearest-rank 方法):
|
||||||
|
* - index = ceil(percentile / 100 * len) - 1
|
||||||
|
* - 示例:len=100, P50 → ceil(50) - 1 = 49(第50个元素,0-indexed)
|
||||||
|
* - 示例:len=100, P99 → ceil(99) - 1 = 98(第99个元素)
|
||||||
|
*/
|
||||||
|
function getPercentile(sortedArray, percentile) {
|
||||||
|
const len = sortedArray.length
|
||||||
|
if (len === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (len === 1) {
|
||||||
|
return sortedArray[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界处理:percentile <= 0 返回最小值
|
||||||
|
if (percentile <= 0) {
|
||||||
|
return sortedArray[0]
|
||||||
|
}
|
||||||
|
// 边界处理:percentile >= 100 返回最大值
|
||||||
|
if (percentile >= 100) {
|
||||||
|
return sortedArray[len - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Math.ceil((percentile / 100) * len) - 1
|
||||||
|
return sortedArray[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算等待时间分布统计
|
||||||
|
* @param {number[]} waitTimes - 等待时间数组(无需预先排序)
|
||||||
|
* @returns {Object|null} 统计对象,空数组返回 null
|
||||||
|
*
|
||||||
|
* 返回对象包含:
|
||||||
|
* - sampleCount: 样本数量(始终包含,便于调用方判断可靠性)
|
||||||
|
* - count: 样本数量(向后兼容)
|
||||||
|
* - min: 最小值
|
||||||
|
* - max: 最大值
|
||||||
|
* - avg: 平均值(四舍五入)
|
||||||
|
* - p50: 50百分位数(中位数)
|
||||||
|
* - p90: 90百分位数
|
||||||
|
* - p99: 99百分位数
|
||||||
|
* - sampleSizeWarning: 样本量不足时的警告信息(样本 < 10)
|
||||||
|
* - p90Unreliable: P90 统计不可靠标记(样本 < 10)
|
||||||
|
* - p99Unreliable: P99 统计不可靠标记(样本 < 100)
|
||||||
|
*
|
||||||
|
* 可靠性标记说明(详见 design.md Decision 6):
|
||||||
|
* - 样本 < 10: P90 和 P99 都不可靠
|
||||||
|
* - 样本 < 100: P99 不可靠(P90 需要 10 个样本,P99 需要 100 个样本)
|
||||||
|
* - 即使标记为不可靠,仍返回计算值供参考
|
||||||
|
*/
|
||||||
|
function calculateWaitTimeStats(waitTimes) {
|
||||||
|
if (!waitTimes || waitTimes.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...waitTimes].sort((a, b) => a - b)
|
||||||
|
const sum = sorted.reduce((a, b) => a + b, 0)
|
||||||
|
const len = sorted.length
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
sampleCount: len, // 新增:始终包含样本数
|
||||||
|
count: len, // 向后兼容
|
||||||
|
min: sorted[0],
|
||||||
|
max: sorted[len - 1],
|
||||||
|
avg: Math.round(sum / len),
|
||||||
|
p50: getPercentile(sorted, 50),
|
||||||
|
p90: getPercentile(sorted, 90),
|
||||||
|
p99: getPercentile(sorted, 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐进式可靠性标记(详见 design.md Decision 6)
|
||||||
|
// 样本 < 10: P90 不可靠(P90 至少需要 ceil(100/10) = 10 个样本)
|
||||||
|
if (len < 10) {
|
||||||
|
stats.sampleSizeWarning = 'Results may be inaccurate due to small sample size'
|
||||||
|
stats.p90Unreliable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样本 < 100: P99 不可靠(P99 至少需要 ceil(100/1) = 100 个样本)
|
||||||
|
if (len < 100) {
|
||||||
|
stats.p99Unreliable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPercentile,
|
||||||
|
calculateWaitTimeStats
|
||||||
|
}
|
||||||
36
src/utils/streamHelper.js
Normal file
36
src/utils/streamHelper.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Stream Helper Utilities
|
||||||
|
* 流处理辅助工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查响应流是否仍然可写(客户端连接是否有效)
|
||||||
|
* @param {import('http').ServerResponse} stream - HTTP响应流
|
||||||
|
* @returns {boolean} 如果流可写返回true,否则返回false
|
||||||
|
*/
|
||||||
|
function isStreamWritable(stream) {
|
||||||
|
if (!stream) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查流是否已销毁
|
||||||
|
if (stream.destroyed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查底层socket是否已销毁
|
||||||
|
if (stream.socket?.destroyed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查流是否已结束写入
|
||||||
|
if (stream.writableEnded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isStreamWritable
|
||||||
|
}
|
||||||
242
src/utils/testPayloadHelper.js
Normal file
242
src/utils/testPayloadHelper.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机十六进制字符串
|
||||||
|
* @param {number} bytes - 字节数
|
||||||
|
* @returns {string} 十六进制字符串
|
||||||
|
*/
|
||||||
|
function randomHex(bytes = 32) {
|
||||||
|
return crypto.randomBytes(bytes).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Claude Code 风格的会话字符串
|
||||||
|
* @returns {string} 会话字符串,格式: user_{64位hex}_account__session_{uuid}
|
||||||
|
*/
|
||||||
|
function generateSessionString() {
|
||||||
|
const hex64 = randomHex(32) // 32 bytes => 64 hex characters
|
||||||
|
const uuid = crypto.randomUUID()
|
||||||
|
return `user_${hex64}_account__session_${uuid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Claude 测试请求体
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
* @param {object} options - 可选配置
|
||||||
|
* @param {boolean} options.stream - 是否流式(默认false)
|
||||||
|
* @returns {object} 测试请求体
|
||||||
|
*/
|
||||||
|
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hi',
|
||||||
|
cache_control: {
|
||||||
|
type: 'ephemeral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||||
|
cache_control: {
|
||||||
|
type: 'ephemeral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
user_id: generateSessionString()
|
||||||
|
},
|
||||||
|
max_tokens: 21333,
|
||||||
|
temperature: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.stream) {
|
||||||
|
payload.stream = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送流式测试请求并处理SSE响应
|
||||||
|
* @param {object} options - 配置选项
|
||||||
|
* @param {string} options.apiUrl - API URL
|
||||||
|
* @param {string} options.authorization - Authorization header值
|
||||||
|
* @param {object} options.responseStream - Express响应流
|
||||||
|
* @param {object} [options.payload] - 请求体(默认使用createClaudeTestPayload)
|
||||||
|
* @param {object} [options.proxyAgent] - 代理agent
|
||||||
|
* @param {number} [options.timeout] - 超时时间(默认30000)
|
||||||
|
* @param {object} [options.extraHeaders] - 额外的请求头
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function sendStreamTestRequest(options) {
|
||||||
|
const axios = require('axios')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
const {
|
||||||
|
apiUrl,
|
||||||
|
authorization,
|
||||||
|
responseStream,
|
||||||
|
payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }),
|
||||||
|
proxyAgent = null,
|
||||||
|
timeout = 30000,
|
||||||
|
extraHeaders = {}
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const sendSSE = (type, data = {}) => {
|
||||||
|
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||||
|
try {
|
||||||
|
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTest = (success, error = null) => {
|
||||||
|
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||||
|
try {
|
||||||
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success, error: error || undefined })}\n\n`
|
||||||
|
)
|
||||||
|
responseStream.end()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSSE('test_start', { message: 'Test started' })
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: apiUrl,
|
||||||
|
data: payload,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
||||||
|
authorization,
|
||||||
|
...extraHeaders
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
requestConfig.httpAgent = proxyAgent
|
||||||
|
requestConfig.httpsAgent = proxyAgent
|
||||||
|
requestConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(requestConfig)
|
||||||
|
logger.debug(`🌊 Test response status: ${response.status}`)
|
||||||
|
|
||||||
|
// 处理非200响应
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endTest(false, errorMsg)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
endTest(false, err.message)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理成功的流式响应
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (data.type === 'content_block_delta' && data.delta?.text) {
|
||||||
|
sendSSE('content', { text: data.delta.text })
|
||||||
|
}
|
||||||
|
if (data.type === 'message_stop') {
|
||||||
|
sendSSE('message_stop')
|
||||||
|
}
|
||||||
|
if (data.type === 'error' || data.error) {
|
||||||
|
const errMsg = data.error?.message || data.message || data.error || 'Unknown error'
|
||||||
|
sendSSE('error', { error: errMsg })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||||
|
endTest(true)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
endTest(false, err.message)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Stream test request failed:', error.message)
|
||||||
|
endTest(false, error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
randomHex,
|
||||||
|
generateSessionString,
|
||||||
|
createClaudeTestPayload,
|
||||||
|
sendStreamTestRequest
|
||||||
|
}
|
||||||
81
src/utils/unstableUpstreamHelper.js
Normal file
81
src/utils/unstableUpstreamHelper.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
function parseList(envValue) {
|
||||||
|
if (!envValue) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return envValue
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES))
|
||||||
|
const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS)
|
||||||
|
const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522])
|
||||||
|
|
||||||
|
function normalizeErrorPayload(payload) {
|
||||||
|
if (!payload) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
try {
|
||||||
|
return normalizeErrorPayload(JSON.parse(payload))
|
||||||
|
} catch (e) {
|
||||||
|
return { message: payload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.error && typeof payload.error === 'object') {
|
||||||
|
return {
|
||||||
|
type: payload.error.type || payload.error.error || payload.error.code,
|
||||||
|
code: payload.error.code || payload.error.error || payload.error.type,
|
||||||
|
message: payload.error.message || payload.error.msg || payload.message || payload.error.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: payload.type || payload.code,
|
||||||
|
code: payload.code || payload.type,
|
||||||
|
message: payload.message || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnstableUpstreamError(statusCode, payload) {
|
||||||
|
const normalizedStatus = Number(statusCode)
|
||||||
|
if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, code, message } = normalizeErrorPayload(payload)
|
||||||
|
const lowerType = (type || '').toString().toLowerCase()
|
||||||
|
const lowerCode = (code || '').toString().toLowerCase()
|
||||||
|
const lowerMessage = (message || '').toString().toLowerCase()
|
||||||
|
|
||||||
|
if (lowerType === 'server_error' || lowerCode === 'server_error') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (unstableKeywords.length > 0) {
|
||||||
|
return unstableKeywords.some((kw) => lowerMessage.includes(kw))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function logUnstable(accountLabel, statusCode) {
|
||||||
|
logger.warn(
|
||||||
|
`Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isUnstableUpstreamError,
|
||||||
|
logUnstable
|
||||||
|
}
|
||||||
@@ -65,8 +65,9 @@ class ClaudeCodeValidator {
|
|||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < threshold) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||||
)
|
)
|
||||||
|
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
860
tests/concurrencyQueue.integration.test.js
Normal file
860
tests/concurrencyQueue.integration.test.js
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
/**
|
||||||
|
* 并发请求排队功能集成测试
|
||||||
|
*
|
||||||
|
* 测试分为三个层次:
|
||||||
|
* 1. Mock 测试 - 测试核心逻辑,不需要真实 Redis
|
||||||
|
* 2. Redis 方法测试 - 测试 Redis 操作的原子性和正确性
|
||||||
|
* 3. 端到端场景测试 - 测试完整的排队流程
|
||||||
|
*
|
||||||
|
* 运行方式:
|
||||||
|
* - npm test -- concurrencyQueue.integration # 运行所有测试(Mock 部分)
|
||||||
|
* - REDIS_TEST=1 npm test -- concurrencyQueue.integration # 包含真实 Redis 测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
jest.mock('../src/utils/logger', () => ({
|
||||||
|
api: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
database: jest.fn(),
|
||||||
|
security: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const redis = require('../src/models/redis')
|
||||||
|
const claudeRelayConfigService = require('../src/services/claudeRelayConfigService')
|
||||||
|
|
||||||
|
// Helper: sleep function
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// Helper: 创建模拟的 req/res 对象
|
||||||
|
function createMockReqRes() {
|
||||||
|
const listeners = {}
|
||||||
|
const req = {
|
||||||
|
destroyed: false,
|
||||||
|
once: jest.fn((event, handler) => {
|
||||||
|
listeners[`req:${event}`] = handler
|
||||||
|
}),
|
||||||
|
removeListener: jest.fn((event) => {
|
||||||
|
delete listeners[`req:${event}`]
|
||||||
|
}),
|
||||||
|
// 触发事件的辅助方法
|
||||||
|
emit: (event) => {
|
||||||
|
const handler = listeners[`req:${event}`]
|
||||||
|
if (handler) {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
once: jest.fn((event, handler) => {
|
||||||
|
listeners[`res:${event}`] = handler
|
||||||
|
}),
|
||||||
|
removeListener: jest.fn((event) => {
|
||||||
|
delete listeners[`res:${event}`]
|
||||||
|
}),
|
||||||
|
emit: (event) => {
|
||||||
|
const handler = listeners[`res:${event}`]
|
||||||
|
if (handler) {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { req, res, listeners }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第一部分:Mock 测试 - waitForConcurrencySlot 核心逻辑
|
||||||
|
// ============================================
|
||||||
|
describe('ConcurrencyQueue Integration Tests', () => {
|
||||||
|
describe('Part 1: waitForConcurrencySlot Logic (Mocked)', () => {
|
||||||
|
// 导入 auth 模块中的 waitForConcurrencySlot
|
||||||
|
// 由于它是内部函数,我们需要通过测试其行为来验证
|
||||||
|
// 这里我们模拟整个流程
|
||||||
|
|
||||||
|
let mockRedis
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
// 创建 Redis mock
|
||||||
|
mockRedis = {
|
||||||
|
concurrencyCount: {},
|
||||||
|
queueCount: {},
|
||||||
|
stats: {},
|
||||||
|
waitTimes: {},
|
||||||
|
globalWaitTimes: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Redis 并发方法
|
||||||
|
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (keyId, requestId, _lease) => {
|
||||||
|
if (!mockRedis.concurrencyCount[keyId]) {
|
||||||
|
mockRedis.concurrencyCount[keyId] = new Set()
|
||||||
|
}
|
||||||
|
mockRedis.concurrencyCount[keyId].add(requestId)
|
||||||
|
return mockRedis.concurrencyCount[keyId].size
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (keyId, requestId) => {
|
||||||
|
if (mockRedis.concurrencyCount[keyId]) {
|
||||||
|
mockRedis.concurrencyCount[keyId].delete(requestId)
|
||||||
|
return mockRedis.concurrencyCount[keyId].size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock 排队计数方法
|
||||||
|
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async (keyId) => {
|
||||||
|
mockRedis.queueCount[keyId] = (mockRedis.queueCount[keyId] || 0) + 1
|
||||||
|
return mockRedis.queueCount[keyId]
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async (keyId) => {
|
||||||
|
mockRedis.queueCount[keyId] = Math.max(0, (mockRedis.queueCount[keyId] || 0) - 1)
|
||||||
|
return mockRedis.queueCount[keyId]
|
||||||
|
})
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'getConcurrencyQueueCount')
|
||||||
|
.mockImplementation(async (keyId) => mockRedis.queueCount[keyId] || 0)
|
||||||
|
|
||||||
|
// Mock 统计方法
|
||||||
|
jest.spyOn(redis, 'incrConcurrencyQueueStats').mockImplementation(async (keyId, field) => {
|
||||||
|
if (!mockRedis.stats[keyId]) {
|
||||||
|
mockRedis.stats[keyId] = {}
|
||||||
|
}
|
||||||
|
mockRedis.stats[keyId][field] = (mockRedis.stats[keyId][field] || 0) + 1
|
||||||
|
return mockRedis.stats[keyId][field]
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'recordQueueWaitTime').mockResolvedValue(undefined)
|
||||||
|
jest.spyOn(redis, 'recordGlobalQueueWaitTime').mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Slot Acquisition Flow', () => {
|
||||||
|
it('should acquire slot immediately when under concurrency limit', async () => {
|
||||||
|
// 模拟 waitForConcurrencySlot 的行为
|
||||||
|
const keyId = 'test-key-1'
|
||||||
|
const requestId = 'req-1'
|
||||||
|
const concurrencyLimit = 5
|
||||||
|
|
||||||
|
// 直接测试 incrConcurrency 的行为
|
||||||
|
const count = await redis.incrConcurrency(keyId, requestId, 300)
|
||||||
|
|
||||||
|
expect(count).toBe(1)
|
||||||
|
expect(count).toBeLessThanOrEqual(concurrencyLimit)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track multiple concurrent requests correctly', async () => {
|
||||||
|
const keyId = 'test-key-2'
|
||||||
|
const concurrencyLimit = 3
|
||||||
|
|
||||||
|
// 模拟多个并发请求
|
||||||
|
const results = []
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
|
||||||
|
results.push({ requestId: `req-${i}`, count, exceeds: count > concurrencyLimit })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前3个应该在限制内
|
||||||
|
expect(results[0].exceeds).toBe(false)
|
||||||
|
expect(results[1].exceeds).toBe(false)
|
||||||
|
expect(results[2].exceeds).toBe(false)
|
||||||
|
// 后2个超过限制
|
||||||
|
expect(results[3].exceeds).toBe(true)
|
||||||
|
expect(results[4].exceeds).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release slot and allow next request', async () => {
|
||||||
|
const keyId = 'test-key-3'
|
||||||
|
const concurrencyLimit = 1
|
||||||
|
|
||||||
|
// 第一个请求获取槽位
|
||||||
|
const count1 = await redis.incrConcurrency(keyId, 'req-1', 300)
|
||||||
|
expect(count1).toBe(1)
|
||||||
|
|
||||||
|
// 第二个请求超限
|
||||||
|
const count2 = await redis.incrConcurrency(keyId, 'req-2', 300)
|
||||||
|
expect(count2).toBe(2)
|
||||||
|
expect(count2).toBeGreaterThan(concurrencyLimit)
|
||||||
|
|
||||||
|
// 释放第二个请求(因为超限)
|
||||||
|
await redis.decrConcurrency(keyId, 'req-2')
|
||||||
|
|
||||||
|
// 释放第一个请求
|
||||||
|
await redis.decrConcurrency(keyId, 'req-1')
|
||||||
|
|
||||||
|
// 现在第三个请求应该能获取
|
||||||
|
const count3 = await redis.incrConcurrency(keyId, 'req-3', 300)
|
||||||
|
expect(count3).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Queue Count Management', () => {
|
||||||
|
it('should increment and decrement queue count atomically', async () => {
|
||||||
|
const keyId = 'test-key-4'
|
||||||
|
|
||||||
|
// 增加排队计数
|
||||||
|
const count1 = await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
expect(count1).toBe(1)
|
||||||
|
|
||||||
|
const count2 = await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
expect(count2).toBe(2)
|
||||||
|
|
||||||
|
// 减少排队计数
|
||||||
|
const count3 = await redis.decrConcurrencyQueue(keyId)
|
||||||
|
expect(count3).toBe(1)
|
||||||
|
|
||||||
|
const count4 = await redis.decrConcurrencyQueue(keyId)
|
||||||
|
expect(count4).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not go below zero on decrement', async () => {
|
||||||
|
const keyId = 'test-key-5'
|
||||||
|
|
||||||
|
// 直接减少(没有先增加)
|
||||||
|
const count = await redis.decrConcurrencyQueue(keyId)
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle concurrent queue operations', async () => {
|
||||||
|
const keyId = 'test-key-6'
|
||||||
|
|
||||||
|
// 并发增加
|
||||||
|
const increments = await Promise.all([
|
||||||
|
redis.incrConcurrencyQueue(keyId, 60000),
|
||||||
|
redis.incrConcurrencyQueue(keyId, 60000),
|
||||||
|
redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
])
|
||||||
|
|
||||||
|
// 所有增量应该是连续的
|
||||||
|
const sortedIncrements = [...increments].sort((a, b) => a - b)
|
||||||
|
expect(sortedIncrements).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Statistics Tracking', () => {
|
||||||
|
it('should track entered/success/timeout/cancelled stats', async () => {
|
||||||
|
const keyId = 'test-key-7'
|
||||||
|
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'entered')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'entered')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'success')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'cancelled')
|
||||||
|
|
||||||
|
expect(mockRedis.stats[keyId]).toEqual({
|
||||||
|
entered: 2,
|
||||||
|
success: 1,
|
||||||
|
timeout: 1,
|
||||||
|
cancelled: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Client Disconnection Handling', () => {
|
||||||
|
it('should detect client disconnection via close event', async () => {
|
||||||
|
const { req } = createMockReqRes()
|
||||||
|
|
||||||
|
let clientDisconnected = false
|
||||||
|
|
||||||
|
// 设置监听器
|
||||||
|
req.once('close', () => {
|
||||||
|
clientDisconnected = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模拟客户端断开
|
||||||
|
req.emit('close')
|
||||||
|
|
||||||
|
expect(clientDisconnected).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect pre-destroyed request', () => {
|
||||||
|
const { req } = createMockReqRes()
|
||||||
|
req.destroyed = true
|
||||||
|
|
||||||
|
expect(req.destroyed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exponential Backoff Simulation', () => {
|
||||||
|
it('should increase poll interval with backoff', () => {
|
||||||
|
const config = {
|
||||||
|
pollIntervalMs: 200,
|
||||||
|
maxPollIntervalMs: 2000,
|
||||||
|
backoffFactor: 1.5,
|
||||||
|
jitterRatio: 0 // 禁用抖动以便测试
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = config.pollIntervalMs
|
||||||
|
const intervals = [interval]
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
|
||||||
|
intervals.push(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证指数增长
|
||||||
|
expect(intervals[1]).toBe(300) // 200 * 1.5
|
||||||
|
expect(intervals[2]).toBe(450) // 300 * 1.5
|
||||||
|
expect(intervals[3]).toBe(675) // 450 * 1.5
|
||||||
|
expect(intervals[4]).toBe(1012.5) // 675 * 1.5
|
||||||
|
expect(intervals[5]).toBe(1518.75) // 1012.5 * 1.5
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cap interval at maximum', () => {
|
||||||
|
const config = {
|
||||||
|
pollIntervalMs: 1000,
|
||||||
|
maxPollIntervalMs: 2000,
|
||||||
|
backoffFactor: 1.5
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = config.pollIntervalMs
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(interval).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply jitter within expected range', () => {
|
||||||
|
const baseInterval = 1000
|
||||||
|
const jitterRatio = 0.2 // ±20%
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const randomValue = Math.random()
|
||||||
|
const jitter = baseInterval * jitterRatio * (randomValue * 2 - 1)
|
||||||
|
const finalInterval = baseInterval + jitter
|
||||||
|
results.push(finalInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...results)
|
||||||
|
const max = Math.max(...results)
|
||||||
|
|
||||||
|
// 所有结果应该在 [800, 1200] 范围内
|
||||||
|
expect(min).toBeGreaterThanOrEqual(800)
|
||||||
|
expect(max).toBeLessThanOrEqual(1200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第二部分:并发竞争场景测试
|
||||||
|
// ============================================
|
||||||
|
describe('Part 2: Concurrent Race Condition Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Race Condition: Multiple Requests Competing for Same Slot', () => {
|
||||||
|
it('should handle race condition when multiple requests try to acquire last slot', async () => {
|
||||||
|
const keyId = 'race-test-1'
|
||||||
|
const concurrencyLimit = 1
|
||||||
|
const concurrencyState = { count: 0, holders: new Set() }
|
||||||
|
|
||||||
|
// 模拟原子的 incrConcurrency
|
||||||
|
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (key, reqId) => {
|
||||||
|
// 模拟原子操作
|
||||||
|
concurrencyState.count++
|
||||||
|
concurrencyState.holders.add(reqId)
|
||||||
|
return concurrencyState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (key, reqId) => {
|
||||||
|
if (concurrencyState.holders.has(reqId)) {
|
||||||
|
concurrencyState.count--
|
||||||
|
concurrencyState.holders.delete(reqId)
|
||||||
|
}
|
||||||
|
return concurrencyState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5个请求同时竞争1个槽位
|
||||||
|
const requests = Array.from({ length: 5 }, (_, i) => `req-${i + 1}`)
|
||||||
|
|
||||||
|
const acquireResults = await Promise.all(
|
||||||
|
requests.map(async (reqId) => {
|
||||||
|
const count = await redis.incrConcurrency(keyId, reqId, 300)
|
||||||
|
const acquired = count <= concurrencyLimit
|
||||||
|
|
||||||
|
if (!acquired) {
|
||||||
|
// 超限,释放
|
||||||
|
await redis.decrConcurrency(keyId, reqId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reqId, count, acquired }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 只有一个请求应该成功获取槽位
|
||||||
|
const successfulAcquires = acquireResults.filter((r) => r.acquired)
|
||||||
|
expect(successfulAcquires.length).toBe(1)
|
||||||
|
|
||||||
|
// 最终并发计数应该是1
|
||||||
|
expect(concurrencyState.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain consistency under high contention', async () => {
|
||||||
|
const keyId = 'race-test-2'
|
||||||
|
const concurrencyLimit = 3
|
||||||
|
const requestCount = 20
|
||||||
|
const concurrencyState = { count: 0, maxSeen: 0 }
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async () => {
|
||||||
|
concurrencyState.count++
|
||||||
|
concurrencyState.maxSeen = Math.max(concurrencyState.maxSeen, concurrencyState.count)
|
||||||
|
return concurrencyState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async () => {
|
||||||
|
concurrencyState.count = Math.max(0, concurrencyState.count - 1)
|
||||||
|
return concurrencyState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模拟多轮请求
|
||||||
|
const activeRequests = []
|
||||||
|
|
||||||
|
for (let i = 0; i < requestCount; i++) {
|
||||||
|
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
|
||||||
|
|
||||||
|
if (count <= concurrencyLimit) {
|
||||||
|
activeRequests.push(`req-${i}`)
|
||||||
|
|
||||||
|
// 模拟处理时间后释放
|
||||||
|
setTimeout(async () => {
|
||||||
|
await redis.decrConcurrency(keyId, `req-${i}`)
|
||||||
|
}, Math.random() * 50)
|
||||||
|
} else {
|
||||||
|
await redis.decrConcurrency(keyId, `req-${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机延迟
|
||||||
|
await sleep(Math.random() * 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有请求完成
|
||||||
|
await sleep(100)
|
||||||
|
|
||||||
|
// 最大并发不应超过限制
|
||||||
|
expect(concurrencyState.maxSeen).toBeLessThanOrEqual(concurrencyLimit + requestCount) // 允许短暂超限
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Queue Overflow Protection', () => {
|
||||||
|
it('should reject requests when queue is full', async () => {
|
||||||
|
const keyId = 'overflow-test-1'
|
||||||
|
const maxQueueSize = 5
|
||||||
|
const queueState = { count: 0 }
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async () => {
|
||||||
|
queueState.count++
|
||||||
|
return queueState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async () => {
|
||||||
|
queueState.count = Math.max(0, queueState.count - 1)
|
||||||
|
return queueState.count
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
// 尝试10个请求进入队列
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const queueCount = await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
|
||||||
|
if (queueCount > maxQueueSize) {
|
||||||
|
// 队列满,释放并拒绝
|
||||||
|
await redis.decrConcurrencyQueue(keyId)
|
||||||
|
results.push({ index: i, accepted: false })
|
||||||
|
} else {
|
||||||
|
results.push({ index: i, accepted: true, position: queueCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepted = results.filter((r) => r.accepted)
|
||||||
|
const rejected = results.filter((r) => !r.accepted)
|
||||||
|
|
||||||
|
expect(accepted.length).toBe(5)
|
||||||
|
expect(rejected.length).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第三部分:真实 Redis 集成测试(可选)
|
||||||
|
// ============================================
|
||||||
|
describe('Part 3: Real Redis Integration Tests', () => {
|
||||||
|
const skipRealRedis = !process.env.REDIS_TEST
|
||||||
|
|
||||||
|
// 辅助函数:检查 Redis 连接
|
||||||
|
async function checkRedisConnection() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await client.ping()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (skipRealRedis) {
|
||||||
|
console.log('⏭️ Skipping real Redis tests (set REDIS_TEST=1 to enable)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await checkRedisConnection()
|
||||||
|
if (!connected) {
|
||||||
|
console.log('⚠️ Redis not connected, skipping real Redis tests')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理测试数据
|
||||||
|
afterEach(async () => {
|
||||||
|
if (skipRealRedis) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理测试键
|
||||||
|
const testKeys = await client.keys('concurrency:queue:test-*')
|
||||||
|
if (testKeys.length > 0) {
|
||||||
|
await client.del(...testKeys)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略清理错误
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Redis Queue Operations', () => {
|
||||||
|
const testOrSkip = skipRealRedis ? it.skip : it
|
||||||
|
|
||||||
|
testOrSkip('should atomically increment queue count with TTL', async () => {
|
||||||
|
const keyId = 'test-redis-queue-1'
|
||||||
|
const timeoutMs = 5000
|
||||||
|
|
||||||
|
const count1 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
|
||||||
|
expect(count1).toBe(1)
|
||||||
|
|
||||||
|
const count2 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
|
||||||
|
expect(count2).toBe(2)
|
||||||
|
|
||||||
|
// 验证 TTL 被设置
|
||||||
|
const client = redis.getClient()
|
||||||
|
const ttl = await client.ttl(`concurrency:queue:${keyId}`)
|
||||||
|
expect(ttl).toBeGreaterThan(0)
|
||||||
|
expect(ttl).toBeLessThanOrEqual(Math.ceil(timeoutMs / 1000) + 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
testOrSkip('should atomically decrement and delete when zero', async () => {
|
||||||
|
const keyId = 'test-redis-queue-2'
|
||||||
|
|
||||||
|
await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
const count = await redis.decrConcurrencyQueue(keyId)
|
||||||
|
|
||||||
|
expect(count).toBe(0)
|
||||||
|
|
||||||
|
// 验证键已删除
|
||||||
|
const client = redis.getClient()
|
||||||
|
const exists = await client.exists(`concurrency:queue:${keyId}`)
|
||||||
|
expect(exists).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
testOrSkip('should handle concurrent increments correctly', async () => {
|
||||||
|
const keyId = 'test-redis-queue-3'
|
||||||
|
const numRequests = 10
|
||||||
|
|
||||||
|
// 并发增加
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: numRequests }, () => redis.incrConcurrencyQueue(keyId, 60000))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 所有结果应该是 1 到 numRequests
|
||||||
|
const sorted = [...results].sort((a, b) => a - b)
|
||||||
|
expect(sorted).toEqual(Array.from({ length: numRequests }, (_, i) => i + 1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Redis Stats Operations', () => {
|
||||||
|
const testOrSkip = skipRealRedis ? it.skip : it
|
||||||
|
|
||||||
|
testOrSkip('should track queue statistics correctly', async () => {
|
||||||
|
const keyId = 'test-redis-stats-1'
|
||||||
|
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'entered')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'entered')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'success')
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
|
||||||
|
|
||||||
|
const stats = await redis.getConcurrencyQueueStats(keyId)
|
||||||
|
|
||||||
|
expect(stats.entered).toBe(2)
|
||||||
|
expect(stats.success).toBe(1)
|
||||||
|
expect(stats.timeout).toBe(1)
|
||||||
|
expect(stats.cancelled).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
testOrSkip('should record and retrieve wait times', async () => {
|
||||||
|
const keyId = 'test-redis-wait-1'
|
||||||
|
const waitTimes = [100, 200, 150, 300, 250]
|
||||||
|
|
||||||
|
for (const wt of waitTimes) {
|
||||||
|
await redis.recordQueueWaitTime(keyId, wt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorded = await redis.getQueueWaitTimes(keyId)
|
||||||
|
|
||||||
|
// 应该按 LIFO 顺序存储
|
||||||
|
expect(recorded.length).toBe(5)
|
||||||
|
expect(recorded[0]).toBe(250) // 最后插入的在前面
|
||||||
|
})
|
||||||
|
|
||||||
|
testOrSkip('should record global wait times', async () => {
|
||||||
|
const waitTimes = [500, 600, 700]
|
||||||
|
|
||||||
|
for (const wt of waitTimes) {
|
||||||
|
await redis.recordGlobalQueueWaitTime(wt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorded = await redis.getGlobalQueueWaitTimes()
|
||||||
|
|
||||||
|
expect(recorded.length).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Redis Cleanup Operations', () => {
|
||||||
|
const testOrSkip = skipRealRedis ? it.skip : it
|
||||||
|
|
||||||
|
testOrSkip('should clear specific queue', async () => {
|
||||||
|
const keyId = 'test-redis-clear-1'
|
||||||
|
|
||||||
|
await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
await redis.incrConcurrencyQueue(keyId, 60000)
|
||||||
|
|
||||||
|
const cleared = await redis.clearConcurrencyQueue(keyId)
|
||||||
|
expect(cleared).toBe(true)
|
||||||
|
|
||||||
|
const count = await redis.getConcurrencyQueueCount(keyId)
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
testOrSkip('should clear all queues but preserve stats', async () => {
|
||||||
|
const keyId1 = 'test-redis-clearall-1'
|
||||||
|
const keyId2 = 'test-redis-clearall-2'
|
||||||
|
|
||||||
|
// 创建队列和统计
|
||||||
|
await redis.incrConcurrencyQueue(keyId1, 60000)
|
||||||
|
await redis.incrConcurrencyQueue(keyId2, 60000)
|
||||||
|
await redis.incrConcurrencyQueueStats(keyId1, 'entered')
|
||||||
|
|
||||||
|
// 清理所有队列
|
||||||
|
const cleared = await redis.clearAllConcurrencyQueues()
|
||||||
|
expect(cleared).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
// 验证队列已清理
|
||||||
|
const count1 = await redis.getConcurrencyQueueCount(keyId1)
|
||||||
|
const count2 = await redis.getConcurrencyQueueCount(keyId2)
|
||||||
|
expect(count1).toBe(0)
|
||||||
|
expect(count2).toBe(0)
|
||||||
|
|
||||||
|
// 统计应该保留
|
||||||
|
const stats = await redis.getConcurrencyQueueStats(keyId1)
|
||||||
|
expect(stats.entered).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第四部分:配置服务集成测试
|
||||||
|
// ============================================
|
||||||
|
describe('Part 4: Configuration Service Integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 清除配置缓存
|
||||||
|
claudeRelayConfigService.clearCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Queue Configuration', () => {
|
||||||
|
it('should return default queue configuration', async () => {
|
||||||
|
jest.spyOn(redis, 'getClient').mockReturnValue(null)
|
||||||
|
|
||||||
|
const config = await claudeRelayConfigService.getConfig()
|
||||||
|
|
||||||
|
expect(config.concurrentRequestQueueEnabled).toBe(false)
|
||||||
|
expect(config.concurrentRequestQueueMaxSize).toBe(3)
|
||||||
|
expect(config.concurrentRequestQueueMaxSizeMultiplier).toBe(0)
|
||||||
|
expect(config.concurrentRequestQueueTimeoutMs).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate max queue size correctly', async () => {
|
||||||
|
const testCases = [
|
||||||
|
{ concurrencyLimit: 5, multiplier: 2, fixedMin: 3, expected: 10 }, // 5*2=10 > 3
|
||||||
|
{ concurrencyLimit: 1, multiplier: 1, fixedMin: 5, expected: 5 }, // 1*1=1 < 5
|
||||||
|
{ concurrencyLimit: 10, multiplier: 0.5, fixedMin: 3, expected: 5 }, // 10*0.5=5 > 3
|
||||||
|
{ concurrencyLimit: 2, multiplier: 1, fixedMin: 10, expected: 10 } // 2*1=2 < 10
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tc of testCases) {
|
||||||
|
const maxQueueSize = Math.max(tc.concurrencyLimit * tc.multiplier, tc.fixedMin)
|
||||||
|
expect(maxQueueSize).toBe(tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第五部分:端到端场景测试
|
||||||
|
// ============================================
|
||||||
|
describe('Part 5: End-to-End Scenario Tests', () => {
|
||||||
|
describe('Scenario: Claude Code Agent Parallel Tool Calls', () => {
|
||||||
|
it('should handle burst of parallel tool results', async () => {
|
||||||
|
// 模拟 Claude Code Agent 发送多个并行工具结果的场景
|
||||||
|
const concurrencyLimit = 2
|
||||||
|
const maxQueueSize = 5
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
concurrency: 0,
|
||||||
|
queue: 0,
|
||||||
|
completed: 0,
|
||||||
|
rejected: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟 8 个并行工具结果请求
|
||||||
|
const requests = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
id: `tool-result-${i + 1}`,
|
||||||
|
startTime: Date.now()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 模拟处理逻辑
|
||||||
|
async function processRequest(req) {
|
||||||
|
// 尝试获取并发槽位
|
||||||
|
state.concurrency++
|
||||||
|
|
||||||
|
if (state.concurrency > concurrencyLimit) {
|
||||||
|
// 超限,进入队列
|
||||||
|
state.concurrency--
|
||||||
|
state.queue++
|
||||||
|
|
||||||
|
if (state.queue > maxQueueSize) {
|
||||||
|
// 队列满,拒绝
|
||||||
|
state.queue--
|
||||||
|
state.rejected++
|
||||||
|
return { ...req, status: 'rejected', reason: 'queue_full' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待槽位(模拟)
|
||||||
|
await sleep(Math.random() * 100)
|
||||||
|
state.queue--
|
||||||
|
state.concurrency++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理请求
|
||||||
|
await sleep(50) // 模拟处理时间
|
||||||
|
state.concurrency--
|
||||||
|
state.completed++
|
||||||
|
|
||||||
|
return { ...req, status: 'completed', duration: Date.now() - req.startTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(requests.map(processRequest))
|
||||||
|
|
||||||
|
const completed = results.filter((r) => r.status === 'completed')
|
||||||
|
const rejected = results.filter((r) => r.status === 'rejected')
|
||||||
|
|
||||||
|
// 大部分请求应该完成
|
||||||
|
expect(completed.length).toBeGreaterThan(0)
|
||||||
|
// 可能有一些被拒绝
|
||||||
|
expect(state.rejected).toBe(rejected.length)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ✓ Completed: ${completed.length}, Rejected: ${rejected.length}, Max concurrent: ${concurrencyLimit}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Scenario: Graceful Degradation', () => {
|
||||||
|
it('should fallback when Redis fails', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'incrConcurrencyQueue')
|
||||||
|
.mockRejectedValue(new Error('Redis connection lost'))
|
||||||
|
|
||||||
|
// 模拟降级行为:Redis 失败时直接拒绝而不是崩溃
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
await redis.incrConcurrencyQueue('fallback-test', 60000)
|
||||||
|
result = { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
// 优雅降级:返回 429 而不是 500
|
||||||
|
result = { success: false, fallback: true, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.fallback).toBe(true)
|
||||||
|
expect(result.error).toContain('Redis')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Scenario: Timeout Behavior', () => {
|
||||||
|
it('should respect queue timeout', async () => {
|
||||||
|
const timeoutMs = 100
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// 模拟等待超时
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10) // 允许 10ms 误差
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track timeout statistics', async () => {
|
||||||
|
const stats = { entered: 0, success: 0, timeout: 0, cancelled: 0 }
|
||||||
|
|
||||||
|
// 模拟多个请求,部分超时
|
||||||
|
const requests = [
|
||||||
|
{ id: 'req-1', willTimeout: false },
|
||||||
|
{ id: 'req-2', willTimeout: true },
|
||||||
|
{ id: 'req-3', willTimeout: false },
|
||||||
|
{ id: 'req-4', willTimeout: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const req of requests) {
|
||||||
|
stats.entered++
|
||||||
|
if (req.willTimeout) {
|
||||||
|
stats.timeout++
|
||||||
|
} else {
|
||||||
|
stats.success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(stats.entered).toBe(4)
|
||||||
|
expect(stats.success).toBe(2)
|
||||||
|
expect(stats.timeout).toBe(2)
|
||||||
|
|
||||||
|
// 成功率应该是 50%
|
||||||
|
const successRate = (stats.success / stats.entered) * 100
|
||||||
|
expect(successRate).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
278
tests/concurrencyQueue.test.js
Normal file
278
tests/concurrencyQueue.test.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* 并发请求排队功能测试
|
||||||
|
* 测试排队逻辑中的核心算法:百分位数计算、等待时间统计、指数退避等
|
||||||
|
*
|
||||||
|
* 注意:Redis 方法的测试需要集成测试环境,这里主要测试纯算法逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
jest.mock('../src/utils/logger', () => ({
|
||||||
|
api: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
database: jest.fn(),
|
||||||
|
security: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 使用共享的统计工具函数(与生产代码一致)
|
||||||
|
const { getPercentile, calculateWaitTimeStats } = require('../src/utils/statsHelper')
|
||||||
|
|
||||||
|
describe('ConcurrencyQueue', () => {
|
||||||
|
describe('Percentile Calculation (nearest-rank method)', () => {
|
||||||
|
// 直接测试共享工具函数,确保与生产代码行为一致
|
||||||
|
it('should return 0 for empty array', () => {
|
||||||
|
expect(getPercentile([], 50)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return single element for single-element array', () => {
|
||||||
|
expect(getPercentile([100], 50)).toBe(100)
|
||||||
|
expect(getPercentile([100], 99)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return min for percentile 0', () => {
|
||||||
|
expect(getPercentile([10, 20, 30, 40, 50], 0)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return max for percentile 100', () => {
|
||||||
|
expect(getPercentile([10, 20, 30, 40, 50], 100)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate P50 correctly for len=10', () => {
|
||||||
|
// For [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] (len=10)
|
||||||
|
// P50: ceil(50/100 * 10) - 1 = ceil(5) - 1 = 4 → value at index 4 = 50
|
||||||
|
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
||||||
|
expect(getPercentile(arr, 50)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate P90 correctly for len=10', () => {
|
||||||
|
// For len=10, P90: ceil(90/100 * 10) - 1 = ceil(9) - 1 = 8 → value at index 8 = 90
|
||||||
|
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
||||||
|
expect(getPercentile(arr, 90)).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate P99 correctly for len=100', () => {
|
||||||
|
// For len=100, P99: ceil(99/100 * 100) - 1 = ceil(99) - 1 = 98
|
||||||
|
const arr = Array.from({ length: 100 }, (_, i) => i + 1)
|
||||||
|
expect(getPercentile(arr, 99)).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle two-element array correctly', () => {
|
||||||
|
// For [10, 20] (len=2)
|
||||||
|
// P50: ceil(50/100 * 2) - 1 = ceil(1) - 1 = 0 → value = 10
|
||||||
|
expect(getPercentile([10, 20], 50)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle negative percentile as 0', () => {
|
||||||
|
expect(getPercentile([10, 20, 30], -10)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle percentile > 100 as 100', () => {
|
||||||
|
expect(getPercentile([10, 20, 30], 150)).toBe(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Wait Time Stats Calculation', () => {
|
||||||
|
// 直接测试共享工具函数
|
||||||
|
it('should return null for empty array', () => {
|
||||||
|
expect(calculateWaitTimeStats([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for null input', () => {
|
||||||
|
expect(calculateWaitTimeStats(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for undefined input', () => {
|
||||||
|
expect(calculateWaitTimeStats(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate stats correctly for typical data', () => {
|
||||||
|
const waitTimes = [100, 200, 150, 300, 250, 180, 220, 280, 190, 210]
|
||||||
|
const stats = calculateWaitTimeStats(waitTimes)
|
||||||
|
|
||||||
|
expect(stats.count).toBe(10)
|
||||||
|
expect(stats.min).toBe(100)
|
||||||
|
expect(stats.max).toBe(300)
|
||||||
|
// Sum: 100+150+180+190+200+210+220+250+280+300 = 2080
|
||||||
|
expect(stats.avg).toBe(208)
|
||||||
|
expect(stats.sampleSizeWarning).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add warning for small sample size (< 10)', () => {
|
||||||
|
const waitTimes = [100, 200, 300]
|
||||||
|
const stats = calculateWaitTimeStats(waitTimes)
|
||||||
|
|
||||||
|
expect(stats.count).toBe(3)
|
||||||
|
expect(stats.sampleSizeWarning).toBe('Results may be inaccurate due to small sample size')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single value', () => {
|
||||||
|
const stats = calculateWaitTimeStats([500])
|
||||||
|
|
||||||
|
expect(stats.count).toBe(1)
|
||||||
|
expect(stats.min).toBe(500)
|
||||||
|
expect(stats.max).toBe(500)
|
||||||
|
expect(stats.avg).toBe(500)
|
||||||
|
expect(stats.p50).toBe(500)
|
||||||
|
expect(stats.p90).toBe(500)
|
||||||
|
expect(stats.p99).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort input array before calculating', () => {
|
||||||
|
const waitTimes = [500, 100, 300, 200, 400]
|
||||||
|
const stats = calculateWaitTimeStats(waitTimes)
|
||||||
|
|
||||||
|
expect(stats.min).toBe(100)
|
||||||
|
expect(stats.max).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not modify original array', () => {
|
||||||
|
const waitTimes = [500, 100, 300]
|
||||||
|
calculateWaitTimeStats(waitTimes)
|
||||||
|
|
||||||
|
expect(waitTimes).toEqual([500, 100, 300])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exponential Backoff with Jitter', () => {
|
||||||
|
/**
|
||||||
|
* 指数退避计算函数(与 auth.js 中的实现一致)
|
||||||
|
* @param {number} currentInterval - 当前轮询间隔
|
||||||
|
* @param {number} backoffFactor - 退避系数
|
||||||
|
* @param {number} jitterRatio - 抖动比例
|
||||||
|
* @param {number} maxInterval - 最大间隔
|
||||||
|
* @param {number} randomValue - 随机值 [0, 1),用于确定性测试
|
||||||
|
*/
|
||||||
|
function calculateNextInterval(
|
||||||
|
currentInterval,
|
||||||
|
backoffFactor,
|
||||||
|
jitterRatio,
|
||||||
|
maxInterval,
|
||||||
|
randomValue
|
||||||
|
) {
|
||||||
|
let nextInterval = currentInterval * backoffFactor
|
||||||
|
// 抖动范围:[-jitterRatio, +jitterRatio]
|
||||||
|
const jitter = nextInterval * jitterRatio * (randomValue * 2 - 1)
|
||||||
|
nextInterval = nextInterval + jitter
|
||||||
|
return Math.max(1, Math.min(nextInterval, maxInterval))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should apply exponential backoff without jitter (randomValue=0.5)', () => {
|
||||||
|
// randomValue = 0.5 gives jitter = 0
|
||||||
|
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.5)
|
||||||
|
expect(next).toBe(150) // 100 * 1.5 = 150
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply maximum positive jitter (randomValue=1.0)', () => {
|
||||||
|
// randomValue = 1.0 gives maximum positive jitter (+20%)
|
||||||
|
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 1.0)
|
||||||
|
// 100 * 1.5 = 150, jitter = 150 * 0.2 * 1 = 30
|
||||||
|
expect(next).toBe(180) // 150 + 30
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply maximum negative jitter (randomValue=0.0)', () => {
|
||||||
|
// randomValue = 0.0 gives maximum negative jitter (-20%)
|
||||||
|
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.0)
|
||||||
|
// 100 * 1.5 = 150, jitter = 150 * 0.2 * -1 = -30
|
||||||
|
expect(next).toBe(120) // 150 - 30
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect maximum interval', () => {
|
||||||
|
const next = calculateNextInterval(800, 1.5, 0.2, 1000, 1.0)
|
||||||
|
// 800 * 1.5 = 1200, with +20% jitter = 1440, capped at 1000
|
||||||
|
expect(next).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should never go below 1ms even with extreme negative jitter', () => {
|
||||||
|
const next = calculateNextInterval(1, 1.0, 0.9, 1000, 0.0)
|
||||||
|
// 1 * 1.0 = 1, jitter = 1 * 0.9 * -1 = -0.9
|
||||||
|
// 1 - 0.9 = 0.1, but Math.max(1, ...) ensures minimum is 1
|
||||||
|
expect(next).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero jitter ratio', () => {
|
||||||
|
const next = calculateNextInterval(100, 2.0, 0, 1000, 0.0)
|
||||||
|
expect(next).toBe(200) // Pure exponential, no jitter
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle large backoff factor', () => {
|
||||||
|
const next = calculateNextInterval(100, 3.0, 0.1, 1000, 0.5)
|
||||||
|
expect(next).toBe(300) // 100 * 3.0 = 300
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jitter distribution', () => {
|
||||||
|
it('should produce values in expected range', () => {
|
||||||
|
const results = []
|
||||||
|
// Test with various random values
|
||||||
|
for (let r = 0; r <= 1; r += 0.1) {
|
||||||
|
results.push(calculateNextInterval(100, 1.5, 0.2, 1000, r))
|
||||||
|
}
|
||||||
|
// All values should be between 120 (150 - 30) and 180 (150 + 30)
|
||||||
|
expect(Math.min(...results)).toBeGreaterThanOrEqual(120)
|
||||||
|
expect(Math.max(...results)).toBeLessThanOrEqual(180)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Queue Size Calculation', () => {
|
||||||
|
/**
|
||||||
|
* 最大排队数计算(与 auth.js 中的实现一致)
|
||||||
|
*/
|
||||||
|
function calculateMaxQueueSize(concurrencyLimit, multiplier, fixedMin) {
|
||||||
|
return Math.max(concurrencyLimit * multiplier, fixedMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should use multiplier when result is larger', () => {
|
||||||
|
// concurrencyLimit=10, multiplier=2, fixedMin=5
|
||||||
|
// max(10*2, 5) = max(20, 5) = 20
|
||||||
|
expect(calculateMaxQueueSize(10, 2, 5)).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use fixed minimum when multiplier result is smaller', () => {
|
||||||
|
// concurrencyLimit=2, multiplier=1, fixedMin=5
|
||||||
|
// max(2*1, 5) = max(2, 5) = 5
|
||||||
|
expect(calculateMaxQueueSize(2, 1, 5)).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero multiplier', () => {
|
||||||
|
// concurrencyLimit=10, multiplier=0, fixedMin=3
|
||||||
|
// max(10*0, 3) = max(0, 3) = 3
|
||||||
|
expect(calculateMaxQueueSize(10, 0, 3)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle fractional multiplier', () => {
|
||||||
|
// concurrencyLimit=10, multiplier=1.5, fixedMin=5
|
||||||
|
// max(10*1.5, 5) = max(15, 5) = 15
|
||||||
|
expect(calculateMaxQueueSize(10, 1.5, 5)).toBe(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TTL Calculation', () => {
|
||||||
|
/**
|
||||||
|
* 排队计数器 TTL 计算(与 redis.js 中的实现一致)
|
||||||
|
*/
|
||||||
|
function calculateQueueTtl(timeoutMs, bufferSeconds = 30) {
|
||||||
|
return Math.ceil(timeoutMs / 1000) + bufferSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should calculate TTL with default buffer', () => {
|
||||||
|
// 60000ms = 60s + 30s buffer = 90s
|
||||||
|
expect(calculateQueueTtl(60000)).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should round up milliseconds to seconds', () => {
|
||||||
|
// 61500ms = ceil(61.5) = 62s + 30s = 92s
|
||||||
|
expect(calculateQueueTtl(61500)).toBe(92)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle custom buffer', () => {
|
||||||
|
// 30000ms = 30s + 60s buffer = 90s
|
||||||
|
expect(calculateQueueTtl(30000, 60)).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very short timeout', () => {
|
||||||
|
// 1000ms = 1s + 30s = 31s
|
||||||
|
expect(calculateQueueTtl(1000)).toBe(31)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
434
tests/userMessageQueue.test.js
Normal file
434
tests/userMessageQueue.test.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务测试
|
||||||
|
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../src/models/redis')
|
||||||
|
const userMessageQueueService = require('../src/services/userMessageQueueService')
|
||||||
|
|
||||||
|
describe('UserMessageQueueService', () => {
|
||||||
|
describe('isUserMessageRequest', () => {
|
||||||
|
it('should return true when last message role is user', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Hi there' },
|
||||||
|
{ role: 'user', content: 'How are you?' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message role is assistant', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Hi there' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message contains tool_result', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Let me check that' },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'test-id',
|
||||||
|
content: 'Tool result'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// tool_result 消息虽然 role 是 user,但不是真正的用户消息
|
||||||
|
// 应该返回 false,不进入用户消息队列
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message contains multiple tool_results', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Run multiple tools' },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-1',
|
||||||
|
content: 'Result 1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-2',
|
||||||
|
content: 'Result 2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when user message has array content with text type', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello, this is a user message'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when user message has mixed text and image content', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'What is in this image?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: 'image/png', data: '...' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is empty', () => {
|
||||||
|
const requestBody = { messages: [] }
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is not an array', () => {
|
||||||
|
const requestBody = { messages: 'not an array' }
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is undefined', () => {
|
||||||
|
const requestBody = {}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestBody is null', () => {
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestBody is undefined', () => {
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message has no role', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single user message', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ role: 'user', content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single assistant message', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ role: 'assistant', content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getConfig', () => {
|
||||||
|
it('should return config with expected properties', async () => {
|
||||||
|
const config = await userMessageQueueService.getConfig()
|
||||||
|
expect(config).toHaveProperty('enabled')
|
||||||
|
expect(config).toHaveProperty('delayMs')
|
||||||
|
expect(config).toHaveProperty('timeoutMs')
|
||||||
|
expect(config).toHaveProperty('lockTtlMs')
|
||||||
|
expect(typeof config.enabled).toBe('boolean')
|
||||||
|
expect(typeof config.delayMs).toBe('number')
|
||||||
|
expect(typeof config.timeoutMs).toBe('number')
|
||||||
|
expect(typeof config.lockTtlMs).toBe('number')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return boolean', async () => {
|
||||||
|
const enabled = await userMessageQueueService.isEnabled()
|
||||||
|
expect(typeof enabled).toBe('boolean')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('acquireQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should acquire lock immediately when no lock exists', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBe('req-1')
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip lock acquisition when queue disabled', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: false,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.skipped).toBe(true)
|
||||||
|
expect(acquireSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate requestId when not provided', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBeDefined()
|
||||||
|
expect(result.requestId.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wait and retry when lock is held by another request', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount < 3) {
|
||||||
|
return { acquired: false, waitMs: -1 } // lock held
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock sleep to speed up test
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(callCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect delay when previous request just completed', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) {
|
||||||
|
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should timeout and return error when wait exceeds timeout', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 100, // very short timeout
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: false,
|
||||||
|
waitMs: -1 // always held
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use real timers for timeout test but mock sleep to be instant
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
|
||||||
|
// Simulate time passing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(false)
|
||||||
|
expect(result.error).toBe('queue_timeout')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('releaseQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock successfully when holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when not holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when accountId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queue serialization behavior', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow different accounts to acquire locks simultaneously', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should serialize requests for same account', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 50,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
const lockState = { held: false, holderId: null }
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (!lockState.held) {
|
||||||
|
lockState.held = true
|
||||||
|
lockState.holderId = requestId
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
}
|
||||||
|
return { acquired: false, waitMs: -1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (lockState.holderId === requestId) {
|
||||||
|
lockState.held = false
|
||||||
|
lockState.holderId = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// First request acquires lock
|
||||||
|
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
|
||||||
|
// Second request should fail to acquire (lock held)
|
||||||
|
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
|
||||||
|
|
||||||
|
// Release first lock
|
||||||
|
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
// Now second request should acquire
|
||||||
|
const result2 = await acquirePromise
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
2
web/admin-spa/package-lock.json
generated
2
web/admin-spa/package-lock.json
generated
@@ -3789,7 +3789,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-tailwindcss": {
|
"node_modules/prettier-plugin-tailwindcss": {
|
||||||
"version": "0.6.14",
|
"version": "0.6.14",
|
||||||
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
||||||
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1320,10 +1320,10 @@
|
|||||||
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||||
type="button"
|
type="button"
|
||||||
@click="
|
@click="
|
||||||
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')
|
addPresetMapping('claude-opus-4-5-20251101', 'claude-opus-4-5-20251101')
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
+ Sonnet 4
|
+ Opus 4.5
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
|
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
|
||||||
@@ -1334,24 +1334,6 @@
|
|||||||
>
|
>
|
||||||
+ Sonnet 4.5
|
+ Sonnet 4.5
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
|
|
||||||
type="button"
|
|
||||||
@click="
|
|
||||||
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
+ Opus 4.1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
|
|
||||||
type="button"
|
|
||||||
@click="
|
|
||||||
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
+ Haiku 3.5
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
|
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1451,6 +1433,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理 -->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>上游错误处理</label
|
||||||
|
>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段 -->
|
<!-- OpenAI-Responses 特定字段 -->
|
||||||
@@ -1630,6 +1632,25 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关 -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置 -->
|
<!-- Claude User-Agent 版本配置 -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -1823,7 +1844,7 @@
|
|||||||
>
|
>
|
||||||
请从已登录 Gemini CLI 的机器上获取
|
请从已登录 Gemini CLI 的机器上获取
|
||||||
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50"
|
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50"
|
||||||
>~/.config/gemini/credentials.json</code
|
>~/.config/.gemini/oauth_creds.json</code
|
||||||
>
|
>
|
||||||
文件中的凭证。
|
文件中的凭证。
|
||||||
</p>
|
</p>
|
||||||
@@ -1924,6 +1945,22 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (OAuth/Manual 模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Key 模式输入 -->
|
<!-- API Key 模式输入 -->
|
||||||
@@ -1969,6 +2006,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
||||||
>
|
>
|
||||||
@@ -2029,6 +2082,7 @@
|
|||||||
<!-- 步骤2: OAuth授权 -->
|
<!-- 步骤2: OAuth授权 -->
|
||||||
<OAuthFlow
|
<OAuthFlow
|
||||||
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
||||||
|
ref="oauthFlowRef"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
:proxy="form.proxy"
|
:proxy="form.proxy"
|
||||||
@back="oauthStep = 1"
|
@back="oauthStep = 1"
|
||||||
@@ -2052,11 +2106,45 @@
|
|||||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
||||||
Claude Setup Token 授权
|
Claude Setup Token 授权
|
||||||
</h4>
|
</h4>
|
||||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
|
||||||
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- 授权方式选择 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
选择授权方式:
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="radio"
|
||||||
|
value="manual"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-link mr-1" />手动授权
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="radio"
|
||||||
|
value="cookie"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-cookie mr-1" />Cookie 自动授权
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动授权流程 -->
|
||||||
|
<div v-if="authMethod === 'manual'" class="space-y-4">
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
||||||
|
</p>
|
||||||
<!-- 步骤1: 生成授权链接 -->
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
@@ -2182,6 +2270,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookie自动授权流程 -->
|
||||||
|
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
使用 sessionKey 自动完成授权,无需手动打开链接。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cookie text-blue-500" />sessionKey
|
||||||
|
<span
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
{{ parsedSessionKeyCount }} 个
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="sessionKey"
|
||||||
|
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': cookieAuthError }"
|
||||||
|
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||||
|
</p>
|
||||||
|
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ cookieAuthError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 帮助说明 -->
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
type="button"
|
||||||
|
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
showSessionKeyHelp
|
||||||
|
? 'fas fa-chevron-down mr-1'
|
||||||
|
: 'fas fa-chevron-right mr-1'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
如何获取 sessionKey?
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showSessionKeyHelp"
|
||||||
|
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
|
||||||
|
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
|
||||||
|
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
|
||||||
|
<li>
|
||||||
|
4. 在左侧选择 <strong>"Cookies"</strong> →
|
||||||
|
<strong>"https://claude.ai"</strong>
|
||||||
|
</li>
|
||||||
|
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
|
||||||
|
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
sessionKey 通常以 "sk-ant-" 开头
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 开始授权按钮 -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full px-4 py-3"
|
||||||
|
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||||
|
type="button"
|
||||||
|
@click="handleCookieAuth"
|
||||||
|
>
|
||||||
|
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||||
|
<i v-else class="fas fa-magic mr-2" />
|
||||||
|
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||||
|
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cookieAuthLoading"> 授权中... </template>
|
||||||
|
<template v-else> 开始自动授权 </template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
|
<strong>提示:</strong>如果您设置了代理,Cookie授权也会使用相同的代理配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2196,6 +2391,7 @@
|
|||||||
上一步
|
上一步
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="authMethod === 'manual'"
|
||||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2438,6 +2634,25 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关(编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -2927,6 +3142,26 @@
|
|||||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理(编辑模式)-->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误处理
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||||
@@ -3456,6 +3691,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 代理设置 -->
|
<!-- 代理设置 -->
|
||||||
<ProxyConfig v-model="form.proxy" />
|
<ProxyConfig v-model="form.proxy" />
|
||||||
|
|
||||||
@@ -3538,6 +3789,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
|||||||
const isEdit = computed(() => !!props.account)
|
const isEdit = computed(() => !!props.account)
|
||||||
const show = ref(true)
|
const show = ref(true)
|
||||||
|
|
||||||
|
// OAuthFlow 组件引用
|
||||||
|
const oauthFlowRef = ref(null)
|
||||||
|
|
||||||
// OAuth步骤
|
// OAuth步骤
|
||||||
const oauthStep = ref(1)
|
const oauthStep = ref(1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -3551,6 +3805,22 @@ const setupTokenAuthCode = ref('')
|
|||||||
const setupTokenCopied = ref(false)
|
const setupTokenCopied = ref(false)
|
||||||
const setupTokenSessionId = ref('')
|
const setupTokenSessionId = ref('')
|
||||||
|
|
||||||
|
// Cookie自动授权相关状态
|
||||||
|
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||||
|
const sessionKey = ref('')
|
||||||
|
const cookieAuthLoading = ref(false)
|
||||||
|
const cookieAuthError = ref('')
|
||||||
|
const showSessionKeyHelp = ref(false)
|
||||||
|
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||||
|
|
||||||
|
// 解析后的 sessionKey 数量
|
||||||
|
const parsedSessionKeyCount = computed(() => {
|
||||||
|
return sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
// Claude Code 统一 User-Agent 信息
|
// Claude Code 统一 User-Agent 信息
|
||||||
const unifiedUserAgent = ref('')
|
const unifiedUserAgent = ref('')
|
||||||
const clearingCache = ref(false)
|
const clearingCache = ref(false)
|
||||||
@@ -3717,6 +3987,7 @@ const form = ref({
|
|||||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||||
|
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
|
||||||
groupId: '',
|
groupId: '',
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
@@ -3750,6 +4021,7 @@ const form = ref({
|
|||||||
})(),
|
})(),
|
||||||
userAgent: props.account?.userAgent || '',
|
userAgent: props.account?.userAgent || '',
|
||||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||||
|
disableAutoProtection: props.account?.disableAutoProtection === true,
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
dailyQuota: props.account?.dailyQuota || 0,
|
dailyQuota: props.account?.dailyQuota || 0,
|
||||||
dailyUsage: props.account?.dailyUsage || 0,
|
dailyUsage: props.account?.dailyUsage || 0,
|
||||||
@@ -4193,10 +4465,199 @@ const exchangeSetupTokenCode = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理OAuth成功
|
// =============================================================================
|
||||||
const handleOAuthSuccess = async (tokenInfo) => {
|
// Cookie自动授权相关方法
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Cookie自动授权(支持批量)
|
||||||
|
const handleCookieAuth = async () => {
|
||||||
|
// 解析多行输入
|
||||||
|
const sessionKeys = sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
|
||||||
|
if (sessionKeys.length === 0) {
|
||||||
|
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieAuthLoading.value = true
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||||
|
|
||||||
|
const isSetupToken = form.value.addType === 'setup-token'
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionKeys.length; i++) {
|
||||||
|
batchProgress.value.current = i + 1
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
sessionKey: sessionKeys[i],
|
||||||
|
...(proxyPayload && { proxy: proxyPayload })
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (isSetupToken) {
|
||||||
|
result = await accountsStore.oauthSetupTokenWithCookie(payload)
|
||||||
|
} else {
|
||||||
|
result = await accountsStore.oauthWithCookie(payload)
|
||||||
|
}
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
index: i + 1,
|
||||||
|
key: sessionKeys[i].substring(0, 20) + '...',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
try {
|
||||||
|
// 成功后处理OAuth数据(传递数组)
|
||||||
|
// cookieAuthLoading 保持 true,直到账号创建完成
|
||||||
|
await handleOAuthSuccess(results)
|
||||||
|
} finally {
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && results.length === 0) {
|
||||||
|
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置Cookie授权状态
|
||||||
|
const resetCookieAuth = () => {
|
||||||
|
sessionKey.value = ''
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
showSessionKeyHelp.value = false
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换授权方式时重置状态
|
||||||
|
const onAuthMethodChange = () => {
|
||||||
|
// 切换到手动模式时清除Cookie相关状态
|
||||||
|
if (authMethod.value === 'manual') {
|
||||||
|
resetCookieAuth()
|
||||||
|
} else {
|
||||||
|
// 切换到Cookie模式时清除手动授权状态
|
||||||
|
setupTokenAuthUrl.value = ''
|
||||||
|
setupTokenAuthCode.value = ''
|
||||||
|
setupTokenSessionId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 Claude 账户数据(辅助函数)
|
||||||
|
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: accountName,
|
||||||
|
description: form.value.description,
|
||||||
|
accountType: form.value.accountType,
|
||||||
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||||
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||||
|
expiresAt: form.value.expiresAt || undefined,
|
||||||
|
proxy: proxyPayload,
|
||||||
|
claudeAiOauth: claudeOauthPayload,
|
||||||
|
priority: form.value.priority || 50,
|
||||||
|
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||||
|
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||||
|
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||||
|
unifiedClientId: clientId,
|
||||||
|
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
|
||||||
|
subscriptionInfo: {
|
||||||
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
|
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||||
|
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||||
|
manuallySet: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 extInfo
|
||||||
|
if (claudeOauthPayload) {
|
||||||
|
const extInfoPayload = {}
|
||||||
|
const extSource = claudeOauthPayload.extInfo
|
||||||
|
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
|
||||||
|
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
|
||||||
|
|
||||||
|
if (!extSource) {
|
||||||
|
if (claudeOauthPayload.organization?.uuid) {
|
||||||
|
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
|
||||||
|
}
|
||||||
|
if (claudeOauthPayload.account?.uuid) {
|
||||||
|
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(extInfoPayload).length > 0) {
|
||||||
|
data.extInfo = extInfoPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理OAuth成功(支持批量)
|
||||||
|
const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const currentPlatform = form.value.platform
|
||||||
|
|
||||||
|
// Claude 平台支持批量创建
|
||||||
|
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
|
||||||
|
const tokenInfoList = tokenInfoOrList
|
||||||
|
const isBatch = tokenInfoList.length > 1
|
||||||
|
const baseName = form.value.name
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < tokenInfoList.length; i++) {
|
||||||
|
const tokenInfo = tokenInfoList[i]
|
||||||
|
// 批量时自动命名
|
||||||
|
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
|
||||||
|
// 如果启用统一客户端标识,为每个账户生成独立 ID
|
||||||
|
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
|
||||||
|
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await accountsStore.createClaudeAccount(data)
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ name: accountName, error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
if (results.length > 0) {
|
||||||
|
const msg = isBatch
|
||||||
|
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
|
||||||
|
: '账户创建成功'
|
||||||
|
showToast(msg, 'success')
|
||||||
|
emit('success', results[0]) // 兼容单个创建的返回
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showToast(`${errors.length} 个账户创建失败`, 'error')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
|
||||||
|
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
|
||||||
|
|
||||||
// OAuth模式也需要确保生成客户端ID
|
// OAuth模式也需要确保生成客户端ID
|
||||||
if (
|
if (
|
||||||
form.value.platform === 'claude' &&
|
form.value.platform === 'claude' &&
|
||||||
@@ -4218,8 +4679,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
proxy: proxyPayload
|
proxy: proxyPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPlatform = form.value.platform
|
|
||||||
|
|
||||||
if (currentPlatform === 'claude') {
|
if (currentPlatform === 'claude') {
|
||||||
// Claude使用claudeAiOauth字段
|
// Claude使用claudeAiOauth字段
|
||||||
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||||
@@ -4256,6 +4715,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -4380,6 +4840,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
// 错误已通过 toast 显示给用户
|
// 错误已通过 toast 显示给用户
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
|
||||||
|
oauthFlowRef.value?.resetCookieAuth()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4577,6 +5039,7 @@ const createAccount = async () => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -4665,6 +5128,10 @@ const createAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理(仅 Claude Console)
|
||||||
|
if (form.value.platform === 'claude-console') {
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
}
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -4963,6 +5430,7 @@ const updateAccount = async () => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 更新订阅类型信息
|
// 更新订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -4993,6 +5461,8 @@ const updateAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -5564,6 +6034,7 @@ watch(
|
|||||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||||
unifiedClientId: newAccount.unifiedClientId || '',
|
unifiedClientId: newAccount.unifiedClientId || '',
|
||||||
|
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
@@ -5614,7 +6085,9 @@ watch(
|
|||||||
dailyUsage: newAccount.dailyUsage || 0,
|
dailyUsage: newAccount.dailyUsage || 0,
|
||||||
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
||||||
// 并发控制字段
|
// 并发控制字段
|
||||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
|
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||||
|
// 上游错误处理
|
||||||
|
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是Claude Console账户,加载实时使用情况
|
// 如果是Claude Console账户,加载实时使用情况
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="handleClose" />
|
||||||
|
<div
|
||||||
|
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||||
|
>
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ account?.name || '未知账户' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 启用开关 -->
|
||||||
|
<div class="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'relative h-6 w-11 rounded-full transition-colors duration-200',
|
||||||
|
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
]"
|
||||||
|
@click="config.enabled = !config.enabled"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||||
|
config.enabled ? 'left-5' : 'left-0.5'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron 表达式配置 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Cron 表达式
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.cronExpression"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="0 8 * * *"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
格式: 分 时 日 月 周 (例: "0 8 * * *" = 每天8:00)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷选项 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
快捷设置
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in cronPresets"
|
||||||
|
:key="preset.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.cronExpression === preset.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.cronExpression = preset.value"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试模型选择 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
测试模型
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.model"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="claude-sonnet-4-5-20250929"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="modelOption in modelOptions"
|
||||||
|
:key="modelOption.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.model === modelOption.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.model = modelOption.value"
|
||||||
|
>
|
||||||
|
{{ modelOption.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试历史 -->
|
||||||
|
<div v-if="testHistory.length > 0" class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
最近测试记录
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(record, index) in testHistory"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas',
|
||||||
|
record.success
|
||||||
|
? 'fa-check-circle text-green-500'
|
||||||
|
: 'fa-times-circle text-red-500'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTimestamp(record.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
|
||||||
|
{{ record.latencyMs }}ms
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="record.error"
|
||||||
|
class="max-w-[150px] truncate text-red-500"
|
||||||
|
:title="record.error"
|
||||||
|
>
|
||||||
|
{{ record.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无历史记录 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
|
||||||
|
<p>暂无测试记录</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||||
|
saving
|
||||||
|
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||||
|
]"
|
||||||
|
:disabled="saving || loading"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
|
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
|
||||||
|
{{ saving ? '保存中...' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { API_PREFIX } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const config = ref({
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
})
|
||||||
|
const testHistory = ref([])
|
||||||
|
|
||||||
|
// Cron 预设选项
|
||||||
|
const cronPresets = [
|
||||||
|
{ label: '每天 8:00', value: '0 8 * * *' },
|
||||||
|
{ label: '每天 12:00', value: '0 12 * * *' },
|
||||||
|
{ label: '每天 18:00', value: '0 18 * * *' },
|
||||||
|
{ label: '每6小时', value: '0 */6 * * *' },
|
||||||
|
{ label: '每12小时', value: '0 */12 * * *' },
|
||||||
|
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模型选项
|
||||||
|
const modelOptions = [
|
||||||
|
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
|
||||||
|
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||||
|
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化时间戳
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) return '未知'
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
// 根据平台获取配置端点
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
// 其他平台暂不支持
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const configRes = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
const data = await configRes.json()
|
||||||
|
if (data.success && data.data?.config) {
|
||||||
|
config.value = {
|
||||||
|
enabled: data.data.config.enabled || false,
|
||||||
|
cronExpression: data.data.config.cronExpression || '0 8 * * *',
|
||||||
|
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取测试历史
|
||||||
|
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
|
||||||
|
const historyRes = await fetch(historyEndpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (historyRes.ok) {
|
||||||
|
const historyData = await historyRes.json()
|
||||||
|
if (historyData.success && historyData.data?.history) {
|
||||||
|
testHistory.value = historyData.data.history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('加载配置失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
saving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: config.value.enabled,
|
||||||
|
cronExpression: config.value.cronExpression,
|
||||||
|
model: config.value.model
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('配置已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json().catch(() => ({}))
|
||||||
|
showToast(errorData.message || '保存失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('保存失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
function handleClose() {
|
||||||
|
if (saving.value) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 show 变化,加载配置
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
config.value = {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
testHistory.value = []
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -44,12 +44,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
<button
|
||||||
@click="handleClose"
|
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
|
||||||
>
|
@click="goTimeline"
|
||||||
<i class="fas fa-times" />
|
>
|
||||||
</button>
|
<i class="fas fa-clock" /> 请求时间线
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
@@ -325,6 +333,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import Chart from 'chart.js/auto'
|
import Chart from 'chart.js/auto'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
|
|||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { isDarkMode } = storeToRefs(themeStore)
|
const { isDarkMode } = storeToRefs(themeStore)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
let chartInstance = null
|
let chartInstance = null
|
||||||
@@ -579,6 +589,14 @@ const handleClose = () => {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goTimeline = () => {
|
||||||
|
if (!props.account?.id) return
|
||||||
|
router.push({
|
||||||
|
path: `/accounts/${props.account.id}/usage-records`,
|
||||||
|
query: { platform: props.account.platform || props.account.accountType }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
|
|||||||
@@ -13,128 +13,269 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
||||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
|
||||||
请按照以下步骤完成 Claude 账户的授权:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- 授权方式选择 -->
|
||||||
<!-- 步骤1: 生成授权链接 -->
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
选择授权方式
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
name="claude-auth-method"
|
||||||
|
type="radio"
|
||||||
|
value="manual"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
name="claude-auth-method"
|
||||||
|
type="radio"
|
||||||
|
value="cookie"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookie自动授权表单 -->
|
||||||
|
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
<div
|
使用 claude.ai 的 sessionKey 自动完成 OAuth 授权流程,无需手动打开浏览器。
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
</p>
|
||||||
|
|
||||||
|
<!-- sessionKey输入 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
1
|
<i class="fas fa-cookie text-blue-500" />
|
||||||
</div>
|
sessionKey
|
||||||
<div class="flex-1">
|
<span
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
v-if="parsedSessionKeyCount > 1"
|
||||||
点击下方按钮生成授权链接
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
v-if="!authUrl"
|
|
||||||
class="btn btn-primary px-4 py-2 text-sm"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="generateAuthUrl"
|
|
||||||
>
|
>
|
||||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
{{ parsedSessionKeyCount }} 个
|
||||||
<div v-else class="loading-spinner mr-2" />
|
</span>
|
||||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
<button
|
||||||
|
class="text-blue-500 hover:text-blue-600"
|
||||||
|
type="button"
|
||||||
|
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||||
|
>
|
||||||
|
<i class="fas fa-question-circle" />
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="space-y-3">
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<textarea
|
||||||
<input
|
v-model="sessionKey"
|
||||||
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
class="form-input w-full resize-y font-mono text-sm"
|
||||||
readonly
|
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||||
type="text"
|
rows="3"
|
||||||
:value="authUrl"
|
/>
|
||||||
/>
|
<p
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 帮助说明 -->
|
||||||
|
<div
|
||||||
|
v-if="showSessionKeyHelp"
|
||||||
|
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||||
|
>
|
||||||
|
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||||
|
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
|
||||||
|
</h5>
|
||||||
|
<ol
|
||||||
|
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<li>在浏览器中登录 <strong>claude.ai</strong></li>
|
||||||
|
<li>
|
||||||
|
按
|
||||||
|
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
|
||||||
|
打开开发者工具
|
||||||
|
</li>
|
||||||
|
<li>切换到 <strong>Application</strong>(应用)标签页</li>
|
||||||
|
<li>
|
||||||
|
在左侧找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>
|
||||||
|
</li>
|
||||||
|
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
|
||||||
|
<li>复制其 <strong>Value</strong>(值)列的内容</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
sessionKey 通常以
|
||||||
|
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
|
||||||
|
开头
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<div
|
||||||
|
v-if="cookieAuthError"
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
<i class="fas fa-exclamation-circle mr-1" />
|
||||||
|
{{ cookieAuthError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 授权按钮 -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
|
||||||
|
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||||
|
type="button"
|
||||||
|
@click="handleCookieAuth"
|
||||||
|
>
|
||||||
|
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||||
|
<i v-else class="fas fa-magic mr-2" />
|
||||||
|
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||||
|
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
|
||||||
|
<template v-else> 开始自动授权 </template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动授权流程 -->
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
请按照以下步骤完成 Claude 账户的授权:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
|
点击下方按钮生成授权链接
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="!authUrl"
|
||||||
|
class="btn btn-primary px-4 py-2 text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="generateAuthUrl"
|
||||||
|
>
|
||||||
|
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||||
|
<div v-else class="loading-spinner mr-2" />
|
||||||
|
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||||
|
</button>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||||
|
readonly
|
||||||
|
type="text"
|
||||||
|
:value="authUrl"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||||
|
title="复制链接"
|
||||||
|
@click="copyAuthUrl"
|
||||||
|
>
|
||||||
|
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
class="text-xs text-blue-600 hover:text-blue-700"
|
||||||
title="复制链接"
|
@click="regenerateAuthUrl"
|
||||||
@click="copyAuthUrl"
|
|
||||||
>
|
>
|
||||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="text-xs text-blue-600 hover:text-blue-700"
|
|
||||||
@click="regenerateAuthUrl"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 步骤2: 访问链接并授权 -->
|
<!-- 步骤2: 访问链接并授权 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
|
||||||
在浏览器中打开链接并完成授权
|
|
||||||
</p>
|
|
||||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
|
||||||
</p>
|
|
||||||
<div
|
<div
|
||||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
2
|
||||||
<i class="fas fa-exclamation-triangle mr-1" />
|
</div>
|
||||||
<strong>注意:</strong
|
<div class="flex-1">
|
||||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
|
在浏览器中打开链接并完成授权
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
|
<strong>注意:</strong
|
||||||
|
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 步骤3: 输入授权码 -->
|
<!-- 步骤3: 输入授权码 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
>
|
>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
输入 Authorization Code
|
输入 Authorization Code
|
||||||
</p>
|
|
||||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
授权完成后,页面会显示一个
|
|
||||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
|
||||||
</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="authCode"
|
|
||||||
class="form-input w-full resize-none font-mono text-sm"
|
|
||||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-info-circle mr-1" />
|
|
||||||
请粘贴从Claude页面复制的Authorization Code
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
授权完成后,页面会显示一个
|
||||||
|
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="authCode"
|
||||||
|
class="form-input w-full resize-none font-mono text-sm"
|
||||||
|
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
请粘贴从Claude页面复制的Authorization Code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +777,9 @@
|
|||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Cookie自动授权模式不显示此按钮(Claude平台) -->
|
||||||
<button
|
<button
|
||||||
|
v-if="!(platform === 'claude' && authMethod === 'cookie')"
|
||||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
:disabled="!canExchange || exchanging"
|
:disabled="!canExchange || exchanging"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
|
|||||||
const remainingSeconds = ref(0)
|
const remainingSeconds = ref(0)
|
||||||
let countdownTimer = null
|
let countdownTimer = null
|
||||||
|
|
||||||
|
// Cookie自动授权相关状态
|
||||||
|
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||||
|
const sessionKey = ref('')
|
||||||
|
const cookieAuthLoading = ref(false)
|
||||||
|
const cookieAuthError = ref('')
|
||||||
|
const showSessionKeyHelp = ref(false)
|
||||||
|
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||||
|
|
||||||
|
// 解析后的 sessionKey 数量
|
||||||
|
const parsedSessionKeyCount = computed(() => {
|
||||||
|
return sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
// 计算是否可以交换code
|
// 计算是否可以交换code
|
||||||
const canExchange = computed(() => {
|
const canExchange = computed(() => {
|
||||||
if (props.platform === 'droid') {
|
if (props.platform === 'droid') {
|
||||||
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopCountdown()
|
stopCountdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cookie自动授权处理(支持批量)
|
||||||
|
const handleCookieAuth = async () => {
|
||||||
|
// 解析多行输入
|
||||||
|
const sessionKeys = sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
|
||||||
|
if (sessionKeys.length === 0) {
|
||||||
|
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieAuthLoading.value = true
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||||
|
|
||||||
|
// 构建代理配置
|
||||||
|
const proxyConfig = props.proxy?.enabled
|
||||||
|
? {
|
||||||
|
type: props.proxy.type,
|
||||||
|
host: props.proxy.host,
|
||||||
|
port: parseInt(props.proxy.port),
|
||||||
|
username: props.proxy.username || null,
|
||||||
|
password: props.proxy.password || null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionKeys.length; i++) {
|
||||||
|
batchProgress.value.current = i + 1
|
||||||
|
try {
|
||||||
|
const result = await accountsStore.oauthWithCookie({
|
||||||
|
sessionKey: sessionKeys[i],
|
||||||
|
proxy: proxyConfig
|
||||||
|
})
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
index: i + 1,
|
||||||
|
key: sessionKeys[i].substring(0, 20) + '...',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
// emit 后父组件会调用 handleOAuthSuccess 创建账号
|
||||||
|
// cookieAuthLoading 保持 true,成功后表单会关闭,失败时父组件会处理
|
||||||
|
emit('success', results) // 返回数组(单个时也是数组)
|
||||||
|
// 注意:不在这里设置 cookieAuthLoading = false
|
||||||
|
// 父组件创建账号完成后表单会关闭/重置
|
||||||
|
} else {
|
||||||
|
// 全部授权失败时才恢复按钮状态
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && results.length === 0) {
|
||||||
|
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置Cookie授权状态
|
||||||
|
const resetCookieAuth = () => {
|
||||||
|
sessionKey.value = ''
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换授权方式时重置状态
|
||||||
|
const onAuthMethodChange = () => {
|
||||||
|
resetCookieAuth()
|
||||||
|
authUrl.value = ''
|
||||||
|
authCode.value = ''
|
||||||
|
sessionId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法供父组件调用
|
||||||
|
defineExpose({
|
||||||
|
resetCookieAuth
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:append-to-body="true"
|
||||||
|
class="record-detail-modal"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:model-value="show"
|
||||||
|
:show-close="false"
|
||||||
|
top="10vh"
|
||||||
|
width="720px"
|
||||||
|
@close="emitClose"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
请求详情
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ record?.model || '未知模型' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="关闭"
|
||||||
|
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||||
|
@click="emitClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">时间</span>
|
||||||
|
<span class="font-medium">{{ formattedTime }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">模型</span>
|
||||||
|
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">账户</span>
|
||||||
|
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">渠道</span>
|
||||||
|
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
|
||||||
|
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{{ formatNumber(record?.inputTokens) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{{ formatNumber(record?.outputTokens) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||||
|
<span class="font-semibold text-purple-600 dark:text-purple-400">
|
||||||
|
{{ formatNumber(record?.cacheCreateTokens) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||||
|
<span class="font-semibold text-orange-600 dark:text-orange-400">
|
||||||
|
{{ formatNumber(record?.cacheReadTokens) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">总计</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(record?.totalTokens) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formattedCosts.input }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formattedCosts.output }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formattedCosts.cacheCreate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formattedCosts.cacheRead }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
|
||||||
|
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ record?.costFormatted || formattedCosts.total }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<el-button type="primary" @click="emitClose">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { formatNumber } from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const emitClose = () => emit('close')
|
||||||
|
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
if (!props.record?.timestamp) return '未知时间'
|
||||||
|
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedCosts = computed(() => {
|
||||||
|
const breakdown = props.record?.costBreakdown || {}
|
||||||
|
const formatValue = (value) => {
|
||||||
|
const num = typeof value === 'number' ? value : 0
|
||||||
|
if (num >= 1) return `$${num.toFixed(2)}`
|
||||||
|
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||||
|
return `$${num.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
input: formatValue(breakdown.input),
|
||||||
|
output: formatValue(breakdown.output),
|
||||||
|
cacheCreate: formatValue(breakdown.cacheCreate),
|
||||||
|
cacheRead: formatValue(breakdown.cacheRead),
|
||||||
|
total: formatValue(breakdown.total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.record-detail-modal :deep(.el-dialog__header) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-modal :deep(.el-dialog__body) {
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-modal :deep(.el-dialog__footer) {
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -231,6 +231,9 @@
|
|||||||
|
|
||||||
<!-- 底部按钮 -->
|
<!-- 底部按钮 -->
|
||||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||||
|
<button class="btn btn-primary px-4 py-2 text-sm" type="button" @click="openTimeline">
|
||||||
|
查看请求时间线
|
||||||
|
</button>
|
||||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</button>
|
||||||
@@ -256,7 +259,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close', 'open-timeline'])
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
|
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
|
||||||
@@ -320,6 +323,10 @@ const formatTokenCount = (count) => {
|
|||||||
const close = () => {
|
const close = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTimeline = () => {
|
||||||
|
emit('open-timeline', props.apiKey?.id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 详细限制信息 -->
|
<!-- 详细限制信息 -->
|
||||||
<div v-if="hasModelRestrictions" class="card p-4 md:p-6">
|
<div v-if="hasModelRestrictions" class="card !overflow-visible p-4 md:p-6">
|
||||||
<h3
|
<h3
|
||||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||||
>
|
>
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
|
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
|
||||||
受限模型列表
|
受限模型列表
|
||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-1 md:space-y-2">
|
<div class="max-h-64 space-y-1 overflow-y-auto pr-1 md:max-h-80 md:space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="model in statsData.restrictions.restrictedModels"
|
v-for="model in statsData.restrictions.restrictedModels"
|
||||||
:key="model"
|
:key="model"
|
||||||
|
|||||||
@@ -77,7 +77,21 @@ const getActionClass = (action) => {
|
|||||||
return colorMap[action.color] || colorMap.gray
|
return colorMap[action.color] || colorMap.gray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instanceId = Symbol('action-dropdown')
|
||||||
|
const handleGlobalOpen = (event) => {
|
||||||
|
if (event?.detail?.id !== instanceId) {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleDropdown = async () => {
|
const toggleDropdown = async () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('action-dropdown-open', {
|
||||||
|
detail: { id: instanceId }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
isOpen.value = !isOpen.value
|
isOpen.value = !isOpen.value
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
|
|||||||
|
|
||||||
const trigger = triggerRef.value.getBoundingClientRect()
|
const trigger = triggerRef.value.getBoundingClientRect()
|
||||||
const dropdownHeight = 200 // 预估高度
|
const dropdownHeight = 200 // 预估高度
|
||||||
const dropdownWidth = 160 // 预估宽度
|
const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
|
||||||
|
const gap = 8
|
||||||
const spaceBelow = window.innerHeight - trigger.bottom
|
const spaceBelow = window.innerHeight - trigger.bottom
|
||||||
const spaceAbove = trigger.top
|
const spaceAbove = trigger.top
|
||||||
const spaceRight = window.innerWidth - trigger.right
|
const spaceRight = window.innerWidth - trigger.right
|
||||||
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
|
|||||||
|
|
||||||
// 计算垂直位置
|
// 计算垂直位置
|
||||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||||
top = trigger.bottom + 4
|
top = trigger.bottom + gap
|
||||||
} else {
|
} else {
|
||||||
top = trigger.top - dropdownHeight - 4
|
top = trigger.top - dropdownHeight - gap
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列)
|
// 计算水平位置 - 优先向右展开,避免遮挡左侧内容
|
||||||
if (spaceLeft >= dropdownWidth) {
|
if (spaceRight >= dropdownWidth + gap) {
|
||||||
left = trigger.left - dropdownWidth + trigger.width
|
left = trigger.right + gap
|
||||||
} else if (spaceRight >= dropdownWidth) {
|
} else if (spaceLeft >= dropdownWidth + gap) {
|
||||||
left = trigger.left
|
left = trigger.left - dropdownWidth - gap + trigger.width
|
||||||
} else {
|
} else {
|
||||||
left = window.innerWidth - dropdownWidth - 10
|
left = window.innerWidth - dropdownWidth - 10
|
||||||
}
|
}
|
||||||
@@ -164,11 +179,13 @@ onMounted(() => {
|
|||||||
window.addEventListener('scroll', handleScroll, true)
|
window.addEventListener('scroll', handleScroll, true)
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
window.addEventListener('action-dropdown-open', handleGlobalOpen)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('scroll', handleScroll, true)
|
window.removeEventListener('scroll', handleScroll, true)
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -41,19 +41,25 @@
|
|||||||
<div
|
<div
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
|
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
|
||||||
:class="[
|
:class="[
|
||||||
option.value === modelValue
|
isSelected(option.value)
|
||||||
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
: option.isGroup
|
||||||
|
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
]"
|
]"
|
||||||
|
:style="{
|
||||||
|
paddingLeft: option.indent ? `${12 + option.indent * 16}px` : '12px',
|
||||||
|
paddingRight: '12px'
|
||||||
|
}"
|
||||||
@click="selectOption(option)"
|
@click="selectOption(option)"
|
||||||
>
|
>
|
||||||
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<i
|
<i
|
||||||
v-if="option.value === modelValue"
|
v-if="isSelected(option.value)"
|
||||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
|
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +74,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Number],
|
type: [String, Number, Array],
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -86,6 +92,10 @@ const props = defineProps({
|
|||||||
iconColor: {
|
iconColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'text-gray-500'
|
default: 'text-gray-500'
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,7 +106,18 @@ const triggerRef = ref(null)
|
|||||||
const dropdownRef = ref(null)
|
const dropdownRef = ref(null)
|
||||||
const dropdownStyle = ref({})
|
const dropdownStyle = ref({})
|
||||||
|
|
||||||
|
const isSelected = (value) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
||||||
|
}
|
||||||
|
return props.modelValue === value
|
||||||
|
}
|
||||||
|
|
||||||
const selectedLabel = computed(() => {
|
const selectedLabel = computed(() => {
|
||||||
|
if (props.multiple) {
|
||||||
|
const count = Array.isArray(props.modelValue) ? props.modelValue.length : 0
|
||||||
|
return count > 0 ? `已选 ${count} 个` : ''
|
||||||
|
}
|
||||||
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
||||||
return selected ? selected.label : ''
|
return selected ? selected.label : ''
|
||||||
})
|
})
|
||||||
@@ -114,9 +135,21 @@ const closeDropdown = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (option) => {
|
const selectOption = (option) => {
|
||||||
emit('update:modelValue', option.value)
|
if (props.multiple) {
|
||||||
emit('change', option.value)
|
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
closeDropdown()
|
const idx = current.indexOf(option.value)
|
||||||
|
if (idx >= 0) {
|
||||||
|
current.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
current.push(option.value)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
emit('change', current)
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', option.value)
|
||||||
|
emit('change', option.value)
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDropdownPosition = () => {
|
const updateDropdownPosition = () => {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
|
|||||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||||
const DashboardView = () => import('@/views/DashboardView.vue')
|
const DashboardView = () => import('@/views/DashboardView.vue')
|
||||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||||
|
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||||
|
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||||
@@ -85,6 +87,18 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/api-keys/:keyId/usage-records',
|
||||||
|
component: MainLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'ApiKeyUsageRecords',
|
||||||
|
component: ApiKeyUsageRecordsView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
@@ -97,6 +111,18 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/accounts/:accountId/usage-records',
|
||||||
|
component: MainLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'AccountUsageRecords',
|
||||||
|
component: AccountUsageRecordsView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/tutorial',
|
path: '/tutorial',
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
|
|||||||
@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权 - 普通OAuth
|
||||||
|
const oauthWithCookie = async (payload) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Cookie授权失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权 - Setup Token
|
||||||
|
const oauthSetupTokenWithCookie = async (payload) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
'/admin/claude-accounts/setup-token-with-cookie',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Cookie授权失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 生成Gemini OAuth URL
|
// 生成Gemini OAuth URL
|
||||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
exchangeClaudeCode,
|
exchangeClaudeCode,
|
||||||
generateClaudeSetupTokenUrl,
|
generateClaudeSetupTokenUrl,
|
||||||
exchangeClaudeSetupTokenCode,
|
exchangeClaudeSetupTokenCode,
|
||||||
|
oauthWithCookie,
|
||||||
|
oauthSetupTokenWithCookie,
|
||||||
generateGeminiAuthUrl,
|
generateGeminiAuthUrl,
|
||||||
exchangeGeminiCode,
|
exchangeGeminiCode,
|
||||||
generateOpenAIAuthUrl,
|
generateOpenAIAuthUrl,
|
||||||
|
|||||||
@@ -67,22 +67,72 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
groupLabel: 'Claude账户'
|
groupLabel: 'Claude账户'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 本地偏好
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
preset: 'dashboard:date:preset',
|
||||||
|
granularity: 'dashboard:trend:granularity'
|
||||||
|
}
|
||||||
|
const defaultPreset = 'today'
|
||||||
|
const defaultGranularity = 'day'
|
||||||
|
|
||||||
|
const getPresetOptions = (granularity) =>
|
||||||
|
granularity === 'hour'
|
||||||
|
? [
|
||||||
|
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||||
|
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||||
|
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'today', label: '今日', days: 1 },
|
||||||
|
{ value: '7days', label: '7天', days: 7 },
|
||||||
|
{ value: '30days', label: '30天', days: 30 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const readFromStorage = (key, fallback) => {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key)
|
||||||
|
return value || fallback
|
||||||
|
} catch (error) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveToStorage = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略存储错误,避免影响渲染
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePresetForGranularity = (preset, granularity) => {
|
||||||
|
const options = getPresetOptions(granularity)
|
||||||
|
const hasPreset = options.some((opt) => opt.value === preset)
|
||||||
|
if (hasPreset) return preset
|
||||||
|
return granularity === 'hour' ? 'last24h' : defaultPreset
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity)
|
||||||
|
const initialGranularity = ['day', 'hour'].includes(storedGranularity)
|
||||||
|
? storedGranularity
|
||||||
|
: defaultGranularity
|
||||||
|
const initialPreset = normalizePresetForGranularity(
|
||||||
|
readFromStorage(STORAGE_KEYS.preset, defaultPreset),
|
||||||
|
initialGranularity
|
||||||
|
)
|
||||||
|
|
||||||
// 日期筛选
|
// 日期筛选
|
||||||
const dateFilter = ref({
|
const dateFilter = ref({
|
||||||
type: 'preset', // preset 或 custom
|
type: 'preset', // preset 或 custom
|
||||||
preset: '7days', // today, 7days, 30days
|
preset: initialPreset, // today, 7days, 30days
|
||||||
customStart: '',
|
customStart: '',
|
||||||
customEnd: '',
|
customEnd: '',
|
||||||
customRange: null,
|
customRange: null,
|
||||||
presetOptions: [
|
presetOptions: getPresetOptions(initialGranularity)
|
||||||
{ value: 'today', label: '今日', days: 1 },
|
|
||||||
{ value: '7days', label: '7天', days: 7 },
|
|
||||||
{ value: '30days', label: '30天', days: 30 }
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 趋势图粒度
|
// 趋势图粒度
|
||||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
const trendGranularity = ref(initialGranularity) // 'day' 或 'hour'
|
||||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||||
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
||||||
|
|
||||||
@@ -138,6 +188,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistDatePreferences = (
|
||||||
|
preset = dateFilter.value.preset,
|
||||||
|
granularity = trendGranularity.value
|
||||||
|
) => {
|
||||||
|
saveToStorage(STORAGE_KEYS.preset, preset)
|
||||||
|
saveToStorage(STORAGE_KEYS.granularity, granularity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEffectiveGranularity = () =>
|
||||||
|
dateFilter.value.type === 'preset' &&
|
||||||
|
dateFilter.value.preset === 'today' &&
|
||||||
|
trendGranularity.value === 'day'
|
||||||
|
? 'hour'
|
||||||
|
: trendGranularity.value
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
async function loadDashboardData(timeRange = null) {
|
async function loadDashboardData(timeRange = null) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -232,7 +297,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsageTrend(days = 7, granularity = 'day') {
|
async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) {
|
||||||
try {
|
try {
|
||||||
let url = '/admin/usage-trend?'
|
let url = '/admin/usage-trend?'
|
||||||
|
|
||||||
@@ -241,26 +306,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
url += `granularity=hour`
|
url += `granularity=hour`
|
||||||
|
|
||||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
|
||||||
const convertToUTC = (systemTzTimeStr) => {
|
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||||
const systemTz = 8
|
|
||||||
// 解析系统时区时间字符串
|
|
||||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
|
||||||
const [year, month, day] = datePart.split('-').map(Number)
|
|
||||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
|
||||||
|
|
||||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
|
||||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
|
||||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
|
||||||
const utcDate = new Date(
|
|
||||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
|
||||||
)
|
|
||||||
return utcDate.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
|
||||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
|
||||||
} else {
|
} else {
|
||||||
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -268,6 +316,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
if (dateFilter.value.type === 'preset') {
|
if (dateFilter.value.type === 'preset') {
|
||||||
switch (dateFilter.value.preset) {
|
switch (dateFilter.value.preset) {
|
||||||
|
case 'today': {
|
||||||
|
// 今日:使用系统时区的当日0点-23:59
|
||||||
|
startTime = getSystemTimezoneDay(now, true)
|
||||||
|
endTime = getSystemTimezoneDay(now, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'last24h': {
|
case 'last24h': {
|
||||||
// 近24小时:从当前时间往前推24小时
|
// 近24小时:从当前时间往前推24小时
|
||||||
endTime = new Date(now)
|
endTime = new Date(now)
|
||||||
@@ -318,34 +372,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModelStats(period = 'daily') {
|
async function loadModelStats(period = 'daily', granularity = null) {
|
||||||
|
const currentGranularity = granularity || getEffectiveGranularity()
|
||||||
try {
|
try {
|
||||||
let url = `/admin/model-stats?period=${period}`
|
let url = `/admin/model-stats?period=${period}`
|
||||||
|
|
||||||
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
||||||
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
|
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
|
||||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||||
// 将系统时区时间转换为UTC
|
// 按系统时区字符串直传,避免额外时区转换
|
||||||
const convertToUTC = (systemTzTimeStr) => {
|
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||||
const systemTz = 8
|
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
|
||||||
const [year, month, day] = datePart.split('-').map(Number)
|
|
||||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
|
||||||
|
|
||||||
const utcDate = new Date(
|
|
||||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
|
||||||
)
|
|
||||||
return utcDate.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
|
||||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
|
||||||
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
|
|
||||||
// 小时粒度的预设时间范围
|
// 小时粒度的预设时间范围
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let startTime, endTime
|
let startTime, endTime
|
||||||
|
|
||||||
switch (dateFilter.value.preset) {
|
switch (dateFilter.value.preset) {
|
||||||
|
case 'today': {
|
||||||
|
startTime = getSystemTimezoneDay(now, true)
|
||||||
|
endTime = getSystemTimezoneDay(now, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'last24h': {
|
case 'last24h': {
|
||||||
endTime = new Date(now)
|
endTime = new Date(now)
|
||||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
@@ -374,7 +422,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||||
}
|
}
|
||||||
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
|
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
|
||||||
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let startDate, endDate
|
let startDate, endDate
|
||||||
@@ -409,36 +457,20 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadApiKeysTrend(metric = 'requests') {
|
async function loadApiKeysTrend(metric = 'requests', granularity = null) {
|
||||||
|
const currentGranularity = granularity || getEffectiveGranularity()
|
||||||
try {
|
try {
|
||||||
let url = '/admin/api-keys-usage-trend?'
|
let url = '/admin/api-keys-usage-trend?'
|
||||||
let days = 7
|
let days = 7
|
||||||
|
|
||||||
if (trendGranularity.value === 'hour') {
|
if (currentGranularity === 'hour') {
|
||||||
// 小时粒度,计算时间范围
|
// 小时粒度,计算时间范围
|
||||||
url += `granularity=hour`
|
url += `granularity=hour`
|
||||||
|
|
||||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
// 使用自定义时间范围 - 按系统时区字符串直传
|
||||||
const convertToUTC = (systemTzTimeStr) => {
|
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||||
const systemTz = 8
|
|
||||||
// 解析系统时区时间字符串
|
|
||||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
|
||||||
const [year, month, day] = datePart.split('-').map(Number)
|
|
||||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
|
||||||
|
|
||||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
|
||||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
|
||||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
|
||||||
const utcDate = new Date(
|
|
||||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
|
||||||
)
|
|
||||||
return utcDate.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
|
||||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
|
||||||
} else {
|
} else {
|
||||||
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -446,6 +478,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
if (dateFilter.value.type === 'preset') {
|
if (dateFilter.value.type === 'preset') {
|
||||||
switch (dateFilter.value.preset) {
|
switch (dateFilter.value.preset) {
|
||||||
|
case 'today': {
|
||||||
|
startTime = getSystemTimezoneDay(now, true)
|
||||||
|
endTime = getSystemTimezoneDay(now, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'last24h': {
|
case 'last24h': {
|
||||||
// 近24小时:从当前时间往前推24小时
|
// 近24小时:从当前时间往前推24小时
|
||||||
endTime = new Date(now)
|
endTime = new Date(now)
|
||||||
@@ -511,29 +548,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
|
async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) {
|
||||||
|
const currentGranularity = granularity || getEffectiveGranularity()
|
||||||
try {
|
try {
|
||||||
let url = '/admin/account-usage-trend?'
|
let url = '/admin/account-usage-trend?'
|
||||||
let days = 7
|
let days = 7
|
||||||
|
|
||||||
if (trendGranularity.value === 'hour') {
|
if (currentGranularity === 'hour') {
|
||||||
url += `granularity=hour`
|
url += `granularity=hour`
|
||||||
|
|
||||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||||
const convertToUTC = (systemTzTimeStr) => {
|
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||||
const systemTz = 8
|
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
|
||||||
const [year, month, day] = datePart.split('-').map(Number)
|
|
||||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
|
||||||
|
|
||||||
const utcDate = new Date(
|
|
||||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
|
||||||
)
|
|
||||||
return utcDate.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
|
||||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
|
||||||
} else {
|
} else {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let startTime
|
let startTime
|
||||||
@@ -541,6 +567,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
if (dateFilter.value.type === 'preset') {
|
if (dateFilter.value.type === 'preset') {
|
||||||
switch (dateFilter.value.preset) {
|
switch (dateFilter.value.preset) {
|
||||||
|
case 'today': {
|
||||||
|
startTime = getSystemTimezoneDay(now, true)
|
||||||
|
endTime = getSystemTimezoneDay(now, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'last24h': {
|
case 'last24h': {
|
||||||
endTime = new Date(now)
|
endTime = new Date(now)
|
||||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
@@ -603,110 +634,87 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 日期筛选相关方法
|
// 日期筛选相关方法
|
||||||
function setDateFilterPreset(preset) {
|
function setDateFilterPreset(preset, options = {}) {
|
||||||
|
const { silent = false, skipSave = false } = options
|
||||||
|
const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value)
|
||||||
|
|
||||||
dateFilter.value.type = 'preset'
|
dateFilter.value.type = 'preset'
|
||||||
dateFilter.value.preset = preset
|
dateFilter.value.preset = normalizedPreset
|
||||||
|
|
||||||
// 根据预设计算并设置具体的日期范围
|
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
|
||||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
|
const now = new Date()
|
||||||
if (option) {
|
let startDate
|
||||||
const now = new Date()
|
let endDate
|
||||||
let startDate, endDate
|
|
||||||
|
|
||||||
if (trendGranularity.value === 'hour') {
|
if (trendGranularity.value === 'hour') {
|
||||||
// 小时粒度的预设
|
switch (normalizedPreset) {
|
||||||
switch (preset) {
|
case 'today': {
|
||||||
case 'last24h': {
|
startDate = getSystemTimezoneDay(now, true)
|
||||||
// 近24小时:从当前时间往前推24小时
|
endDate = getSystemTimezoneDay(now, false)
|
||||||
endDate = new Date(now)
|
break
|
||||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'yesterday': {
|
|
||||||
// 昨天:获取本地时间的昨天
|
|
||||||
const yesterday = new Date()
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
|
||||||
// 转换为系统时区的昨天0点和23:59
|
|
||||||
startDate = getSystemTimezoneDay(yesterday, true)
|
|
||||||
endDate = getSystemTimezoneDay(yesterday, false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'dayBefore': {
|
|
||||||
// 前天:获取本地时间的前天
|
|
||||||
const dayBefore = new Date()
|
|
||||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
|
||||||
// 转换为系统时区的前天0点和23:59
|
|
||||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
|
||||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
case 'last24h': {
|
||||||
// 天粒度的预设
|
endDate = new Date(now)
|
||||||
startDate = new Date(now)
|
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
endDate = new Date(now)
|
break
|
||||||
|
}
|
||||||
if (preset === 'today') {
|
case 'yesterday': {
|
||||||
// 今日:从凌晨开始
|
const yesterday = new Date()
|
||||||
startDate.setHours(0, 0, 0, 0)
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
endDate.setHours(23, 59, 59, 999)
|
startDate = getSystemTimezoneDay(yesterday, true)
|
||||||
} else {
|
endDate = getSystemTimezoneDay(yesterday, false)
|
||||||
// 其他预设:按天数计算
|
break
|
||||||
startDate.setDate(now.getDate() - (option.days - 1))
|
}
|
||||||
startDate.setHours(0, 0, 0, 0)
|
case 'dayBefore': {
|
||||||
endDate.setHours(23, 59, 59, 999)
|
const dayBefore = new Date()
|
||||||
|
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||||
|
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||||
|
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
endDate = new Date(now)
|
||||||
|
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
startDate = new Date(now)
|
||||||
|
endDate = new Date(now)
|
||||||
|
|
||||||
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
|
if (normalizedPreset === 'today') {
|
||||||
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
|
startDate.setHours(0, 0, 0, 0)
|
||||||
|
endDate.setHours(23, 59, 59, 999)
|
||||||
// 设置 customRange 为 Element Plus 需要的格式
|
} else if (option?.days) {
|
||||||
// 对于小时粒度的昨天/前天,需要特殊处理显示
|
startDate.setDate(now.getDate() - (option.days - 1))
|
||||||
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
|
startDate.setHours(0, 0, 0, 0)
|
||||||
// 获取本地日期
|
endDate.setHours(23, 59, 59, 999)
|
||||||
const targetDate = new Date()
|
|
||||||
if (preset === 'yesterday') {
|
|
||||||
targetDate.setDate(targetDate.getDate() - 1)
|
|
||||||
} else {
|
|
||||||
targetDate.setDate(targetDate.getDate() - 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示系统时区的完整一天
|
|
||||||
const year = targetDate.getFullYear()
|
|
||||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(targetDate.getDate()).padStart(2, '0')
|
|
||||||
|
|
||||||
dateFilter.value.customRange = [
|
|
||||||
`${year}-${month}-${day} 00:00:00`,
|
|
||||||
`${year}-${month}-${day} 23:59:59`
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// 其他情况:近24小时或天粒度
|
|
||||||
const formatDateForDisplay = (date) => {
|
|
||||||
// 固定使用UTC+8来显示时间
|
|
||||||
const systemTz = 8
|
|
||||||
const tzOffset = systemTz * 60 * 60 * 1000
|
|
||||||
const localTime = new Date(date.getTime() + tzOffset)
|
|
||||||
|
|
||||||
const year = localTime.getUTCFullYear()
|
|
||||||
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(localTime.getUTCDate()).padStart(2, '0')
|
|
||||||
const hours = String(localTime.getUTCHours()).padStart(2, '0')
|
|
||||||
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
dateFilter.value.customRange = [
|
|
||||||
formatDateForDisplay(startDate),
|
|
||||||
formatDateForDisplay(endDate)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发数据刷新
|
const formatDateForDisplay = (date) => {
|
||||||
refreshChartsData()
|
// 使用本地时间直接格式化,避免多余的时区偏移导致日期错位
|
||||||
|
const localTime = new Date(date)
|
||||||
|
const year = localTime.getFullYear()
|
||||||
|
const month = String(localTime.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(localTime.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(localTime.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(localTime.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(localTime.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
dateFilter.value.customStart = startDate ? startDate.toISOString().split('T')[0] : ''
|
||||||
|
dateFilter.value.customEnd = endDate ? endDate.toISOString().split('T')[0] : ''
|
||||||
|
dateFilter.value.customRange =
|
||||||
|
startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null
|
||||||
|
|
||||||
|
if (!skipSave) {
|
||||||
|
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
refreshChartsData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCustomDateRangeChange(value) {
|
function onCustomDateRangeChange(value) {
|
||||||
@@ -751,20 +759,17 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
refreshChartsData()
|
refreshChartsData()
|
||||||
} else if (value === null) {
|
} else if (value === null) {
|
||||||
// 清空时恢复默认
|
// 清空时恢复默认
|
||||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
|
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTrendGranularity(granularity) {
|
function setTrendGranularity(granularity, options = {}) {
|
||||||
|
const { silent = false, skipSave = false, presetOverride } = options
|
||||||
trendGranularity.value = granularity
|
trendGranularity.value = granularity
|
||||||
|
|
||||||
// 根据粒度更新预设选项
|
// 根据粒度更新预设选项
|
||||||
if (granularity === 'hour') {
|
if (granularity === 'hour') {
|
||||||
dateFilter.value.presetOptions = [
|
dateFilter.value.presetOptions = getPresetOptions('hour')
|
||||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
|
||||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
|
||||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 检查当前自定义日期范围是否超过24小时
|
// 检查当前自定义日期范围是否超过24小时
|
||||||
if (
|
if (
|
||||||
@@ -777,46 +782,53 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||||
if (hoursDiff > 24) {
|
if (hoursDiff > 24) {
|
||||||
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
||||||
setDateFilterPreset('last24h')
|
setDateFilterPreset('last24h', { silent, skipSave })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
|
|
||||||
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
|
|
||||||
setDateFilterPreset('last24h')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 天粒度
|
// 天粒度
|
||||||
dateFilter.value.presetOptions = [
|
dateFilter.value.presetOptions = getPresetOptions('day')
|
||||||
{ value: 'today', label: '今日', days: 1 },
|
|
||||||
{ value: '7days', label: '7天', days: 7 },
|
|
||||||
{ value: '30days', label: '30天', days: 30 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
|
||||||
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
|
|
||||||
setDateFilterPreset('7days')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发数据刷新
|
if (dateFilter.value.type === 'custom') {
|
||||||
refreshChartsData()
|
if (!skipSave) {
|
||||||
|
persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
refreshChartsData()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPreset =
|
||||||
|
presetOverride ||
|
||||||
|
normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value)
|
||||||
|
|
||||||
|
setDateFilterPreset(nextPreset, { silent: true, skipSave: true })
|
||||||
|
|
||||||
|
if (!skipSave) {
|
||||||
|
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
refreshChartsData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshChartsData() {
|
async function refreshChartsData() {
|
||||||
// 根据当前筛选条件刷新数据
|
// 根据当前筛选条件刷新数据
|
||||||
let days
|
let days
|
||||||
let modelPeriod = 'monthly'
|
let modelPeriod = 'monthly'
|
||||||
|
const effectiveGranularity = getEffectiveGranularity()
|
||||||
|
|
||||||
if (dateFilter.value.type === 'preset') {
|
if (dateFilter.value.type === 'preset') {
|
||||||
const option = dateFilter.value.presetOptions.find(
|
const option = dateFilter.value.presetOptions.find(
|
||||||
(opt) => opt.value === dateFilter.value.preset
|
(opt) => opt.value === dateFilter.value.preset
|
||||||
)
|
)
|
||||||
|
|
||||||
if (trendGranularity.value === 'hour') {
|
if (effectiveGranularity === 'hour') {
|
||||||
// 小时粒度
|
// 小时粒度
|
||||||
days = 1 // 小时粒度默认查看1天的数据
|
days = 1 // 小时粒度默认查看1天的数据
|
||||||
modelPeriod = 'daily' // 小时粒度使用日统计
|
modelPeriod = 'daily' // 小时粒度使用日统计
|
||||||
@@ -832,7 +844,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 自定义日期范围
|
// 自定义日期范围
|
||||||
if (trendGranularity.value === 'hour') {
|
if (effectiveGranularity === 'hour') {
|
||||||
// 小时粒度下的自定义范围,计算小时数
|
// 小时粒度下的自定义范围,计算小时数
|
||||||
const start = new Date(dateFilter.value.customRange[0])
|
const start = new Date(dateFilter.value.customRange[0])
|
||||||
const end = new Date(dateFilter.value.customRange[1])
|
const end = new Date(dateFilter.value.customRange[1])
|
||||||
@@ -845,16 +857,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadUsageTrend(days, trendGranularity.value),
|
loadUsageTrend(days, effectiveGranularity),
|
||||||
loadModelStats(modelPeriod),
|
loadModelStats(modelPeriod, effectiveGranularity),
|
||||||
loadApiKeysTrend(apiKeysTrendMetric.value),
|
loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity),
|
||||||
loadAccountUsageTrend(accountUsageGroup.value)
|
loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAccountUsageGroup(group) {
|
function setAccountUsageGroup(group) {
|
||||||
accountUsageGroup.value = group
|
accountUsageGroup.value = group
|
||||||
return loadAccountUsageTrend(group)
|
return loadAccountUsageTrend(group, getEffectiveGranularity())
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateDaysBetween(start, end) {
|
function calculateDaysBetween(start, end) {
|
||||||
@@ -870,6 +882,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
return date > new Date()
|
return date > new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化日期筛选:同步本地偏好并填充范围
|
||||||
|
setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true })
|
||||||
|
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4 p-4 lg:p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
账户请求详情时间线
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ accountDisplayName }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">渠道:{{ platformDisplayName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-clock text-blue-500" />
|
||||||
|
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||||
|
<span v-else>显示近 5000 条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalRequests) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalTokens) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ formatCost(summary.totalCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCost(summary.avgCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
class="max-w-[320px]"
|
||||||
|
clearable
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
type="datetimerange"
|
||||||
|
unlink-panels
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.model"
|
||||||
|
class="w-[180px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有模型"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="modelOption in availableModels"
|
||||||
|
:key="modelOption"
|
||||||
|
:label="modelOption"
|
||||||
|
:value="modelOption"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.apiKeyId"
|
||||||
|
class="w-[220px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有 API Key"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="apiKey in availableApiKeys"
|
||||||
|
:key="apiKey.id"
|
||||||
|
:label="apiKey.name || apiKey.id"
|
||||||
|
:value="apiKey.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||||
|
<el-option label="时间降序" value="desc" />
|
||||||
|
<el-option label="时间升序" value="asc" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||||
|
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||||
|
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-if="records.length === 0"
|
||||||
|
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-inbox text-2xl" />
|
||||||
|
<p>暂无记录</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
时间
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
API Key
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
模型
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输入
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输出
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
缓存(创/读)
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
总 Token
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
费用
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatDate(record.timestamp) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
ID: {{ record.apiKeyId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ record.model }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
{{ formatNumber(record.inputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.outputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatNumber(record.totalTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||||
|
>
|
||||||
|
{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 md:hidden">
|
||||||
|
<div
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.timestamp + record.model"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
渠道:{{ platformDisplayName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div>模型:{{ record.model }}</div>
|
||||||
|
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||||
|
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||||
|
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||||
|
<div>
|
||||||
|
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-yellow-600 dark:text-yellow-400">
|
||||||
|
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-4 pb-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
共 {{ pagination.totalRecords }} 条记录
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
:current-page="pagination.currentPage"
|
||||||
|
layout="prev, pager, next, sizes"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[20, 50, 100, 200]"
|
||||||
|
:total="pagination.totalRecords"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
import { formatNumber } from '@/utils/format'
|
||||||
|
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const accountId = computed(() => route.params.accountId)
|
||||||
|
const platform = computed(() => route.query.platform)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const records = ref([])
|
||||||
|
const availableModels = ref([])
|
||||||
|
const availableApiKeys = ref([])
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalRecords: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
dateRange: null,
|
||||||
|
model: '',
|
||||||
|
apiKeyId: '',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = reactive({
|
||||||
|
totalRequests: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
avgCost: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountInfo = reactive({
|
||||||
|
id: accountId.value,
|
||||||
|
name: '',
|
||||||
|
platform: platform.value || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const activeRecord = ref(null)
|
||||||
|
|
||||||
|
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
|
||||||
|
const platformDisplayName = computed(() => {
|
||||||
|
const map = {
|
||||||
|
claude: 'Claude官方',
|
||||||
|
'claude-console': 'Claude Console',
|
||||||
|
ccr: 'Claude Console Relay',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
'openai-responses': 'OpenAI Responses',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
'gemini-api': 'Gemini API',
|
||||||
|
droid: 'Droid',
|
||||||
|
unknown: '未知渠道'
|
||||||
|
}
|
||||||
|
const key = accountInfo.platform || platform.value || 'unknown'
|
||||||
|
return map[key] || '未知渠道'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateRangeHint = computed(() => {
|
||||||
|
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||||
|
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '--'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCost = (value) => {
|
||||||
|
const num = typeof value === 'number' ? value : 0
|
||||||
|
if (num >= 1) return `$${num.toFixed(2)}`
|
||||||
|
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||||
|
return `$${num.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParams = (page) => {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortOrder: filters.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.model) params.model = filters.model
|
||||||
|
if (filters.apiKeyId) params.apiKeyId = filters.apiKeyId
|
||||||
|
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||||
|
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||||
|
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||||
|
}
|
||||||
|
if (platform.value) {
|
||||||
|
params.platform = platform.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncResponseState = (data) => {
|
||||||
|
records.value = data.records || []
|
||||||
|
|
||||||
|
const pageInfo = data.pagination || {}
|
||||||
|
pagination.currentPage = pageInfo.currentPage || 1
|
||||||
|
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||||
|
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||||
|
|
||||||
|
const filterEcho = data.filters || {}
|
||||||
|
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||||
|
if (filterEcho.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
|
||||||
|
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||||
|
if (filterEcho.startDate && filterEcho.endDate) {
|
||||||
|
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||||
|
const currentRange = filters.dateRange || []
|
||||||
|
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||||
|
filters.dateRange = nextRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = data.summary || {}
|
||||||
|
summary.totalRequests = summaryData.totalRequests || 0
|
||||||
|
summary.totalTokens = summaryData.totalTokens || 0
|
||||||
|
summary.totalCost = summaryData.totalCost || 0
|
||||||
|
summary.avgCost = summaryData.avgCost || 0
|
||||||
|
|
||||||
|
accountInfo.id = data.accountInfo?.id || accountId.value
|
||||||
|
accountInfo.name = data.accountInfo?.name || ''
|
||||||
|
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
|
||||||
|
|
||||||
|
availableModels.value = data.availableFilters?.models || []
|
||||||
|
availableApiKeys.value = data.availableFilters?.apiKeys || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecords = async (page = pagination.currentPage) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
|
params: buildParams(page)
|
||||||
|
})
|
||||||
|
syncResponseState(response.data || {})
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
pagination.currentPage = page
|
||||||
|
fetchRecords(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pagination.pageSize = size
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.model = ''
|
||||||
|
filters.apiKeyId = ''
|
||||||
|
filters.dateRange = null
|
||||||
|
filters.sortOrder = 'desc'
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = (record) => {
|
||||||
|
activeRecord.value = record
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
detailVisible.value = false
|
||||||
|
activeRecord.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/accounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = async () => {
|
||||||
|
if (exporting.value) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const aggregated = []
|
||||||
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||||
|
|
||||||
|
while (page <= totalPages && page <= maxPages) {
|
||||||
|
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
|
params: { ...buildParams(page), pageSize: 200 }
|
||||||
|
})
|
||||||
|
const payload = response.data || {}
|
||||||
|
aggregated.push(...(payload.records || []))
|
||||||
|
totalPages = payload.pagination?.totalPages || 1
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregated.length === 0) {
|
||||||
|
showToast('没有可导出的记录', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'时间',
|
||||||
|
'API Key',
|
||||||
|
'模型',
|
||||||
|
'输入Token',
|
||||||
|
'输出Token',
|
||||||
|
'缓存创建Token',
|
||||||
|
'缓存读取Token',
|
||||||
|
'总Token',
|
||||||
|
'费用'
|
||||||
|
]
|
||||||
|
|
||||||
|
const csvRows = [headers.join(',')]
|
||||||
|
aggregated.forEach((record) => {
|
||||||
|
const row = [
|
||||||
|
formatDate(record.timestamp),
|
||||||
|
record.apiKeyName || record.apiKeyId || '',
|
||||||
|
record.model || '',
|
||||||
|
record.inputTokens || 0,
|
||||||
|
record.outputTokens || 0,
|
||||||
|
record.cacheCreateTokens || 0,
|
||||||
|
record.cacheReadTokens || 0,
|
||||||
|
record.totalTokens || 0,
|
||||||
|
record.costFormatted || formatCost(record.cost)
|
||||||
|
]
|
||||||
|
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = new Blob([csvRows.join('\n')], {
|
||||||
|
type: 'text/csv;charset=utf-8;'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `account-${accountId.value}-usage-records.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
showToast('导出 CSV 成功', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filters.model, filters.apiKeyId, filters.sortOrder],
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.dateRange,
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4 p-4 lg:p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
API Key 请求详情时间线
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ apiKeyDisplayName }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-clock text-blue-500" />
|
||||||
|
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||||
|
<span v-else>显示近 5000 条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalRequests) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalTokens) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ formatCost(summary.totalCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCost(summary.avgCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
class="max-w-[320px]"
|
||||||
|
clearable
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
type="datetimerange"
|
||||||
|
unlink-panels
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.model"
|
||||||
|
class="w-[180px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有模型"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="modelOption in availableModels"
|
||||||
|
:key="modelOption"
|
||||||
|
:label="modelOption"
|
||||||
|
:value="modelOption"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.accountId"
|
||||||
|
class="w-[220px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有账户"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="account in availableAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
:label="`${account.name}(${account.accountTypeName})`"
|
||||||
|
:value="account.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||||
|
<el-option label="时间降序" value="desc" />
|
||||||
|
<el-option label="时间升序" value="asc" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||||
|
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||||
|
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-if="records.length === 0"
|
||||||
|
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-inbox text-2xl" />
|
||||||
|
<p>暂无记录</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
时间
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
账户
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
模型
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输入
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输出
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
缓存(创/读)
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
总 Token
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
费用
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatDate(record.timestamp) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold">{{ record.accountName || '未知账户' }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ record.accountTypeName || '未知渠道' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ record.model }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
{{ formatNumber(record.inputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.outputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatNumber(record.totalTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||||
|
>
|
||||||
|
{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 md:hidden">
|
||||||
|
<div
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.timestamp + record.model"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ record.accountName || '未知账户' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDate(record.timestamp) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div>模型:{{ record.model }}</div>
|
||||||
|
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||||
|
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||||
|
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||||
|
<div>
|
||||||
|
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-yellow-600 dark:text-yellow-400">
|
||||||
|
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-4 pb-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
共 {{ pagination.totalRecords }} 条记录
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
:current-page="pagination.currentPage"
|
||||||
|
layout="prev, pager, next, sizes"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[20, 50, 100, 200]"
|
||||||
|
:total="pagination.totalRecords"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
import { formatNumber } from '@/utils/format'
|
||||||
|
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const keyId = computed(() => route.params.keyId)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const records = ref([])
|
||||||
|
const availableModels = ref([])
|
||||||
|
const availableAccounts = ref([])
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalRecords: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
dateRange: null,
|
||||||
|
model: '',
|
||||||
|
accountId: '',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = reactive({
|
||||||
|
totalRequests: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
avgCost: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKeyInfo = reactive({
|
||||||
|
id: keyId.value,
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const activeRecord = ref(null)
|
||||||
|
|
||||||
|
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
|
||||||
|
|
||||||
|
const dateRangeHint = computed(() => {
|
||||||
|
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||||
|
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '--'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCost = (value) => {
|
||||||
|
const num = typeof value === 'number' ? value : 0
|
||||||
|
if (num >= 1) return `$${num.toFixed(2)}`
|
||||||
|
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||||
|
return `$${num.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParams = (page) => {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortOrder: filters.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.model) params.model = filters.model
|
||||||
|
if (filters.accountId) params.accountId = filters.accountId
|
||||||
|
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||||
|
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||||
|
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncResponseState = (data) => {
|
||||||
|
records.value = data.records || []
|
||||||
|
|
||||||
|
const pageInfo = data.pagination || {}
|
||||||
|
pagination.currentPage = pageInfo.currentPage || 1
|
||||||
|
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||||
|
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||||
|
|
||||||
|
const filterEcho = data.filters || {}
|
||||||
|
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||||
|
if (filterEcho.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
|
||||||
|
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||||
|
if (filterEcho.startDate && filterEcho.endDate) {
|
||||||
|
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||||
|
const currentRange = filters.dateRange || []
|
||||||
|
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||||
|
filters.dateRange = nextRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = data.summary || {}
|
||||||
|
summary.totalRequests = summaryData.totalRequests || 0
|
||||||
|
summary.totalTokens = summaryData.totalTokens || 0
|
||||||
|
summary.totalCost = summaryData.totalCost || 0
|
||||||
|
summary.avgCost = summaryData.avgCost || 0
|
||||||
|
|
||||||
|
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
|
||||||
|
apiKeyInfo.name = data.apiKeyInfo?.name || ''
|
||||||
|
|
||||||
|
availableModels.value = data.availableFilters?.models || []
|
||||||
|
availableAccounts.value = data.availableFilters?.accounts || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecords = async (page = pagination.currentPage) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||||
|
params: buildParams(page)
|
||||||
|
})
|
||||||
|
syncResponseState(response.data || {})
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
pagination.currentPage = page
|
||||||
|
fetchRecords(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pagination.pageSize = size
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.model = ''
|
||||||
|
filters.accountId = ''
|
||||||
|
filters.dateRange = null
|
||||||
|
filters.sortOrder = 'desc'
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = (record) => {
|
||||||
|
activeRecord.value = record
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
detailVisible.value = false
|
||||||
|
activeRecord.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/api-keys')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = async () => {
|
||||||
|
if (exporting.value) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const aggregated = []
|
||||||
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||||
|
|
||||||
|
while (page <= totalPages && page <= maxPages) {
|
||||||
|
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||||
|
params: { ...buildParams(page), pageSize: 200 }
|
||||||
|
})
|
||||||
|
const payload = response.data || {}
|
||||||
|
aggregated.push(...(payload.records || []))
|
||||||
|
totalPages = payload.pagination?.totalPages || 1
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregated.length === 0) {
|
||||||
|
showToast('没有可导出的记录', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'时间',
|
||||||
|
'账户',
|
||||||
|
'渠道',
|
||||||
|
'模型',
|
||||||
|
'输入Token',
|
||||||
|
'输出Token',
|
||||||
|
'缓存创建Token',
|
||||||
|
'缓存读取Token',
|
||||||
|
'总Token',
|
||||||
|
'费用'
|
||||||
|
]
|
||||||
|
|
||||||
|
const csvRows = [headers.join(',')]
|
||||||
|
aggregated.forEach((record) => {
|
||||||
|
const row = [
|
||||||
|
formatDate(record.timestamp),
|
||||||
|
record.accountName || '',
|
||||||
|
record.accountTypeName || '',
|
||||||
|
record.model || '',
|
||||||
|
record.inputTokens || 0,
|
||||||
|
record.outputTokens || 0,
|
||||||
|
record.cacheCreateTokens || 0,
|
||||||
|
record.cacheReadTokens || 0,
|
||||||
|
record.totalTokens || 0,
|
||||||
|
record.costFormatted || formatCost(record.cost)
|
||||||
|
]
|
||||||
|
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = new Blob([csvRows.join('\n')], {
|
||||||
|
type: 'text/csv;charset=utf-8;'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `api-key-${keyId.value}-usage-records.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
showToast('导出 CSV 成功', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filters.model, filters.accountId, filters.sortOrder],
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.dateRange,
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -116,6 +116,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型筛选器 -->
|
||||||
|
<div class="group relative min-w-[140px]">
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<div class="relative">
|
||||||
|
<CustomDropdown
|
||||||
|
v-model="selectedModels"
|
||||||
|
icon="fa-cube"
|
||||||
|
icon-color="text-orange-500"
|
||||||
|
:multiple="true"
|
||||||
|
:options="modelOptions"
|
||||||
|
placeholder="所有模型"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="selectedModels.length > 0"
|
||||||
|
class="absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-xs text-white shadow-sm"
|
||||||
|
>
|
||||||
|
{{ selectedModels.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 搜索模式与搜索框 -->
|
<!-- 搜索模式与搜索框 -->
|
||||||
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="sm:w-44">
|
<div class="sm:w-44">
|
||||||
@@ -2085,17 +2108,18 @@
|
|||||||
@save="handleSaveExpiry"
|
@save="handleSaveExpiry"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 使用详情弹窗 -->
|
|
||||||
<UsageDetailModal
|
<UsageDetailModal
|
||||||
:api-key="selectedApiKeyForDetail || {}"
|
:api-key="selectedApiKeyForDetail || {}"
|
||||||
:show="showUsageDetailModal"
|
:show="showUsageDetailModal"
|
||||||
@close="showUsageDetailModal = false"
|
@close="showUsageDetailModal = false"
|
||||||
|
@open-timeline="openTimeline"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
@@ -2114,6 +2138,7 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
|||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
|
const router = useRouter()
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
@@ -2218,6 +2243,10 @@ const selectedApiKeyForDetail = ref(null)
|
|||||||
const selectedTagFilter = ref('')
|
const selectedTagFilter = ref('')
|
||||||
const availableTags = ref([])
|
const availableTags = ref([])
|
||||||
|
|
||||||
|
// 模型筛选相关
|
||||||
|
const selectedModels = ref([])
|
||||||
|
const availableModels = ref([])
|
||||||
|
|
||||||
// 搜索相关
|
// 搜索相关
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchMode = ref('apiKey')
|
const searchMode = ref('apiKey')
|
||||||
@@ -2234,6 +2263,14 @@ const tagOptions = computed(() => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const modelOptions = computed(() => {
|
||||||
|
return availableModels.value.map((model) => ({
|
||||||
|
value: model,
|
||||||
|
label: model,
|
||||||
|
icon: 'fa-cube'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
const selectedTagCount = computed(() => {
|
const selectedTagCount = computed(() => {
|
||||||
if (!selectedTagFilter.value) return 0
|
if (!selectedTagFilter.value) return 0
|
||||||
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
|
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
|
||||||
@@ -2472,6 +2509,18 @@ const loadAccounts = async (forceRefresh = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载已使用的模型列表
|
||||||
|
const loadUsedModels = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/admin/api-keys/used-models')
|
||||||
|
if (data.success) {
|
||||||
|
availableModels.value = data.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load used models:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载API Keys(使用后端分页)
|
// 加载API Keys(使用后端分页)
|
||||||
const loadApiKeys = async (clearStatsCache = true) => {
|
const loadApiKeys = async (clearStatsCache = true) => {
|
||||||
apiKeysLoading.value = true
|
apiKeysLoading.value = true
|
||||||
@@ -2500,6 +2549,11 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
|||||||
params.set('tag', selectedTagFilter.value)
|
params.set('tag', selectedTagFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型筛选参数
|
||||||
|
if (selectedModels.value.length > 0) {
|
||||||
|
params.set('models', selectedModels.value.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
// 排序参数(支持费用排序)
|
// 排序参数(支持费用排序)
|
||||||
const validSortFields = [
|
const validSortFields = [
|
||||||
'name',
|
'name',
|
||||||
@@ -4193,19 +4247,15 @@ const formatWindowTime = (seconds) => {
|
|||||||
|
|
||||||
// 显示使用详情
|
// 显示使用详情
|
||||||
const showUsageDetails = (apiKey) => {
|
const showUsageDetails = (apiKey) => {
|
||||||
// 获取异步加载的统计数据
|
|
||||||
const cachedStats = getCachedStats(apiKey.id)
|
const cachedStats = getCachedStats(apiKey.id)
|
||||||
|
|
||||||
// 合并异步统计数据到 apiKey 对象
|
|
||||||
const enrichedApiKey = {
|
const enrichedApiKey = {
|
||||||
...apiKey,
|
...apiKey,
|
||||||
// 合并实时限制数据
|
|
||||||
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
|
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
|
||||||
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
|
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
|
||||||
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
|
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
|
||||||
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
|
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
|
||||||
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
|
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
|
||||||
// 合并 usage 数据(用于详情弹窗中的统计卡片)
|
|
||||||
usage: {
|
usage: {
|
||||||
...apiKey.usage,
|
...apiKey.usage,
|
||||||
total: {
|
total: {
|
||||||
@@ -4226,6 +4276,13 @@ const showUsageDetails = (apiKey) => {
|
|||||||
showUsageDetailModal.value = true
|
showUsageDetailModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTimeline = (keyId) => {
|
||||||
|
const id = keyId || selectedApiKeyForDetail.value?.id
|
||||||
|
if (!id) return
|
||||||
|
showUsageDetailModal.value = false
|
||||||
|
router.push(`/api-keys/${id}/usage-records`)
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
|
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
|
||||||
// const formatTime = (seconds) => {
|
// const formatTime = (seconds) => {
|
||||||
// if (seconds === null || seconds === undefined) return '--:--'
|
// if (seconds === null || seconds === undefined) return '--:--'
|
||||||
@@ -4706,6 +4763,12 @@ watch(selectedTagFilter, () => {
|
|||||||
loadApiKeys(false)
|
loadApiKeys(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听模型筛选变化
|
||||||
|
watch(selectedModels, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadApiKeys(false)
|
||||||
|
})
|
||||||
|
|
||||||
// 监听排序变化,重新加载数据
|
// 监听排序变化,重新加载数据
|
||||||
watch([apiKeysSortBy, apiKeysSortOrder], () => {
|
watch([apiKeysSortBy, apiKeysSortOrder], () => {
|
||||||
loadApiKeys(false)
|
loadApiKeys(false)
|
||||||
@@ -4740,7 +4803,7 @@ onMounted(async () => {
|
|||||||
fetchCostSortStatus()
|
fetchCostSortStatus()
|
||||||
|
|
||||||
// 先加载 API Keys(优先显示列表)
|
// 先加载 API Keys(优先显示列表)
|
||||||
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
|
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys(), loadUsedModels()])
|
||||||
|
|
||||||
// 初始化全选状态
|
// 初始化全选状态
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
@@ -4841,40 +4904,40 @@ onUnmounted(() => {
|
|||||||
z-index: 12;
|
z-index: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保操作列在浅色模式下有正确的背景 */
|
/* 确保操作列在浅色模式下有正确的背景 - 使用纯色避免滚动时重叠 */
|
||||||
.table-container thead .operations-column {
|
.table-container thead .operations-column {
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container thead .operations-column {
|
.dark .table-container thead .operations-column {
|
||||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tbody 中的操作列背景处理 */
|
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
||||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
.table-container tbody tr:nth-child(odd) .operations-column {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .operations-column {
|
.table-container tbody tr:nth-child(even) .operations-column {
|
||||||
background-color: rgba(249, 250, 251, 0.7);
|
background-color: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
||||||
background-color: rgba(31, 41, 55, 0.4);
|
background-color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
||||||
background-color: rgba(55, 65, 81, 0.3);
|
background-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* hover 状态下的操作列背景 */
|
/* hover 状态下的操作列背景 */
|
||||||
.table-container tbody tr:hover .operations-column {
|
.table-container tbody tr:hover .operations-column {
|
||||||
background-color: rgba(239, 246, 255, 0.6);
|
background-color: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .operations-column {
|
.dark .table-container tbody tr:hover .operations-column {
|
||||||
background-color: rgba(30, 58, 138, 0.2);
|
background-color: #1e3a5f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container tbody .operations-column {
|
.table-container tbody .operations-column {
|
||||||
@@ -4892,19 +4955,19 @@ onUnmounted(() => {
|
|||||||
z-index: 12;
|
z-index: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表头左侧固定列背景 */
|
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
||||||
.table-container thead .checkbox-column,
|
.table-container thead .checkbox-column,
|
||||||
.table-container thead .name-column {
|
.table-container thead .name-column {
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container thead .checkbox-column,
|
.dark .table-container thead .checkbox-column,
|
||||||
.dark .table-container thead .name-column {
|
.dark .table-container thead .name-column {
|
||||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tbody 中的左侧固定列背景处理 */
|
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
||||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||||
.table-container tbody tr:nth-child(odd) .name-column {
|
.table-container tbody tr:nth-child(odd) .name-column {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
@@ -4912,28 +4975,28 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
.table-container tbody tr:nth-child(even) .checkbox-column,
|
||||||
.table-container tbody tr:nth-child(even) .name-column {
|
.table-container tbody tr:nth-child(even) .name-column {
|
||||||
background-color: rgba(249, 250, 251, 0.7);
|
background-color: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
|
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||||
.dark .table-container tbody tr:nth-child(odd) .name-column {
|
.dark .table-container tbody tr:nth-child(odd) .name-column {
|
||||||
background-color: rgba(31, 41, 55, 0.4);
|
background-color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
||||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
.dark .table-container tbody tr:nth-child(even) .name-column {
|
||||||
background-color: rgba(55, 65, 81, 0.3);
|
background-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* hover 状态下的左侧固定列背景 */
|
/* hover 状态下的左侧固定列背景 */
|
||||||
.table-container tbody tr:hover .checkbox-column,
|
.table-container tbody tr:hover .checkbox-column,
|
||||||
.table-container tbody tr:hover .name-column {
|
.table-container tbody tr:hover .name-column {
|
||||||
background-color: rgba(239, 246, 255, 0.6);
|
background-color: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .checkbox-column,
|
.dark .table-container tbody tr:hover .checkbox-column,
|
||||||
.dark .table-container tbody tr:hover .name-column {
|
.dark .table-container tbody tr:hover .name-column {
|
||||||
background-color: rgba(30, 58, 138, 0.2);
|
background-color: #1e3a5f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 名称列右侧阴影(分隔效果) */
|
/* 名称列右侧阴影(分隔效果) */
|
||||||
|
|||||||
@@ -1036,6 +1036,7 @@ function createUsageTrendChart() {
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
display: true,
|
display: true,
|
||||||
position: 'left',
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Token数量',
|
text: 'Token数量',
|
||||||
@@ -1055,6 +1056,7 @@ function createUsageTrendChart() {
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
display: true,
|
display: true,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
|
min: 0,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: '请求数',
|
text: '请求数',
|
||||||
@@ -1073,7 +1075,8 @@ function createUsageTrendChart() {
|
|||||||
y2: {
|
y2: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
display: false, // 隐藏费用轴,在tooltip中显示
|
display: false, // 隐藏费用轴,在tooltip中显示
|
||||||
position: 'right'
|
position: 'right',
|
||||||
|
min: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1253,6 +1256,7 @@ function createApiKeysUsageTrendChart() {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
|
min: 0,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
|
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
|
||||||
@@ -1428,6 +1432,7 @@ function createAccountUsageTrendChart() {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
|
min: 0,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: '消耗金额 (USD)',
|
text: '消耗金额 (USD)',
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<i class="fas fa-bell mr-2"></i>
|
<i class="fas fa-bell mr-2"></i>
|
||||||
通知设置
|
通知设置
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeSection === 'claude'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeSection = 'claude'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-2"></i>
|
||||||
|
Claude 转发
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -629,6 +641,390 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 转发配置部分 -->
|
||||||
|
<div v-show="activeSection === 'claude'">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="claudeConfigLoading" class="py-12 text-center">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Claude Code 客户端限制 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-orange-500 to-amber-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-terminal"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
仅允许 Claude Code 客户端
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,所有
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/api/v1/messages</code
|
||||||
|
>
|
||||||
|
和
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/claude/v1/messages</code
|
||||||
|
>
|
||||||
|
端点将强制验证 Claude Code CLI 客户端
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.claudeCodeOnlyEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-orange-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-orange-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-amber-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
此设置与 API Key 级别的客户端限制是 <strong>OR 逻辑</strong>:全局启用或 API
|
||||||
|
Key 设置中启用,都会执行 Claude Code 验证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局会话绑定 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
强制会话绑定
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.globalSessionBindingEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-purple-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-purple-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绑定配置详情(仅在启用时显示) -->
|
||||||
|
<div v-if="claudeConfig.globalSessionBindingEnabled" class="mt-6 space-y-4">
|
||||||
|
<!-- 绑定有效期 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-clock mr-2 text-gray-400"></i>
|
||||||
|
绑定有效期(天)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.sessionBindingTtlDays"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="365"
|
||||||
|
min="1"
|
||||||
|
placeholder="30"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
会话绑定到账户后的有效时间,过期后会自动解除绑定
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示消息 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 text-gray-400"></i>
|
||||||
|
旧会话污染提示
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="claudeConfig.sessionBindingErrorMessage"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
placeholder="你的本地session已污染,请清理后使用。"
|
||||||
|
rows="2"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-purple-50 p-4 dark:bg-purple-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mt-0.5 text-purple-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>工作原理:</strong>系统会提取请求中的原始 session ID (来自
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>metadata.user_id</code
|
||||||
|
>), 并将其与首次调度的账户绑定。后续使用相同 session ID
|
||||||
|
的请求将自动路由到同一账户。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>新会话识别:</strong>如果绑定会话历史中没有该sessionId但请求中
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>messages.length > 1</code
|
||||||
|
>, 系统会认为这是一个污染的会话并拒绝请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户消息串行队列 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-list-ol"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
用户消息串行队列
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,同一账户的用户消息请求将串行执行,并在请求之间添加延迟,防止触发上游限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.userMessageQueueEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-teal-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-teal-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-teal-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 队列配置详情(仅在启用时显示) -->
|
||||||
|
<div v-if="claudeConfig.userMessageQueueEnabled" class="mt-6 space-y-4">
|
||||||
|
<!-- 请求间隔 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-hourglass-half mr-2 text-gray-400"></i>
|
||||||
|
请求间隔(毫秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.userMessageQueueDelayMs"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="10000"
|
||||||
|
min="0"
|
||||||
|
placeholder="200"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
同一账户的用户消息请求之间的最小间隔时间(0-10000毫秒)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 队列超时 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-stopwatch mr-2 text-gray-400"></i>
|
||||||
|
队列超时(毫秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.userMessageQueueTimeoutMs"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="300000"
|
||||||
|
min="1000"
|
||||||
|
placeholder="30000"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
请求在队列中等待的最大时间,超时将返回 503 错误(1000-300000毫秒)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-teal-50 p-4 dark:bg-teal-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-teal-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-teal-700 dark:text-teal-300">
|
||||||
|
<strong>工作原理:</strong>系统检测请求中最后一条消息的
|
||||||
|
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">role</code>
|
||||||
|
是否为
|
||||||
|
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">user</code
|
||||||
|
>。用户消息请求需要排队串行执行,而工具调用结果、助手消息续传等不受此限制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 并发请求排队 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-layer-group text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
并发请求排队
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
当 API Key 并发请求超限时进入队列等待,而非直接拒绝
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.concurrentRequestQueueEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排队配置详情(仅在启用时显示) -->
|
||||||
|
<div v-if="claudeConfig.concurrentRequestQueueEnabled" class="mt-6 space-y-4">
|
||||||
|
<!-- 固定最小排队数 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-list-ol mr-2 text-gray-400"></i>
|
||||||
|
固定最小排队数
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.concurrentRequestQueueMaxSize"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="100"
|
||||||
|
min="1"
|
||||||
|
placeholder="3"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
最大排队数的固定最小值(1-100)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排队数倍数 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-times mr-2 text-gray-400"></i>
|
||||||
|
排队数倍数
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.concurrentRequestQueueMaxSizeMultiplier"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="10"
|
||||||
|
min="0"
|
||||||
|
placeholder="1"
|
||||||
|
step="0.5"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
最大排队数 = MAX(倍数 × 并发限制, 固定值),设为 0 则仅使用固定值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排队超时时间 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-stopwatch mr-2 text-gray-400"></i>
|
||||||
|
排队超时时间(毫秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.concurrentRequestQueueTimeoutMs"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="300000"
|
||||||
|
min="5000"
|
||||||
|
placeholder="10000"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
请求在排队中等待的最大时间,超时将返回 429 错误(5秒-5分钟,默认10秒)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-blue-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<strong>工作原理:</strong>当 API Key 的并发请求超过
|
||||||
|
<code class="rounded bg-blue-100 px-1 dark:bg-blue-800"
|
||||||
|
>concurrencyLimit</code
|
||||||
|
>
|
||||||
|
时,超限请求会进入队列等待而非直接返回 429。适合 Claude Code Agent
|
||||||
|
并行工具调用场景。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 配置更新信息 -->
|
||||||
|
<div
|
||||||
|
v-if="claudeConfig.updatedAt"
|
||||||
|
class="rounded-lg bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mr-2"></i>
|
||||||
|
最后更新:{{ formatDateTime(claudeConfig.updatedAt) }}
|
||||||
|
<span v-if="claudeConfig.updatedBy" class="ml-2">
|
||||||
|
由 <strong>{{ claudeConfig.updatedBy }}</strong> 修改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1274,6 +1670,24 @@ const webhookConfig = ref({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Claude 转发配置
|
||||||
|
const claudeConfigLoading = ref(false)
|
||||||
|
const claudeConfig = ref({
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30,
|
||||||
|
userMessageQueueEnabled: false, // 与后端默认值保持一致
|
||||||
|
userMessageQueueDelayMs: 200,
|
||||||
|
userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待)
|
||||||
|
concurrentRequestQueueEnabled: false,
|
||||||
|
concurrentRequestQueueMaxSize: 3,
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier: 0,
|
||||||
|
concurrentRequestQueueTimeoutMs: 10000,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
})
|
||||||
|
|
||||||
// 平台表单相关
|
// 平台表单相关
|
||||||
const showAddPlatformModal = ref(false)
|
const showAddPlatformModal = ref(false)
|
||||||
const editingPlatform = ref(null)
|
const editingPlatform = ref(null)
|
||||||
@@ -1311,6 +1725,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
if (newSection === 'webhook') {
|
if (newSection === 'webhook') {
|
||||||
await loadWebhookConfig()
|
await loadWebhookConfig()
|
||||||
|
} else if (newSection === 'claude') {
|
||||||
|
await loadClaudeConfig()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1522,6 +1938,83 @@ const saveWebhookConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载 Claude 转发配置
|
||||||
|
const loadClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
claudeConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/claude-relay-config', {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false,
|
||||||
|
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
||||||
|
sessionBindingErrorMessage:
|
||||||
|
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
|
||||||
|
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致
|
||||||
|
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
|
||||||
|
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致
|
||||||
|
concurrentRequestQueueEnabled: response.config?.concurrentRequestQueueEnabled ?? false,
|
||||||
|
concurrentRequestQueueMaxSize: response.config?.concurrentRequestQueueMaxSize ?? 3,
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier:
|
||||||
|
response.config?.concurrentRequestQueueMaxSizeMultiplier ?? 0,
|
||||||
|
concurrentRequestQueueTimeoutMs: response.config?.concurrentRequestQueueTimeoutMs ?? 10000,
|
||||||
|
updatedAt: response.config?.updatedAt || null,
|
||||||
|
updatedBy: response.config?.updatedBy || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('获取 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (isMounted.value) {
|
||||||
|
claudeConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Claude 转发配置
|
||||||
|
const saveClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays,
|
||||||
|
userMessageQueueEnabled: claudeConfig.value.userMessageQueueEnabled,
|
||||||
|
userMessageQueueDelayMs: claudeConfig.value.userMessageQueueDelayMs,
|
||||||
|
userMessageQueueTimeoutMs: claudeConfig.value.userMessageQueueTimeoutMs,
|
||||||
|
concurrentRequestQueueEnabled: claudeConfig.value.concurrentRequestQueueEnabled,
|
||||||
|
concurrentRequestQueueMaxSize: claudeConfig.value.concurrentRequestQueueMaxSize,
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier:
|
||||||
|
claudeConfig.value.concurrentRequestQueueMaxSizeMultiplier,
|
||||||
|
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.put('/admin/claude-relay-config', payload, {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
...claudeConfig.value,
|
||||||
|
updatedAt: response.config?.updatedAt || new Date().toISOString(),
|
||||||
|
updatedBy: response.config?.updatedBy || null
|
||||||
|
}
|
||||||
|
showToast('Claude 转发配置已保存', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('保存 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
// Bark和SMTP平台不需要验证URL
|
// Bark和SMTP平台不需要验证URL
|
||||||
|
|||||||
Reference in New Issue
Block a user