mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
328 Commits
revert-292
...
revert-424
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f213e3e2 | ||
|
|
1d915d8327 | ||
|
|
af8350b850 | ||
|
|
a039d817db | ||
|
|
ebafbdcc55 | ||
|
|
67e72f1aaf | ||
|
|
99d72516ae | ||
|
|
5ea3623736 | ||
|
|
e36bacfd6b | ||
|
|
22e27738aa | ||
|
|
8522d20cad | ||
|
|
d5b9f809b0 | ||
|
|
022724336b | ||
|
|
482eb7c8f7 | ||
|
|
01eadea10b | ||
|
|
5f5826ce56 | ||
|
|
97b94eeff9 | ||
|
|
9836f88068 | ||
|
|
26d8c98c9d | ||
|
|
5706c32933 | ||
|
|
2de5191c05 | ||
|
|
2b40552eab | ||
|
|
30acf4a374 | ||
|
|
be7416386f | ||
|
|
1beed324d9 | ||
|
|
2e09896d0b | ||
|
|
e80c49c1ce | ||
|
|
27034997a6 | ||
|
|
24ad052d02 | ||
|
|
19ca374527 | ||
|
|
27c0804219 | ||
|
|
cd7959f3bf | ||
|
|
e88e97b485 | ||
|
|
4aae4aaec0 | ||
|
|
c7e1a3429d | ||
|
|
74d37486b8 | ||
|
|
1eadc94592 | ||
|
|
87591365bc | ||
|
|
f4b873315a | ||
|
|
cb1b7bc0e3 | ||
|
|
504b9e3ea7 | ||
|
|
19ad0cd5f8 | ||
|
|
009f7c84f6 | ||
|
|
7c4feec5aa | ||
|
|
b6b16d05f0 | ||
|
|
02989a7588 | ||
|
|
fef2c8c3c2 | ||
|
|
c0735b1bc5 | ||
|
|
0eb95b3b06 | ||
|
|
f667a95d88 | ||
|
|
03db930354 | ||
|
|
2fcfccb2fc | ||
|
|
78adf82f0d | ||
|
|
fe1f05fadd | ||
|
|
cd5573ecde | ||
|
|
fcc2f51f81 | ||
|
|
4fd4dbfa51 | ||
|
|
ce8706d1b6 | ||
|
|
d3fcd95b94 | ||
|
|
433f0c5f23 | ||
|
|
7712d5516c | ||
|
|
bdae9d6ceb | ||
|
|
08946c67ea | ||
|
|
8b0e9b8d8e | ||
|
|
1dd00e1463 | ||
|
|
5938180583 | ||
|
|
fb6d0e7f55 | ||
|
|
3c5068866c | ||
|
|
c0059c68eb | ||
|
|
7f9869ae20 | ||
|
|
61cf1166ff | ||
|
|
27fe3b6853 | ||
|
|
af3d688e98 | ||
|
|
1c3b74f45b | ||
|
|
929852a881 | ||
|
|
c58d8d2040 | ||
|
|
4ee9e0b546 | ||
|
|
8064bc24b9 | ||
|
|
4592773ea2 | ||
|
|
da744528bc | ||
|
|
d3577fae03 | ||
|
|
b581a618b9 | ||
|
|
f375f9f841 | ||
|
|
52820a7e49 | ||
|
|
5d677b4f17 | ||
|
|
268a262281 | ||
|
|
283362acd0 | ||
|
|
756918b0ce | ||
|
|
4c2660a2d3 | ||
|
|
908e323db0 | ||
|
|
97da7d44ba | ||
|
|
ca79e08c81 | ||
|
|
86ed5c6344 | ||
|
|
6e16df0b45 | ||
|
|
73d3df56e5 | ||
|
|
c4f1e7a411 | ||
|
|
3f5004626a | ||
|
|
7f8fae70e6 | ||
|
|
3239965cbe | ||
|
|
fec80a16fa | ||
|
|
399e6b9d8c | ||
|
|
d77605a8ad | ||
|
|
4dcd251662 | ||
|
|
5c8136ddd4 | ||
|
|
8b13403304 | ||
|
|
8cb9f52c1a | ||
|
|
3aa7c89e25 | ||
|
|
b46ccb10d0 | ||
|
|
f51d345ad9 | ||
|
|
bed7b7f000 | ||
|
|
bd2f25dc19 | ||
|
|
cc27b377d8 | ||
|
|
9cbf3195e0 | ||
|
|
9fa7602947 | ||
|
|
9bd7f7ae7b | ||
|
|
0a43bb2645 | ||
|
|
94c5c2e364 | ||
|
|
f284d5666f | ||
|
|
e824858d60 | ||
|
|
9d05c03a3a | ||
|
|
0fc5309ff9 | ||
|
|
92ec3ffc72 | ||
|
|
8c9d6381f3 | ||
|
|
fc5c60a9b4 | ||
|
|
8f43b9367b | ||
|
|
8dd58900d6 | ||
|
|
4e67e597b0 | ||
|
|
a9a560da67 | ||
|
|
b11305f4e9 | ||
|
|
ed02c8abec | ||
|
|
9e01095249 | ||
|
|
e28080bb51 | ||
|
|
4104858bc0 | ||
|
|
b4e7c760b2 | ||
|
|
6efd95ed9d | ||
|
|
abe18211c0 | ||
|
|
33bb5d7895 | ||
|
|
0cb58c099d | ||
|
|
6479db0b16 | ||
|
|
9d1906c0b1 | ||
|
|
2c2f772071 | ||
|
|
c14abe5132 | ||
|
|
cd2ccef5a1 | ||
|
|
3e6edae198 | ||
|
|
8b51c2ef64 | ||
|
|
d2f3f6866c | ||
|
|
0e746b1056 | ||
|
|
723f13eb2b | ||
|
|
d7c80b69e8 | ||
|
|
a71f0e58a2 | ||
|
|
56c48a4304 | ||
|
|
2f6e5ab289 | ||
|
|
96e505d662 | ||
|
|
d4989f5401 | ||
|
|
ed10fb06b2 | ||
|
|
503f20b06b | ||
|
|
19cf38d92d | ||
|
|
c16cfe60ab | ||
|
|
4cc937a144 | ||
|
|
7d20810179 | ||
|
|
4e8e630904 | ||
|
|
8c158d82fa | ||
|
|
d8e833ef1a | ||
|
|
bdd17a85e9 | ||
|
|
192fd19632 | ||
|
|
9d94475d3f | ||
|
|
b2e7d686fe | ||
|
|
ae727d381c | ||
|
|
4b0861eb7f | ||
|
|
861af192bf | ||
|
|
566f15768f | ||
|
|
0cc8714c3c | ||
|
|
d6745dbe4a | ||
|
|
75ac51bb57 | ||
|
|
6e353893d1 | ||
|
|
5a29502fcd | ||
|
|
aa869521c0 | ||
|
|
8f08d7843f | ||
|
|
1ff14e38cb | ||
|
|
86f92a774e | ||
|
|
5ed07f4407 | ||
|
|
ac9107aa5f | ||
|
|
5bed7c932b | ||
|
|
9dd1b07e45 | ||
|
|
69795f2ed0 | ||
|
|
39c49fe2bb | ||
|
|
7fa3ed239f | ||
|
|
3fd9110ba7 | ||
|
|
26c57148f7 | ||
|
|
631931990b | ||
|
|
79097d5b40 | ||
|
|
16d397125a | ||
|
|
3e6b7c729f | ||
|
|
eba52a6e88 | ||
|
|
8ab8cf4a7a | ||
|
|
6aeb05f685 | ||
|
|
ff99f5d123 | ||
|
|
2c0ffd07d0 | ||
|
|
54d1bc076c | ||
|
|
bec9cf565b | ||
|
|
f69333f312 | ||
|
|
0039569471 | ||
|
|
a5361c15a1 | ||
|
|
d93a157380 | ||
|
|
aeace0c5f0 | ||
|
|
088cf8401f | ||
|
|
a1005e91c8 | ||
|
|
b158a90b72 | ||
|
|
941cfacea9 | ||
|
|
1fc35197e1 | ||
|
|
2e6feeb1c1 | ||
|
|
da0ffa07ec | ||
|
|
886ec35edc | ||
|
|
58fcf6962c | ||
|
|
b0990e7169 | ||
|
|
3860f7d9b3 | ||
|
|
81ad098678 | ||
|
|
59d7705697 | ||
|
|
8b0d8088d1 | ||
|
|
9c7ec8758d | ||
|
|
d56da4d799 | ||
|
|
945e0ac198 | ||
|
|
37e6c14eac | ||
|
|
4627475b7c | ||
|
|
58958cf246 | ||
|
|
5ee98597e7 | ||
|
|
1165427df0 | ||
|
|
7a9e4abdd5 | ||
|
|
e973158472 | ||
|
|
9c3fec7568 | ||
|
|
558aa173fe | ||
|
|
1a9746c84d | ||
|
|
aa04487c79 | ||
|
|
3f570d5fc2 | ||
|
|
86c243e1a4 | ||
|
|
4e094c21b7 | ||
|
|
b1ca898dff | ||
|
|
85196911ce | ||
|
|
e00872f9db | ||
|
|
9f3fff1f27 | ||
|
|
23cb44f60f | ||
|
|
60428921a1 | ||
|
|
5406b5790c | ||
|
|
d0eef7e98e | ||
|
|
96cf49d3b7 | ||
|
|
f2c2bdf6d6 | ||
|
|
68603bc046 | ||
|
|
e2e621341c | ||
|
|
19eaed2f32 | ||
|
|
5cfa3cc72f | ||
|
|
c979be5aab | ||
|
|
e0c926c53d | ||
|
|
50b372473c | ||
|
|
246bdc928a | ||
|
|
f77ab03d18 | ||
|
|
26438e0c9b | ||
|
|
86f5a3e670 | ||
|
|
9a46310238 | ||
|
|
07e9bc1137 | ||
|
|
ef21c118e9 | ||
|
|
e84c6a5555 | ||
|
|
0240a17c1e | ||
|
|
e4078e36ad | ||
|
|
01274a6a96 | ||
|
|
87c2f1dfe2 | ||
|
|
7c4cbe6ed7 | ||
|
|
bf732b9525 | ||
|
|
b00d0eb9e1 | ||
|
|
1762669de4 | ||
|
|
dc3d311def | ||
|
|
a54622e3d7 | ||
|
|
3bc239e85c | ||
|
|
70c8cb5aff | ||
|
|
92f4fbcef3 | ||
|
|
2cf2574ebe | ||
|
|
06f7e3c28f | ||
|
|
90574bc4e6 | ||
|
|
d01bcdbaca | ||
|
|
76ec2e6afb | ||
|
|
34629a9bb2 | ||
|
|
c638c8b82c | ||
|
|
3f1117e8f6 | ||
|
|
a608b267ae | ||
|
|
43cf7d3c28 | ||
|
|
7c3257764c | ||
|
|
7ce55c006e | ||
|
|
71b3374761 | ||
|
|
1726e6d3f3 | ||
|
|
79c7d1d116 | ||
|
|
fb57cfd293 | ||
|
|
a7009e6864 | ||
|
|
fcc8387c24 | ||
|
|
d5f5e0f4dd | ||
|
|
b0ad541f5d | ||
|
|
77338276db | ||
|
|
7a0acbdfdc | ||
|
|
71ce1e33b7 | ||
|
|
94eed70cf2 | ||
|
|
6b4ce99237 | ||
|
|
283583d289 | ||
|
|
c80446ae98 | ||
|
|
65620a4cde | ||
|
|
a7c6445f36 | ||
|
|
4509f303e6 | ||
|
|
aff9966ed1 | ||
|
|
5d850a7c1c | ||
|
|
70e87de639 | ||
|
|
9efe429912 | ||
|
|
8ea150a975 | ||
|
|
c413fddec0 | ||
|
|
1ba55401f9 | ||
|
|
983cc520ae | ||
|
|
02a801c290 | ||
|
|
2756671117 | ||
|
|
a3c9e39401 | ||
|
|
9a46ac3928 | ||
|
|
bb60df8b41 | ||
|
|
aa86e062f1 | ||
|
|
4a1423615f | ||
|
|
d8af7959e2 | ||
|
|
1f3fd9c285 | ||
|
|
39c6e3146c | ||
|
|
1ad720304c | ||
|
|
2a0be1b187 | ||
|
|
8ab4ad32fe | ||
|
|
56e4630827 | ||
|
|
f193db926d | ||
|
|
eb150b4937 |
57
.env.example
57
.env.example
@@ -22,17 +22,30 @@ REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_ENABLE_TLS=
|
||||
|
||||
# 🔗 会话管理配置
|
||||
# 粘性会话TTL配置(小时),默认1小时
|
||||
STICKY_SESSION_TTL_HOURS=1
|
||||
# 续期阈值(分钟),默认0分钟(不续期)
|
||||
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15
|
||||
|
||||
# 🎯 Claude API 配置
|
||||
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
CLAUDE_API_VERSION=2023-06-01
|
||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||
|
||||
# 🚫 529错误处理配置
|
||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||
|
||||
# 🌐 代理配置
|
||||
DEFAULT_PROXY_TIMEOUT=60000
|
||||
DEFAULT_PROXY_TIMEOUT=600000
|
||||
MAX_PROXY_RETRIES=3
|
||||
# IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
|
||||
PROXY_USE_IPV4=true
|
||||
|
||||
# ⏱️ 请求超时配置
|
||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||
|
||||
# 📈 使用限制
|
||||
DEFAULT_TOKEN_LIMIT=1000000
|
||||
|
||||
@@ -55,14 +68,46 @@ WEB_LOGO_URL=/assets/logo.png
|
||||
|
||||
# 🛠️ 开发配置
|
||||
DEBUG=false
|
||||
DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
|
||||
ENABLE_CORS=true
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 🔒 客户端限制(可选)
|
||||
# ALLOW_CUSTOM_CLIENTS=false
|
||||
|
||||
# 📢 Webhook 通知配置
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
WEBHOOK_RETRIES=3
|
||||
# 🔐 LDAP 认证配置
|
||||
LDAP_ENABLED=false
|
||||
LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636
|
||||
LDAP_BIND_DN=cn=admin,dc=example,dc=com
|
||||
LDAP_BIND_PASSWORD=admin_password
|
||||
LDAP_SEARCH_BASE=dc=example,dc=com
|
||||
LDAP_SEARCH_FILTER=(uid={{username}})
|
||||
LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn
|
||||
LDAP_TIMEOUT=5000
|
||||
LDAP_CONNECT_TIMEOUT=10000
|
||||
|
||||
# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL)
|
||||
# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误)
|
||||
LDAP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# CA 证书文件路径 (可选,用于自定义CA证书)
|
||||
# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem
|
||||
# 客户端证书文件路径 (可选,用于双向认证)
|
||||
# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem
|
||||
# 客户端私钥文件路径 (可选,用于双向认证)
|
||||
# LDAP_TLS_KEY_FILE=/path/to/client-key.pem
|
||||
# 服务器名称 (可选,用于 SNI)
|
||||
# LDAP_TLS_SERVERNAME=ldap.example.com
|
||||
|
||||
# 🗺️ LDAP 用户属性映射
|
||||
LDAP_USER_ATTR_USERNAME=uid
|
||||
LDAP_USER_ATTR_DISPLAY_NAME=cn
|
||||
LDAP_USER_ATTR_EMAIL=mail
|
||||
LDAP_USER_ATTR_FIRST_NAME=givenName
|
||||
LDAP_USER_ATTR_LAST_NAME=sn
|
||||
|
||||
# 👥 用户管理配置
|
||||
USER_MANAGEMENT_ENABLED=false
|
||||
DEFAULT_USER_ROLE=user
|
||||
USER_SESSION_TIMEOUT=86400000
|
||||
MAX_API_KEYS_PER_USER=1
|
||||
ALLOW_USER_DELETE_API_KEYS=false
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -216,6 +216,10 @@ local/
|
||||
debug.log
|
||||
error.log
|
||||
access.log
|
||||
http-debug*.log
|
||||
logs/http-debug-*.log
|
||||
|
||||
src/middleware/debugInterceptor.js
|
||||
|
||||
# Session files
|
||||
sessions/
|
||||
|
||||
160
README.md
160
README.md
@@ -250,11 +250,6 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Webhook通知配置(可选)
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
WEBHOOK_RETRIES=3
|
||||
```
|
||||
|
||||
**编辑 `config/config.js` 文件:**
|
||||
@@ -479,103 +474,102 @@ claude
|
||||
gemini # 或其他 Gemini CLI 命令
|
||||
```
|
||||
|
||||
**Codex 设置环境变量:**
|
||||
**Codex 配置:**
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
|
||||
export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
|
||||
在 `~/.codex/config.toml` 文件中添加以下配置:
|
||||
|
||||
```toml
|
||||
model_provider = "crs"
|
||||
model = "gpt-5"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[model_providers.crs]
|
||||
name = "crs"
|
||||
base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
在 `~/.codex/auth.json` 文件中配置API密钥:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": "你的后台创建的API密钥"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 第三方工具API接入
|
||||
|
||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
|
||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
|
||||
|
||||
**Claude标准格式:**
|
||||
#### Cherry Studio 接入示例
|
||||
|
||||
Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置:
|
||||
|
||||
**1. Claude账号接入:**
|
||||
|
||||
```
|
||||
# 如果工具支持Claude标准格式,请使用该接口
|
||||
# API地址
|
||||
http://你的服务器:3000/claude/
|
||||
|
||||
# 模型ID示例
|
||||
claude-sonnet-4-20250514 # Claude Sonnet 4
|
||||
claude-opus-4-20250514 # Claude Opus 4
|
||||
```
|
||||
|
||||
**OpenAI兼容格式:**
|
||||
配置步骤:
|
||||
- 供应商类型选择"Anthropic"
|
||||
- API地址填入:`http://你的服务器:3000/claude/`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
**2. Gemini账号接入:**
|
||||
|
||||
```
|
||||
# 适用于需要OpenAI格式的第三方工具
|
||||
http://你的服务器:3000/openai/claude/v1/
|
||||
# API地址
|
||||
http://你的服务器:3000/gemini/
|
||||
|
||||
# 模型ID示例
|
||||
gemini-2.5-pro # Gemini 2.5 Pro
|
||||
```
|
||||
|
||||
**接入示例:**
|
||||
配置步骤:
|
||||
- 供应商类型选择"Gemini"
|
||||
- API地址填入:`http://你的服务器:3000/gemini/`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
|
||||
- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
|
||||
**3. Codex接入:**
|
||||
|
||||
```
|
||||
# API地址
|
||||
http://你的服务器:3000/openai/
|
||||
|
||||
# 模型ID(固定)
|
||||
gpt-5 # Codex使用固定模型ID
|
||||
```
|
||||
|
||||
配置步骤:
|
||||
- 供应商类型选择"Openai-Response"
|
||||
- API地址填入:`http://你的服务器:3000/openai/`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
- **重要**:Codex只支持Openai-Response标准
|
||||
|
||||
#### 其他第三方工具接入
|
||||
|
||||
**接入要点:**
|
||||
|
||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||
- 根据不同的路由前缀自动识别账号类型
|
||||
- `/claude/` - 使用Claude账号池
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||
- 支持所有标准API端点(messages、models等)
|
||||
|
||||
**重要说明:**
|
||||
|
||||
- 所有格式都支持相同的功能,仅是路径不同
|
||||
- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
|
||||
- 选择适合你使用工具的格式即可
|
||||
- 支持所有Claude API端点(messages、models等)
|
||||
|
||||
---
|
||||
|
||||
## 📢 Webhook 通知功能
|
||||
|
||||
### 功能说明
|
||||
|
||||
当系统检测到账号异常时,会自动发送 webhook 通知,支持企业微信、钉钉、Slack 等平台。
|
||||
|
||||
### 通知触发场景
|
||||
|
||||
- **Claude OAuth 账户**: token 过期或未授权时
|
||||
- **Claude Console 账户**: 系统检测到账户被封锁时
|
||||
- **Gemini 账户**: token 刷新失败时
|
||||
- **手动禁用账户**: 管理员手动禁用账户时
|
||||
|
||||
### 配置方法
|
||||
|
||||
**1. 环境变量配置**
|
||||
|
||||
```bash
|
||||
# 启用 webhook 通知
|
||||
WEBHOOK_ENABLED=true
|
||||
|
||||
# 企业微信 webhook 地址(替换为你的实际地址)
|
||||
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
|
||||
|
||||
# 多个地址用逗号分隔
|
||||
WEBHOOK_URLS=https://webhook1.com,https://webhook2.com
|
||||
|
||||
# 请求超时时间(毫秒,默认10秒)
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
|
||||
# 重试次数(默认3次)
|
||||
WEBHOOK_RETRIES=3
|
||||
```
|
||||
|
||||
**2. 企业微信设置**
|
||||
|
||||
1. 在企业微信群中添加「群机器人」
|
||||
2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
|
||||
3. 将地址配置到 `WEBHOOK_URLS` 环境变量
|
||||
|
||||
### 通知内容格式
|
||||
|
||||
系统会发送结构化的通知消息:
|
||||
|
||||
```
|
||||
账户名称 账号异常,异常代码 ERROR_CODE
|
||||
平台:claude-oauth
|
||||
时间:2025-08-14 17:30:00
|
||||
原因:Token expired
|
||||
```
|
||||
|
||||
### 测试 Webhook
|
||||
|
||||
可以通过管理后台测试 webhook 连通性:
|
||||
|
||||
1. 登录管理后台:`http://你的服务器:3000/web`
|
||||
2. 访问:`/admin/webhook/test`
|
||||
3. 发送测试通知确认配置正确
|
||||
- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex)
|
||||
- API密钥可以通用,系统会根据路由自动选择账号类型
|
||||
- 建议为不同用户创建不同的API密钥便于使用统计
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,13 +32,28 @@ const config = {
|
||||
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
|
||||
},
|
||||
|
||||
// 🔗 会话管理配置
|
||||
session: {
|
||||
// 粘性会话TTL配置(小时),默认1小时
|
||||
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
|
||||
// 续期阈值(分钟),默认0分钟(不续期)
|
||||
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
|
||||
},
|
||||
|
||||
// 🎯 Claude API配置
|
||||
claude: {
|
||||
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
|
||||
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
|
||||
betaHeader:
|
||||
process.env.CLAUDE_BETA_HEADER ||
|
||||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
|
||||
overloadHandling: {
|
||||
enabled: (() => {
|
||||
const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
|
||||
// 验证配置值:限制在0-1440分钟(24小时)内
|
||||
return Math.max(0, Math.min(minutes, 1440))
|
||||
})()
|
||||
}
|
||||
},
|
||||
|
||||
// ☁️ Bedrock API配置
|
||||
@@ -56,12 +71,15 @@ const config = {
|
||||
|
||||
// 🌐 代理配置
|
||||
proxy: {
|
||||
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
|
||||
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟
|
||||
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
|
||||
// IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
|
||||
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6
|
||||
},
|
||||
|
||||
// ⏱️ 请求超时配置
|
||||
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟
|
||||
|
||||
// 📈 使用限制
|
||||
limits: {
|
||||
defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000
|
||||
@@ -127,6 +145,58 @@ const config = {
|
||||
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
|
||||
},
|
||||
|
||||
// 🔐 LDAP 认证配置
|
||||
ldap: {
|
||||
enabled: process.env.LDAP_ENABLED === 'true',
|
||||
server: {
|
||||
url: process.env.LDAP_URL || 'ldap://localhost:389',
|
||||
bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com',
|
||||
bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin',
|
||||
searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com',
|
||||
searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})',
|
||||
searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES
|
||||
? process.env.LDAP_SEARCH_ATTRIBUTES.split(',')
|
||||
: ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'],
|
||||
timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000,
|
||||
connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000,
|
||||
// TLS/SSL 配置
|
||||
tls: {
|
||||
// 是否忽略证书错误 (用于自签名证书)
|
||||
rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略
|
||||
// CA证书文件路径 (可选,用于自定义CA证书)
|
||||
ca: process.env.LDAP_TLS_CA_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE)
|
||||
: undefined,
|
||||
// 客户端证书文件路径 (可选,用于双向认证)
|
||||
cert: process.env.LDAP_TLS_CERT_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE)
|
||||
: undefined,
|
||||
// 客户端私钥文件路径 (可选,用于双向认证)
|
||||
key: process.env.LDAP_TLS_KEY_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE)
|
||||
: undefined,
|
||||
// 服务器名称 (用于SNI,可选)
|
||||
servername: process.env.LDAP_TLS_SERVERNAME || undefined
|
||||
}
|
||||
},
|
||||
userMapping: {
|
||||
username: process.env.LDAP_USER_ATTR_USERNAME || 'uid',
|
||||
displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn',
|
||||
email: process.env.LDAP_USER_ATTR_EMAIL || 'mail',
|
||||
firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName',
|
||||
lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn'
|
||||
}
|
||||
},
|
||||
|
||||
// 👥 用户管理配置
|
||||
userManagement: {
|
||||
enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
|
||||
defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
|
||||
userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
|
||||
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1,
|
||||
allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
|
||||
},
|
||||
|
||||
// 📢 Webhook通知配置
|
||||
webhook: {
|
||||
enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用
|
||||
|
||||
1923
package-lock.json
generated
1923
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,9 @@
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"inquirer": "^8.2.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^7.0.6",
|
||||
"ora": "^5.4.1",
|
||||
"rate-limiter-flexible": "^5.0.5",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
|
||||
@@ -86,6 +86,33 @@ function decryptGeminiData(encryptedData) {
|
||||
}
|
||||
}
|
||||
|
||||
// API Key 哈希函数(与apiKeyService保持一致)
|
||||
function hashApiKey(apiKey) {
|
||||
if (!apiKey || !config.security.encryptionKey) {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(apiKey + config.security.encryptionKey)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
// 检查是否为明文API Key(通过格式判断,不依赖前缀)
|
||||
function isPlaintextApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false
|
||||
if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) {
|
||||
return false // 已经是哈希值
|
||||
}
|
||||
|
||||
// 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等)
|
||||
return true
|
||||
}
|
||||
|
||||
// 数据加密函数(用于导入)
|
||||
function encryptClaudeData(data) {
|
||||
if (!data || !config.security.encryptionKey) {
|
||||
@@ -651,6 +678,13 @@ Important Notes:
|
||||
- If importing decrypted data, it will be re-encrypted automatically
|
||||
- If importing encrypted data, it will be stored as-is
|
||||
- Sanitized exports cannot be properly imported (missing sensitive data)
|
||||
- Automatic handling of plaintext API Keys
|
||||
* Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.)
|
||||
* Automatically detects plaintext vs hashed API Keys by format
|
||||
* Plaintext API Keys are automatically hashed during import
|
||||
* Hash mappings are created correctly for plaintext keys
|
||||
* Supports custom prefixes and legacy format detection
|
||||
* No manual conversion needed - just import your backup file
|
||||
|
||||
Examples:
|
||||
# Export all data with decryption (for migration)
|
||||
@@ -659,7 +693,7 @@ Examples:
|
||||
# Export without decrypting (for backup)
|
||||
node scripts/data-transfer-enhanced.js export --decrypt=false
|
||||
|
||||
# Import data (auto-handles encryption)
|
||||
# Import data (auto-handles encryption and plaintext API keys)
|
||||
node scripts/data-transfer-enhanced.js import --input=backup.json
|
||||
|
||||
# Import with force overwrite
|
||||
@@ -773,6 +807,26 @@ async function importData() {
|
||||
const apiKeyData = { ...apiKey }
|
||||
delete apiKeyData.usageStats
|
||||
|
||||
// 检查并处理API Key哈希
|
||||
let plainTextApiKey = null
|
||||
let hashedApiKey = null
|
||||
|
||||
if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) {
|
||||
// 如果是明文API Key,保存明文并计算哈希
|
||||
plainTextApiKey = apiKeyData.apiKey
|
||||
hashedApiKey = hashApiKey(plainTextApiKey)
|
||||
logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`)
|
||||
} else if (apiKeyData.apiKey) {
|
||||
// 如果已经是哈希值,直接使用
|
||||
hashedApiKey = apiKeyData.apiKey
|
||||
logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`)
|
||||
}
|
||||
|
||||
// API Key字段始终存储哈希值
|
||||
if (hashedApiKey) {
|
||||
apiKeyData.apiKey = hashedApiKey
|
||||
}
|
||||
|
||||
// 使用 hset 存储到哈希表
|
||||
const pipeline = redis.client.pipeline()
|
||||
for (const [field, value] of Object.entries(apiKeyData)) {
|
||||
@@ -780,9 +834,12 @@ async function importData() {
|
||||
}
|
||||
await pipeline.exec()
|
||||
|
||||
// 更新哈希映射
|
||||
if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
|
||||
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
|
||||
// 更新哈希映射:hash_map的key必须是哈希值
|
||||
if (!importDataObj.metadata.sanitized && hashedApiKey) {
|
||||
await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id)
|
||||
logger.info(
|
||||
`📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入使用统计数据
|
||||
|
||||
@@ -185,7 +185,7 @@ class ServiceManager {
|
||||
|
||||
restart(daemon = false) {
|
||||
console.log('🔄 重启服务...')
|
||||
|
||||
this.stop()
|
||||
// 等待停止完成
|
||||
setTimeout(() => {
|
||||
this.start(daemon)
|
||||
|
||||
@@ -937,15 +937,61 @@ stop_service() {
|
||||
# 强制停止所有相关进程
|
||||
pkill -f "node.*src/app.js" 2>/dev/null || true
|
||||
|
||||
# 等待进程完全退出(最多等待10秒)
|
||||
local wait_count=0
|
||||
while pgrep -f "node.*src/app.js" > /dev/null; do
|
||||
if [ $wait_count -ge 10 ]; then
|
||||
print_warning "进程停止超时,尝试强制终止..."
|
||||
pkill -9 -f "node.*src/app.js" 2>/dev/null || true
|
||||
sleep 1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# 最终确认进程已停止
|
||||
if pgrep -f "node.*src/app.js" > /dev/null; then
|
||||
print_error "无法完全停止服务进程"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_success "服务已停止"
|
||||
}
|
||||
|
||||
# 重启服务
|
||||
restart_service() {
|
||||
print_info "重启服务..."
|
||||
stop_service
|
||||
sleep 2
|
||||
start_service
|
||||
|
||||
# 停止服务并检查结果
|
||||
if ! stop_service; then
|
||||
print_error "停止服务失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 短暂等待,确保端口释放
|
||||
sleep 1
|
||||
|
||||
# 启动服务,如果失败则重试
|
||||
local retry_count=0
|
||||
while [ $retry_count -lt 3 ]; do
|
||||
# 清除可能的僵尸进程检测
|
||||
if ! pgrep -f "node.*src/app.js" > /dev/null; then
|
||||
# 进程确实已停止,可以启动
|
||||
if start_service; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
retry_count=$((retry_count + 1))
|
||||
if [ $retry_count -lt 3 ]; then
|
||||
print_warning "启动失败,等待2秒后重试(第 $retry_count 次)..."
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
print_error "重启服务失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 更新模型价格
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* 多分组功能测试脚本
|
||||
* 测试一个账户可以属于多个分组的功能
|
||||
*/
|
||||
|
||||
require('dotenv').config()
|
||||
const redis = require('../src/models/redis')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
|
||||
// 测试配置
|
||||
const TEST_PREFIX = 'multi_group_test_'
|
||||
const CLEANUP_ON_FINISH = true
|
||||
|
||||
// 测试数据存储
|
||||
const testData = {
|
||||
groups: [],
|
||||
accounts: []
|
||||
}
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
}
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const color =
|
||||
{
|
||||
success: colors.green,
|
||||
error: colors.red,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue
|
||||
}[type] || colors.reset
|
||||
|
||||
console.log(`${color}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
async function cleanup() {
|
||||
log('\n🧹 清理测试数据...', 'info')
|
||||
|
||||
// 删除测试账户
|
||||
for (const account of testData.accounts) {
|
||||
try {
|
||||
await claudeAccountService.deleteAccount(account.id)
|
||||
log(`✅ 删除测试账户: ${account.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除账户失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试分组
|
||||
for (const group of testData.groups) {
|
||||
try {
|
||||
// 先移除所有成员
|
||||
const members = await accountGroupService.getGroupMembers(group.id)
|
||||
for (const memberId of members) {
|
||||
await accountGroupService.removeAccountFromGroup(memberId, group.id)
|
||||
}
|
||||
|
||||
await accountGroupService.deleteGroup(group.id)
|
||||
log(`✅ 删除测试分组: ${group.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除分组失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试1: 创建测试数据
|
||||
async function test1_createTestData() {
|
||||
log('\n📝 测试1: 创建测试数据', 'info')
|
||||
|
||||
try {
|
||||
// 创建3个测试分组
|
||||
const group1 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}高优先级组`,
|
||||
platform: 'claude',
|
||||
description: '高优先级账户分组'
|
||||
})
|
||||
testData.groups.push(group1)
|
||||
log(`✅ 创建分组1: ${group1.name}`, 'success')
|
||||
|
||||
const group2 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}备用组`,
|
||||
platform: 'claude',
|
||||
description: '备用账户分组'
|
||||
})
|
||||
testData.groups.push(group2)
|
||||
log(`✅ 创建分组2: ${group2.name}`, 'success')
|
||||
|
||||
const group3 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}专用组`,
|
||||
platform: 'claude',
|
||||
description: '专用账户分组'
|
||||
})
|
||||
testData.groups.push(group3)
|
||||
log(`✅ 创建分组3: ${group3.name}`, 'success')
|
||||
|
||||
// 创建测试账户
|
||||
const account1 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}测试账户1`,
|
||||
email: 'test1@example.com',
|
||||
refreshToken: 'test_refresh_token_1',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push(account1)
|
||||
log(`✅ 创建测试账户1: ${account1.name}`, 'success')
|
||||
|
||||
const account2 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}测试账户2`,
|
||||
email: 'test2@example.com',
|
||||
refreshToken: 'test_refresh_token_2',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push(account2)
|
||||
log(`✅ 创建测试账户2: ${account2.name}`, 'success')
|
||||
|
||||
log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试1失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试2: 账户加入多个分组
|
||||
async function test2_addAccountToMultipleGroups() {
|
||||
log('\n📝 测试2: 账户加入多个分组', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 账户1加入分组1和分组2
|
||||
await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude')
|
||||
log(`✅ 账户1加入分组1: ${group1.name}`, 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude')
|
||||
log(`✅ 账户1加入分组2: ${group2.name}`, 'success')
|
||||
|
||||
// 账户2加入分组2和分组3
|
||||
await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude')
|
||||
log(`✅ 账户2加入分组2: ${group2.name}`, 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude')
|
||||
log(`✅ 账户2加入分组3: ${group3.name}`, 'success')
|
||||
|
||||
log(`✅ 多分组关系建立完成`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试2失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试3: 验证多分组关系
|
||||
async function test3_verifyMultiGroupRelationships() {
|
||||
log('\n📝 测试3: 验证多分组关系', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 验证账户1的分组关系
|
||||
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
|
||||
log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info')
|
||||
|
||||
const account1GroupNames = account1Groups.map((g) => g.name).sort()
|
||||
const expectedAccount1Groups = [group1.name, group2.name].sort()
|
||||
|
||||
if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) {
|
||||
log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`账户1分组关系错误,期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
// 验证账户2的分组关系
|
||||
const account2Groups = await accountGroupService.getAccountGroup(account2.id)
|
||||
log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info')
|
||||
|
||||
const account2GroupNames = account2Groups.map((g) => g.name).sort()
|
||||
const expectedAccount2Groups = [group2.name, group3.name].sort()
|
||||
|
||||
if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) {
|
||||
log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`账户2分组关系错误,期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
log(`✅ 多分组关系验证通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试3失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试4: 验证分组成员关系
|
||||
async function test4_verifyGroupMemberships() {
|
||||
log('\n📝 测试4: 验证分组成员关系', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 验证分组1的成员
|
||||
const group1Members = await accountGroupService.getGroupMembers(group1.id)
|
||||
if (group1Members.includes(account1.id) && group1Members.length === 1) {
|
||||
log(`✅ 分组1成员正确: [${account1.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组1成员错误,期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组2的成员(应该包含两个账户)
|
||||
const group2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
const expectedGroup2Members = [account1.id, account2.id].sort()
|
||||
const actualGroup2Members = group2Members.sort()
|
||||
|
||||
if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) {
|
||||
log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`分组2成员错误,期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
// 验证分组3的成员
|
||||
const group3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
if (group3Members.includes(account2.id) && group3Members.length === 1) {
|
||||
log(`✅ 分组3成员正确: [${account2.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组3成员错误,期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
log(`✅ 分组成员关系验证通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试4失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试5: 从部分分组中移除账户
|
||||
async function test5_removeFromPartialGroups() {
|
||||
log('\n📝 测试5: 从部分分组中移除账户', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2] = testData.groups
|
||||
const [account1] = testData.accounts
|
||||
|
||||
// 将账户1从分组1中移除(但仍在分组2中)
|
||||
await accountGroupService.removeAccountFromGroup(account1.id, group1.id)
|
||||
log(`✅ 从分组1中移除账户1`, 'success')
|
||||
|
||||
// 验证账户1现在只属于分组2
|
||||
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
|
||||
if (account1Groups.length === 1 && account1Groups[0].id === group2.id) {
|
||||
log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success')
|
||||
} else {
|
||||
const groupNames = account1Groups.map((g) => g.name)
|
||||
throw new Error(`账户1分组状态错误,期望只在分组2中,实际: [${groupNames.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组1现在为空
|
||||
const group1Members = await accountGroupService.getGroupMembers(group1.id)
|
||||
if (group1Members.length === 0) {
|
||||
log(`✅ 分组1现在为空`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组1应该为空,但还有成员: [${group1Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组2仍有两个成员
|
||||
const group2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
if (group2Members.length === 2) {
|
||||
log(`✅ 分组2仍有两个成员`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组2应该有2个成员,实际: ${group2Members.length}个`)
|
||||
}
|
||||
|
||||
log(`✅ 部分移除测试通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试5失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试6: 账户完全移除时的分组清理
|
||||
async function test6_accountDeletionGroupCleanup() {
|
||||
log('\n📝 测试6: 账户删除时的分组清理', 'info')
|
||||
|
||||
try {
|
||||
const [, group2, group3] = testData.groups // 跳过第一个元素
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 记录删除前的状态
|
||||
const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
|
||||
log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info')
|
||||
log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info')
|
||||
|
||||
// 删除账户2(这应该会触发从所有分组中移除的逻辑)
|
||||
await claudeAccountService.deleteAccount(account2.id)
|
||||
log(`✅ 删除账户2: ${account2.name}`, 'success')
|
||||
|
||||
// 从测试数据中移除,避免cleanup时重复删除
|
||||
testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id)
|
||||
|
||||
// 等待一下确保删除操作完成
|
||||
await sleep(500)
|
||||
|
||||
// 验证分组2现在只有账户1
|
||||
const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) {
|
||||
log(`✅ 分组2现在只有账户1`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组2成员状态错误,期望只有账户1,实际: [${afterGroup2Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组3现在为空
|
||||
const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
if (afterGroup3Members.length === 0) {
|
||||
log(`✅ 分组3现在为空`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组3应该为空,但还有成员: [${afterGroup3Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
log(`✅ 账户删除的分组清理测试通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试6失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
log('\n🚀 开始多分组功能测试\n', 'info')
|
||||
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect()
|
||||
log('✅ Redis连接成功', 'success')
|
||||
|
||||
// 执行测试
|
||||
await test1_createTestData()
|
||||
await test2_addAccountToMultipleGroups()
|
||||
await test3_verifyMultiGroupRelationships()
|
||||
await test4_verifyGroupMemberships()
|
||||
await test5_removeFromPartialGroups()
|
||||
await test6_accountDeletionGroupCleanup()
|
||||
|
||||
log('\n🎉 所有测试通过!多分组功能工作正常', 'success')
|
||||
} catch (error) {
|
||||
log(`\n❌ 测试失败: ${error.message}`, 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
if (CLEANUP_ON_FINISH) {
|
||||
await cleanup()
|
||||
} else {
|
||||
log('\n⚠️ 测试数据未清理,请手动清理', 'warning')
|
||||
}
|
||||
|
||||
// 关闭Redis连接
|
||||
await redis.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests()
|
||||
40
src/app.js
40
src/app.js
@@ -19,8 +19,10 @@ const webRoutes = require('./routes/web')
|
||||
const apiStatsRoutes = require('./routes/apiStats')
|
||||
const geminiRoutes = require('./routes/geminiRoutes')
|
||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||
const openaiRoutes = require('./routes/openaiRoutes')
|
||||
const userRoutes = require('./routes/userRoutes')
|
||||
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
||||
const webhookRoutes = require('./routes/webhook')
|
||||
|
||||
@@ -33,6 +35,7 @@ const {
|
||||
globalRateLimit,
|
||||
requestSizeLimit
|
||||
} = require('./middleware/auth')
|
||||
const { browserFallbackMiddleware } = require('./middleware/browserFallback')
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
@@ -108,6 +111,9 @@ class Application {
|
||||
this.app.use(corsMiddleware)
|
||||
}
|
||||
|
||||
// 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前)
|
||||
this.app.use(browserFallbackMiddleware)
|
||||
|
||||
// 📦 压缩 - 排除流式响应(SSE)
|
||||
this.app.use(
|
||||
compression({
|
||||
@@ -133,6 +139,17 @@ class Application {
|
||||
// 📝 请求日志(使用自定义logger而不是morgan)
|
||||
this.app.use(requestLogger)
|
||||
|
||||
// 🐛 HTTP调试拦截器(仅在启用调试时生效)
|
||||
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
|
||||
try {
|
||||
const { debugInterceptor } = require('./middleware/debugInterceptor')
|
||||
this.app.use(debugInterceptor)
|
||||
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 基础中间件
|
||||
this.app.use(
|
||||
express.json({
|
||||
@@ -235,10 +252,13 @@ class Application {
|
||||
this.app.use('/api', apiRoutes)
|
||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||
this.app.use('/admin', adminRoutes)
|
||||
this.app.use('/users', userRoutes)
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes)
|
||||
this.app.use('/apiStats', apiStatsRoutes)
|
||||
this.app.use('/gemini', geminiRoutes)
|
||||
// Gemini 路由:同时支持标准格式和原有格式
|
||||
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
|
||||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||
this.app.use('/openai', openaiRoutes)
|
||||
@@ -524,6 +544,15 @@ class Application {
|
||||
logger.info(
|
||||
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
|
||||
)
|
||||
|
||||
// 🚨 启动限流状态自动清理服务
|
||||
// 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
|
||||
rateLimitCleanupService.start(cleanupIntervalMinutes)
|
||||
logger.info(
|
||||
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
|
||||
)
|
||||
}
|
||||
|
||||
setupGracefulShutdown() {
|
||||
@@ -542,6 +571,15 @@ class Application {
|
||||
logger.error('❌ Error cleaning up pricing service:', error)
|
||||
}
|
||||
|
||||
// 停止限流清理服务
|
||||
try {
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
rateLimitCleanupService.stop()
|
||||
logger.info('🚨 Rate limit cleanup service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.disconnect()
|
||||
logger.info('👋 Redis disconnected')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const userService = require('../services/userService')
|
||||
const logger = require('../utils/logger')
|
||||
const redis = require('../models/redis')
|
||||
const { RateLimiterRedis } = require('rate-limiter-flexible')
|
||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||
const config = require('../../config/config')
|
||||
|
||||
// 🔑 API Key验证中间件(优化版)
|
||||
@@ -182,11 +183,18 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
// 检查时间窗口限流
|
||||
const rateLimitWindow = validation.keyData.rateLimitWindow || 0
|
||||
const rateLimitRequests = validation.keyData.rateLimitRequests || 0
|
||||
const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制
|
||||
|
||||
if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) {
|
||||
// 兼容性检查:如果tokenLimit仍有值,使用tokenLimit;否则使用rateLimitCost
|
||||
const hasRateLimits =
|
||||
rateLimitWindow > 0 &&
|
||||
(rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0)
|
||||
|
||||
if (hasRateLimits) {
|
||||
const windowStartKey = `rate_limit:window_start:${validation.keyData.id}`
|
||||
const requestCountKey = `rate_limit:requests:${validation.keyData.id}`
|
||||
const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}`
|
||||
const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器
|
||||
|
||||
const now = Date.now()
|
||||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||
@@ -199,6 +207,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
|
||||
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
|
||||
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
|
||||
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
|
||||
windowStart = now
|
||||
} else {
|
||||
windowStart = parseInt(windowStart)
|
||||
@@ -209,6 +218,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
|
||||
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
|
||||
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
|
||||
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
|
||||
windowStart = now
|
||||
}
|
||||
}
|
||||
@@ -216,6 +226,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
// 获取当前计数
|
||||
const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0')
|
||||
const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0')
|
||||
const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用
|
||||
|
||||
// 检查请求次数限制
|
||||
if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) {
|
||||
@@ -236,24 +247,46 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查Token使用量限制
|
||||
// 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制
|
||||
const tokenLimit = parseInt(validation.keyData.tokenLimit)
|
||||
if (tokenLimit > 0 && currentTokens >= tokenLimit) {
|
||||
const resetTime = new Date(windowStart + windowDuration)
|
||||
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
|
||||
if (tokenLimit > 0) {
|
||||
// 使用Token限制(向后兼容)
|
||||
if (currentTokens >= tokenLimit) {
|
||||
const resetTime = new Date(windowStart + windowDuration)
|
||||
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
|
||||
|
||||
logger.security(
|
||||
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`
|
||||
)
|
||||
logger.security(
|
||||
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded',
|
||||
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`,
|
||||
currentTokens,
|
||||
tokenLimit,
|
||||
resetAt: resetTime.toISOString(),
|
||||
remainingMinutes
|
||||
})
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded',
|
||||
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`,
|
||||
currentTokens,
|
||||
tokenLimit,
|
||||
resetAt: resetTime.toISOString(),
|
||||
remainingMinutes
|
||||
})
|
||||
}
|
||||
} else if (rateLimitCost > 0) {
|
||||
// 使用费用限制(新功能)
|
||||
if (currentCost >= rateLimitCost) {
|
||||
const resetTime = new Date(windowStart + windowDuration)
|
||||
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
|
||||
|
||||
logger.security(
|
||||
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded',
|
||||
message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`,
|
||||
currentCost,
|
||||
costLimit: rateLimitCost,
|
||||
resetAt: resetTime.toISOString(),
|
||||
remainingMinutes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 增加请求计数
|
||||
@@ -265,10 +298,13 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
windowDuration,
|
||||
requestCountKey,
|
||||
tokenCountKey,
|
||||
costCountKey, // 新增:费用计数器
|
||||
currentRequests: currentRequests + 1,
|
||||
currentTokens,
|
||||
currentCost, // 新增:当前费用
|
||||
rateLimitRequests,
|
||||
tokenLimit
|
||||
tokenLimit,
|
||||
rateLimitCost // 新增:费用限制
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +333,46 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
// 从请求中获取模型信息
|
||||
const requestBody = req.body || {}
|
||||
const model = requestBody.model || ''
|
||||
|
||||
// 判断是否为 Opus 模型
|
||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||
|
||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||
logger.security(
|
||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
|
||||
// 计算下周一的重置时间
|
||||
const now = new Date()
|
||||
const dayOfWeek = now.getDay()
|
||||
const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7
|
||||
const resetDate = new Date(now)
|
||||
resetDate.setDate(now.getDate() + daysUntilMonday)
|
||||
resetDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Weekly Opus cost limit exceeded',
|
||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
currentCost: weeklyOpusCost,
|
||||
costLimit: weeklyOpusCostLimit,
|
||||
resetAt: resetDate.toISOString() // 下周一重置
|
||||
})
|
||||
}
|
||||
|
||||
// 记录当前 Opus 费用使用情况
|
||||
logger.api(
|
||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 将验证信息添加到请求对象(只包含必要信息)
|
||||
req.apiKey = {
|
||||
id: validation.keyData.id,
|
||||
@@ -311,6 +387,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
concurrencyLimit: validation.keyData.concurrencyLimit,
|
||||
rateLimitWindow: validation.keyData.rateLimitWindow,
|
||||
rateLimitRequests: validation.keyData.rateLimitRequests,
|
||||
rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制
|
||||
enableModelRestriction: validation.keyData.enableModelRestriction,
|
||||
restrictedModels: validation.keyData.restrictedModels,
|
||||
enableClientRestriction: validation.keyData.enableClientRestriction,
|
||||
@@ -449,10 +526,238 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 👤 用户验证中间件
|
||||
const authenticateUser = async (req, res, next) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 安全提取用户session token,支持多种方式
|
||||
const sessionToken =
|
||||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
|
||||
req.cookies?.userToken ||
|
||||
req.headers['x-user-token']
|
||||
|
||||
if (!sessionToken) {
|
||||
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing user session token',
|
||||
message: 'Please login to access this resource'
|
||||
})
|
||||
}
|
||||
|
||||
// 基本token格式验证
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) {
|
||||
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token format',
|
||||
message: 'Session token format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证用户会话
|
||||
const sessionValidation = await userService.validateUserSession(sessionToken)
|
||||
|
||||
if (!sessionValidation) {
|
||||
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token',
|
||||
message: 'Invalid or expired user session'
|
||||
})
|
||||
}
|
||||
|
||||
const { session, user } = sessionValidation
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (!user.isActive) {
|
||||
logger.security(
|
||||
`🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'Account disabled',
|
||||
message: 'Your account has been disabled. Please contact administrator.'
|
||||
})
|
||||
}
|
||||
|
||||
// 设置用户信息(只包含必要信息)
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
sessionToken,
|
||||
sessionCreatedAt: session.createdAt
|
||||
}
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`)
|
||||
|
||||
return next()
|
||||
} catch (error) {
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.error(`❌ User authentication error (${authDuration}ms):`, {
|
||||
error: error.message,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
url: req.originalUrl
|
||||
})
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Authentication error',
|
||||
message: 'Internal server error during user authentication'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 👤 用户或管理员验证中间件(支持两种身份)
|
||||
const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 检查是否有管理员token
|
||||
const adminToken =
|
||||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
|
||||
req.cookies?.adminToken ||
|
||||
req.headers['x-admin-token']
|
||||
|
||||
// 检查是否有用户session token
|
||||
const userToken =
|
||||
req.headers['x-user-token'] ||
|
||||
req.cookies?.userToken ||
|
||||
(!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null)
|
||||
|
||||
// 优先尝试管理员认证
|
||||
if (adminToken) {
|
||||
try {
|
||||
const adminSession = await redis.getSession(adminToken)
|
||||
if (adminSession && Object.keys(adminSession).length > 0) {
|
||||
req.admin = {
|
||||
id: adminSession.adminId || 'admin',
|
||||
username: adminSession.username,
|
||||
sessionId: adminToken,
|
||||
loginTime: adminSession.loginTime
|
||||
}
|
||||
req.userType = 'admin'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试用户认证
|
||||
if (userToken) {
|
||||
try {
|
||||
const sessionValidation = await userService.validateUserSession(userToken)
|
||||
if (sessionValidation) {
|
||||
const { session, user } = sessionValidation
|
||||
|
||||
if (user.isActive) {
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
sessionToken: userToken,
|
||||
sessionCreatedAt: session.createdAt
|
||||
}
|
||||
req.userType = 'user'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('User authentication failed:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都失败了,返回未授权
|
||||
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Please login as user or admin to access this resource'
|
||||
})
|
||||
} catch (error) {
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, {
|
||||
error: error.message,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
url: req.originalUrl
|
||||
})
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Authentication error',
|
||||
message: 'Internal server error during authentication'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 权限检查中间件
|
||||
const requireRole = (allowedRoles) => (req, res, next) => {
|
||||
// 管理员始终有权限
|
||||
if (req.admin) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 检查用户角色
|
||||
if (req.user) {
|
||||
const userRole = req.user.role
|
||||
const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles]
|
||||
|
||||
if (allowed.includes(userRole)) {
|
||||
return next()
|
||||
} else {
|
||||
logger.security(
|
||||
`🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
message: `This resource requires one of the following roles: ${allowed.join(', ')}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Please login to access this resource'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 管理员权限检查中间件
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.admin) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 检查是否是admin角色的用户
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
return next()
|
||||
}
|
||||
|
||||
logger.security(
|
||||
`🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'Admin access required',
|
||||
message: 'This resource requires administrator privileges'
|
||||
})
|
||||
}
|
||||
|
||||
// 注意:使用统计现在直接在/api/v1/messages路由中处理,
|
||||
// 以便从Claude API响应中提取真实的usage数据
|
||||
|
||||
// 🚦 CORS中间件(优化版)
|
||||
// 🚦 CORS中间件(优化版,支持Chrome插件)
|
||||
const corsMiddleware = (req, res, next) => {
|
||||
const { origin } = req.headers
|
||||
|
||||
@@ -464,8 +769,11 @@ const corsMiddleware = (req, res, next) => {
|
||||
'https://127.0.0.1:3000'
|
||||
]
|
||||
|
||||
// 🆕 检查是否为Chrome插件请求
|
||||
const isChromeExtension = origin && origin.startsWith('chrome-extension://')
|
||||
|
||||
// 设置CORS头
|
||||
if (allowedOrigins.includes(origin) || !origin) {
|
||||
if (allowedOrigins.includes(origin) || !origin || isChromeExtension) {
|
||||
res.header('Access-Control-Allow-Origin', origin || '*')
|
||||
}
|
||||
|
||||
@@ -480,7 +788,9 @@ const corsMiddleware = (req, res, next) => {
|
||||
'Authorization',
|
||||
'x-api-key',
|
||||
'api-key',
|
||||
'x-admin-token'
|
||||
'x-admin-token',
|
||||
'anthropic-version',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
].join(', ')
|
||||
)
|
||||
|
||||
@@ -713,35 +1023,41 @@ const errorHandler = (error, req, res, _next) => {
|
||||
}
|
||||
|
||||
// 🌐 全局速率限制中间件(延迟初始化)
|
||||
let rateLimiter = null
|
||||
// const rateLimiter = null // 暂时未使用
|
||||
|
||||
const getRateLimiter = () => {
|
||||
if (!rateLimiter) {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ Redis client not available for rate limiter')
|
||||
return null
|
||||
}
|
||||
// 暂时注释掉未使用的函数
|
||||
// const getRateLimiter = () => {
|
||||
// if (!rateLimiter) {
|
||||
// try {
|
||||
// const client = redis.getClient()
|
||||
// if (!client) {
|
||||
// logger.warn('⚠️ Redis client not available for rate limiter')
|
||||
// return null
|
||||
// }
|
||||
//
|
||||
// rateLimiter = new RateLimiterRedis({
|
||||
// storeClient: client,
|
||||
// keyPrefix: 'global_rate_limit',
|
||||
// points: 1000, // 请求数量
|
||||
// duration: 900, // 15分钟 (900秒)
|
||||
// blockDuration: 900 // 阻塞时间15分钟
|
||||
// })
|
||||
//
|
||||
// logger.info('✅ Rate limiter initialized successfully')
|
||||
// } catch (error) {
|
||||
// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
|
||||
// return null
|
||||
// }
|
||||
// }
|
||||
// return rateLimiter
|
||||
// }
|
||||
|
||||
rateLimiter = new RateLimiterRedis({
|
||||
storeClient: client,
|
||||
keyPrefix: 'global_rate_limit',
|
||||
points: 1000, // 请求数量
|
||||
duration: 900, // 15分钟 (900秒)
|
||||
blockDuration: 900 // 阻塞时间15分钟
|
||||
})
|
||||
const globalRateLimit = async (req, res, next) =>
|
||||
// 已禁用全局IP限流 - 直接跳过所有请求
|
||||
next()
|
||||
|
||||
logger.info('✅ Rate limiter initialized successfully')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
return rateLimiter
|
||||
}
|
||||
|
||||
const globalRateLimit = async (req, res, next) => {
|
||||
// 以下代码已被禁用
|
||||
/*
|
||||
// 跳过健康检查和内部请求
|
||||
if (req.path === '/health' || req.path === '/api/health') {
|
||||
return next()
|
||||
@@ -777,11 +1093,11 @@ const globalRateLimit = async (req, res, next) => {
|
||||
retryAfter: Math.round(msBeforeNext / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 📊 请求大小限制中间件
|
||||
const requestSizeLimit = (req, res, next) => {
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
||||
const maxSize = 60 * 1024 * 1024 // 60MB
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||
|
||||
if (contentLength > maxSize) {
|
||||
@@ -799,6 +1115,10 @@ const requestSizeLimit = (req, res, next) => {
|
||||
module.exports = {
|
||||
authenticateApiKey,
|
||||
authenticateAdmin,
|
||||
authenticateUser,
|
||||
authenticateUserOrAdmin,
|
||||
requireRole,
|
||||
requireAdmin,
|
||||
corsMiddleware,
|
||||
requestLogger,
|
||||
securityMiddleware,
|
||||
|
||||
52
src/middleware/browserFallback.js
Normal file
52
src/middleware/browserFallback.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 浏览器/Chrome插件兜底中间件
|
||||
* 专门处理第三方插件的兼容性问题
|
||||
*/
|
||||
const browserFallbackMiddleware = (req, res, next) => {
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const origin = req.headers['origin'] || ''
|
||||
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
|
||||
|
||||
// 检查是否为Chrome插件或浏览器请求
|
||||
const isChromeExtension = origin.startsWith('chrome-extension://')
|
||||
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
|
||||
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
|
||||
|
||||
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
||||
// 为Chrome插件请求添加特殊标记
|
||||
req.isBrowserFallback = true
|
||||
req.originalUserAgent = userAgent
|
||||
|
||||
// 🆕 关键修改:伪装成claude-cli请求以绕过客户端限制
|
||||
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
||||
|
||||
// 确保设置正确的认证头
|
||||
if (!req.headers['authorization'] && req.headers['x-api-key']) {
|
||||
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
|
||||
}
|
||||
|
||||
// 添加必要的Anthropic头
|
||||
if (!req.headers['anthropic-version']) {
|
||||
req.headers['anthropic-version'] = '2023-06-01'
|
||||
}
|
||||
|
||||
if (!req.headers['anthropic-dangerous-direct-browser-access']) {
|
||||
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request`
|
||||
)
|
||||
logger.api(` Original User-Agent: "${req.originalUserAgent}"`)
|
||||
logger.api(` Origin: "${origin}"`)
|
||||
logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
browserFallbackMiddleware
|
||||
}
|
||||
@@ -29,6 +29,25 @@ function getHourInTimezone(date = new Date()) {
|
||||
return tzDate.getUTCHours()
|
||||
}
|
||||
|
||||
// 获取配置时区的 ISO 周(YYYY-Wxx 格式,周一到周日)
|
||||
function getWeekStringInTimezone(date = new Date()) {
|
||||
const tzDate = getDateInTimezone(date)
|
||||
|
||||
// 获取年份
|
||||
const year = tzDate.getUTCFullYear()
|
||||
|
||||
// 计算 ISO 周数(周一为第一天)
|
||||
const dateObj = new Date(tzDate)
|
||||
const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7
|
||||
const firstThursday = new Date(dateObj)
|
||||
firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四
|
||||
|
||||
const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1)
|
||||
const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7)
|
||||
|
||||
return `${year}-W${String(weekNumber).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
class RedisClient {
|
||||
constructor() {
|
||||
this.client = null
|
||||
@@ -193,7 +212,8 @@ class RedisClient {
|
||||
cacheReadTokens = 0,
|
||||
model = 'unknown',
|
||||
ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens
|
||||
ephemeral1hTokens = 0 // 新增:1小时缓存 tokens
|
||||
ephemeral1hTokens = 0, // 新增:1小时缓存 tokens
|
||||
isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k)
|
||||
) {
|
||||
const key = `usage:${keyId}`
|
||||
const now = new Date()
|
||||
@@ -250,6 +270,12 @@ class RedisClient {
|
||||
// 详细缓存类型统计(新增)
|
||||
pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens)
|
||||
// 1M 上下文请求统计(新增)
|
||||
if (isLongContextRequest) {
|
||||
pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens)
|
||||
pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens)
|
||||
pipeline.hincrby(key, 'totalLongContextRequests', 1)
|
||||
}
|
||||
// 请求计数
|
||||
pipeline.hincrby(key, 'totalRequests', 1)
|
||||
|
||||
@@ -264,6 +290,12 @@ class RedisClient {
|
||||
// 详细缓存类型统计
|
||||
pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
// 1M 上下文请求统计
|
||||
if (isLongContextRequest) {
|
||||
pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens)
|
||||
pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens)
|
||||
pipeline.hincrby(daily, 'longContextRequests', 1)
|
||||
}
|
||||
|
||||
// 每月统计
|
||||
pipeline.hincrby(monthly, 'tokens', coreTokens)
|
||||
@@ -376,7 +408,8 @@ class RedisClient {
|
||||
outputTokens = 0,
|
||||
cacheCreateTokens = 0,
|
||||
cacheReadTokens = 0,
|
||||
model = 'unknown'
|
||||
model = 'unknown',
|
||||
isLongContextRequest = false
|
||||
) {
|
||||
const now = new Date()
|
||||
const today = getDateStringInTimezone(now)
|
||||
@@ -407,7 +440,8 @@ class RedisClient {
|
||||
finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens
|
||||
const coreTokens = finalInputTokens + finalOutputTokens
|
||||
|
||||
await Promise.all([
|
||||
// 构建统计操作数组
|
||||
const operations = [
|
||||
// 账户总体统计
|
||||
this.client.hincrby(accountKey, 'totalTokens', coreTokens),
|
||||
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
|
||||
@@ -444,6 +478,26 @@ class RedisClient {
|
||||
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountHourly, 'requests', 1),
|
||||
|
||||
// 添加模型级别的数据到hourly键中,以支持会话窗口的统计
|
||||
this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens),
|
||||
this.client.hincrby(
|
||||
accountHourly,
|
||||
`model:${normalizedModel}:outputTokens`,
|
||||
finalOutputTokens
|
||||
),
|
||||
this.client.hincrby(
|
||||
accountHourly,
|
||||
`model:${normalizedModel}:cacheCreateTokens`,
|
||||
finalCacheCreateTokens
|
||||
),
|
||||
this.client.hincrby(
|
||||
accountHourly,
|
||||
`model:${normalizedModel}:cacheReadTokens`,
|
||||
finalCacheReadTokens
|
||||
),
|
||||
this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens),
|
||||
this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1),
|
||||
|
||||
// 账户按模型统计 - 每日
|
||||
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
|
||||
@@ -475,7 +529,21 @@ class RedisClient {
|
||||
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
|
||||
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
|
||||
])
|
||||
]
|
||||
|
||||
// 如果是 1M 上下文请求,添加额外的统计
|
||||
if (isLongContextRequest) {
|
||||
operations.push(
|
||||
this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountKey, 'totalLongContextRequests', 1),
|
||||
this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountDaily, 'longContextRequests', 1)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(operations)
|
||||
}
|
||||
|
||||
async getUsageStats(keyId) {
|
||||
@@ -632,8 +700,87 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 💰 获取本周 Opus 费用
|
||||
async getWeeklyOpusCost(keyId) {
|
||||
const currentWeek = getWeekStringInTimezone()
|
||||
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
const cost = await this.client.get(costKey)
|
||||
const result = parseFloat(cost || 0)
|
||||
logger.debug(
|
||||
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
// 💰 增加本周 Opus 费用
|
||||
async incrementWeeklyOpusCost(keyId, amount) {
|
||||
const currentWeek = getWeekStringInTimezone()
|
||||
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
const totalKey = `usage:opus:total:${keyId}`
|
||||
|
||||
logger.debug(
|
||||
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
|
||||
)
|
||||
|
||||
// 使用 pipeline 批量执行,提高性能
|
||||
const pipeline = this.client.pipeline()
|
||||
pipeline.incrbyfloat(weeklyKey, amount)
|
||||
pipeline.incrbyfloat(totalKey, amount)
|
||||
// 设置周费用键的过期时间为 2 周
|
||||
pipeline.expire(weeklyKey, 14 * 24 * 3600)
|
||||
|
||||
const results = await pipeline.exec()
|
||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||
}
|
||||
|
||||
// 💰 计算账户的每日费用(基于模型使用)
|
||||
async getAccountDailyCost(accountId) {
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const today = getDateStringInTimezone()
|
||||
|
||||
// 获取账户今日所有模型的使用数据
|
||||
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
|
||||
const modelKeys = await this.client.keys(pattern)
|
||||
|
||||
if (!modelKeys || modelKeys.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalCost = 0
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 从key中解析模型名称
|
||||
// 格式:account_usage:model:daily:{accountId}:{model}:{date}
|
||||
const parts = key.split(':')
|
||||
const model = parts[4] // 模型名在第5个位置(索引4)
|
||||
|
||||
// 获取该模型的使用数据
|
||||
const modelUsage = await this.client.hgetall(key)
|
||||
|
||||
if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) {
|
||||
const usage = {
|
||||
input_tokens: parseInt(modelUsage.inputTokens || 0),
|
||||
output_tokens: parseInt(modelUsage.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0)
|
||||
}
|
||||
|
||||
// 使用CostCalculator计算费用
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total
|
||||
|
||||
logger.debug(
|
||||
`💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`)
|
||||
return totalCost
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
async getAccountUsageStats(accountId, accountType = null) {
|
||||
const accountKey = `account_usage:${accountId}`
|
||||
const today = getDateStringInTimezone()
|
||||
const accountDailyKey = `account_usage:daily:${accountId}:${today}`
|
||||
@@ -647,8 +794,25 @@ class RedisClient {
|
||||
this.client.hgetall(accountMonthlyKey)
|
||||
])
|
||||
|
||||
// 获取账户创建时间来计算平均值
|
||||
const accountData = await this.client.hgetall(`claude_account:${accountId}`)
|
||||
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
|
||||
let accountData = {}
|
||||
if (accountType === 'openai') {
|
||||
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
||||
} else if (accountType === 'openai-responses') {
|
||||
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
||||
} else {
|
||||
// 尝试多个前缀
|
||||
accountData = await this.client.hgetall(`claude_account:${accountId}`)
|
||||
if (!accountData.createdAt) {
|
||||
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
||||
}
|
||||
if (!accountData.createdAt) {
|
||||
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
||||
}
|
||||
if (!accountData.createdAt) {
|
||||
accountData = await this.client.hgetall(`openai_account:${accountId}`)
|
||||
}
|
||||
}
|
||||
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
|
||||
const now = new Date()
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||
@@ -691,10 +855,16 @@ class RedisClient {
|
||||
const dailyData = handleAccountData(daily)
|
||||
const monthlyData = handleAccountData(monthly)
|
||||
|
||||
// 获取每日费用(基于模型使用)
|
||||
const dailyCost = await this.getAccountDailyCost(accountId)
|
||||
|
||||
return {
|
||||
accountId,
|
||||
total: totalData,
|
||||
daily: dailyData,
|
||||
daily: {
|
||||
...dailyData,
|
||||
cost: dailyCost
|
||||
},
|
||||
monthly: monthlyData,
|
||||
averages: {
|
||||
rpm: Math.round(avgRPM * 100) / 100,
|
||||
@@ -1203,9 +1373,12 @@ class RedisClient {
|
||||
}
|
||||
|
||||
// 🔗 会话sticky映射管理
|
||||
async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
|
||||
async setSessionAccountMapping(sessionHash, accountId, ttl = null) {
|
||||
const appConfig = require('../../config/config')
|
||||
// 从配置读取TTL(小时),转换为秒,默认1小时
|
||||
const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
await this.client.set(key, accountId, 'EX', ttl)
|
||||
await this.client.set(key, accountId, 'EX', defaultTTL)
|
||||
}
|
||||
|
||||
async getSessionAccountMapping(sessionHash) {
|
||||
@@ -1213,6 +1386,57 @@ class RedisClient {
|
||||
return await this.client.get(key)
|
||||
}
|
||||
|
||||
// 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期
|
||||
async extendSessionAccountMappingTTL(sessionHash) {
|
||||
const appConfig = require('../../config/config')
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
|
||||
// 📊 从配置获取参数
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时
|
||||
const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期)
|
||||
|
||||
// 如果阈值为0,不执行续期
|
||||
if (thresholdMinutes === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = ttlHours * 60 * 60 // 转换为秒
|
||||
const renewalThreshold = thresholdMinutes * 60 // 转换为秒
|
||||
|
||||
try {
|
||||
// 获取当前剩余TTL(秒)
|
||||
const remainingTTL = await this.client.ttl(key)
|
||||
|
||||
// 键不存在或已过期
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 键存在但没有TTL(永不过期,不需要处理)
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 🎯 智能续期策略:仅在剩余时间少于阈值时才续期
|
||||
if (remainingTTL < renewalThreshold) {
|
||||
await this.client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// 剩余时间充足,无需续期
|
||||
logger.debug(
|
||||
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSessionAccountMapping(sessionHash) {
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
return await this.client.del(key)
|
||||
@@ -1311,13 +1535,229 @@ class RedisClient {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 Basic Redis operations wrapper methods for convenience
|
||||
async get(key) {
|
||||
const client = this.getClientSafe()
|
||||
return await client.get(key)
|
||||
}
|
||||
|
||||
async set(key, value, ...args) {
|
||||
const client = this.getClientSafe()
|
||||
return await client.set(key, value, ...args)
|
||||
}
|
||||
|
||||
async setex(key, ttl, value) {
|
||||
const client = this.getClientSafe()
|
||||
return await client.setex(key, ttl, value)
|
||||
}
|
||||
|
||||
async del(...keys) {
|
||||
const client = this.getClientSafe()
|
||||
return await client.del(...keys)
|
||||
}
|
||||
|
||||
async keys(pattern) {
|
||||
const client = this.getClientSafe()
|
||||
return await client.keys(pattern)
|
||||
}
|
||||
|
||||
// 📊 获取账户会话窗口内的使用统计(包含模型细分)
|
||||
async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) {
|
||||
try {
|
||||
if (!windowStart || !windowEnd) {
|
||||
return {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheCreateTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalAllTokens: 0,
|
||||
totalRequests: 0,
|
||||
modelUsage: {}
|
||||
}
|
||||
}
|
||||
|
||||
const startDate = new Date(windowStart)
|
||||
const endDate = new Date(windowEnd)
|
||||
|
||||
// 添加日志以调试时间窗口
|
||||
logger.debug(`📊 Getting session window usage for account ${accountId}`)
|
||||
logger.debug(` Window: ${windowStart} to ${windowEnd}`)
|
||||
logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`)
|
||||
|
||||
// 获取窗口内所有可能的小时键
|
||||
// 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区
|
||||
const hourlyKeys = []
|
||||
const currentHour = new Date(startDate)
|
||||
currentHour.setMinutes(0)
|
||||
currentHour.setSeconds(0)
|
||||
currentHour.setMilliseconds(0)
|
||||
|
||||
while (currentHour <= endDate) {
|
||||
// 使用时区转换函数来获取正确的日期和小时
|
||||
const tzDateStr = getDateStringInTimezone(currentHour)
|
||||
const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0')
|
||||
const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}`
|
||||
|
||||
logger.debug(` Adding hourly key: ${key}`)
|
||||
hourlyKeys.push(key)
|
||||
currentHour.setHours(currentHour.getHours() + 1)
|
||||
}
|
||||
|
||||
// 批量获取所有小时的数据
|
||||
const pipeline = this.client.pipeline()
|
||||
for (const key of hourlyKeys) {
|
||||
pipeline.hgetall(key)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 聚合所有数据
|
||||
let totalInputTokens = 0
|
||||
let totalOutputTokens = 0
|
||||
let totalCacheCreateTokens = 0
|
||||
let totalCacheReadTokens = 0
|
||||
let totalAllTokens = 0
|
||||
let totalRequests = 0
|
||||
const modelUsage = {}
|
||||
|
||||
logger.debug(` Processing ${results.length} hourly results`)
|
||||
|
||||
for (const [error, data] of results) {
|
||||
if (error || !data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理总计数据
|
||||
const hourInputTokens = parseInt(data.inputTokens || 0)
|
||||
const hourOutputTokens = parseInt(data.outputTokens || 0)
|
||||
const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0)
|
||||
const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0)
|
||||
const hourAllTokens = parseInt(data.allTokens || 0)
|
||||
const hourRequests = parseInt(data.requests || 0)
|
||||
|
||||
totalInputTokens += hourInputTokens
|
||||
totalOutputTokens += hourOutputTokens
|
||||
totalCacheCreateTokens += hourCacheCreateTokens
|
||||
totalCacheReadTokens += hourCacheReadTokens
|
||||
totalAllTokens += hourAllTokens
|
||||
totalRequests += hourRequests
|
||||
|
||||
if (hourAllTokens > 0) {
|
||||
logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`)
|
||||
}
|
||||
|
||||
// 处理每个模型的数据
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// 查找模型相关的键(格式: model:{modelName}:{metric})
|
||||
if (key.startsWith('model:')) {
|
||||
const parts = key.split(':')
|
||||
if (parts.length >= 3) {
|
||||
const modelName = parts[1]
|
||||
const metric = parts.slice(2).join(':')
|
||||
|
||||
if (!modelUsage[modelName]) {
|
||||
modelUsage[modelName] = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
requests: 0
|
||||
}
|
||||
}
|
||||
|
||||
if (metric === 'inputTokens') {
|
||||
modelUsage[modelName].inputTokens += parseInt(value || 0)
|
||||
} else if (metric === 'outputTokens') {
|
||||
modelUsage[modelName].outputTokens += parseInt(value || 0)
|
||||
} else if (metric === 'cacheCreateTokens') {
|
||||
modelUsage[modelName].cacheCreateTokens += parseInt(value || 0)
|
||||
} else if (metric === 'cacheReadTokens') {
|
||||
modelUsage[modelName].cacheReadTokens += parseInt(value || 0)
|
||||
} else if (metric === 'allTokens') {
|
||||
modelUsage[modelName].allTokens += parseInt(value || 0)
|
||||
} else if (metric === 'requests') {
|
||||
modelUsage[modelName].requests += parseInt(value || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`📊 Session window usage summary:`)
|
||||
logger.debug(` Total allTokens: ${totalAllTokens}`)
|
||||
logger.debug(` Total requests: ${totalRequests}`)
|
||||
logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`)
|
||||
logger.debug(
|
||||
` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}`
|
||||
)
|
||||
|
||||
return {
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheCreateTokens,
|
||||
totalCacheReadTokens,
|
||||
totalAllTokens,
|
||||
totalRequests,
|
||||
modelUsage
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error)
|
||||
return {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheCreateTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalAllTokens: 0,
|
||||
totalRequests: 0,
|
||||
modelUsage: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redisClient = new RedisClient()
|
||||
|
||||
// 分布式锁相关方法
|
||||
redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) {
|
||||
try {
|
||||
// 使用SET NX EX实现原子性的锁获取
|
||||
const result = await this.client.set(lockKey, lockValue, {
|
||||
NX: true, // 只在键不存在时设置
|
||||
PX: ttlMs // 毫秒级过期时间
|
||||
})
|
||||
return result === 'OK'
|
||||
} catch (error) {
|
||||
logger.error(`Failed to acquire lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
redisClient.releaseAccountLock = async function (lockKey, lockValue) {
|
||||
try {
|
||||
// 使用Lua脚本确保只有持有锁的进程才能释放锁
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
const result = await this.client.eval(script, {
|
||||
keys: [lockKey],
|
||||
arguments: [lockValue]
|
||||
})
|
||||
return result === 1
|
||||
} catch (error) {
|
||||
logger.error(`Failed to release lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone
|
||||
redisClient.getHourInTimezone = getHourInTimezone
|
||||
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
|
||||
|
||||
module.exports = redisClient
|
||||
|
||||
1994
src/routes/admin.js
1994
src/routes/admin.js
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,15 @@ const express = require('express')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const bedrockRelayService = require('../services/bedrockRelayService')
|
||||
const ccrRelayService = require('../services/ccrRelayService')
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const pricingService = require('../services/pricingService')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const redis = require('../models/redis')
|
||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
const router = express.Router()
|
||||
@@ -39,6 +42,23 @@ async function handleMessagesRequest(req, res) {
|
||||
})
|
||||
}
|
||||
|
||||
// 模型限制(黑名单)校验:统一在此处处理(去除供应商前缀)
|
||||
if (
|
||||
req.apiKey.enableModelRestriction &&
|
||||
Array.isArray(req.apiKey.restrictedModels) &&
|
||||
req.apiKey.restrictedModels.length > 0
|
||||
) {
|
||||
const effectiveModel = getEffectiveModel(req.body.model || '')
|
||||
if (req.apiKey.restrictedModels.includes(effectiveModel)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为流式请求
|
||||
const isStream = req.body.stream === true
|
||||
|
||||
@@ -131,14 +151,16 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
redis
|
||||
.getClient()
|
||||
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
@@ -146,6 +168,22 @@ async function handleMessagesRequest(req, res) {
|
||||
logger.error('❌ Failed to update rate limit token count:', error)
|
||||
})
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(usageData, model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit cost count:', error)
|
||||
})
|
||||
logger.api(
|
||||
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -216,14 +254,22 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.recordUsageWithDetails(
|
||||
req.apiKey.id,
|
||||
usageObject,
|
||||
model,
|
||||
usageAccountId,
|
||||
'claude-console'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
redis
|
||||
.getClient()
|
||||
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
@@ -231,6 +277,22 @@ async function handleMessagesRequest(req, res) {
|
||||
logger.error('❌ Failed to update rate limit token count:', error)
|
||||
})
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(usageData, model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit cost count:', error)
|
||||
})
|
||||
logger.api(
|
||||
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -271,9 +333,11 @@ async function handleMessagesRequest(req, res) {
|
||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||
})
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
redis
|
||||
.getClient()
|
||||
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
@@ -281,6 +345,20 @@ async function handleMessagesRequest(req, res) {
|
||||
logger.error('❌ Failed to update rate limit token count:', error)
|
||||
})
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(result.usage, result.model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit cost count:', error)
|
||||
})
|
||||
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -295,6 +373,110 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
res,
|
||||
req.headers,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
'🎯 CCR usage callback triggered with complete data:',
|
||||
JSON.stringify(usageData, null, 2)
|
||||
)
|
||||
|
||||
if (
|
||||
usageData &&
|
||||
usageData.input_tokens !== undefined &&
|
||||
usageData.output_tokens !== undefined
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
|
||||
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
let ephemeral5mTokens = 0
|
||||
let ephemeral1hTokens = 0
|
||||
|
||||
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
|
||||
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
// 总的缓存创建 tokens 是两者之和
|
||||
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
|
||||
}
|
||||
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0
|
||||
const model = usageData.model || 'unknown'
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const usageAccountId = usageData.accountId
|
||||
|
||||
// 构建 usage 对象以传递给 recordUsage
|
||||
const usageObject = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
// 如果有详细的缓存创建数据,添加到 usage 对象中
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usageObject.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||
})
|
||||
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
redis
|
||||
.getClient()
|
||||
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit token count:', error)
|
||||
})
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(usageData, model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit cost count:', error)
|
||||
})
|
||||
logger.api(
|
||||
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataCaptured = true
|
||||
logger.api(
|
||||
`📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'⚠️ CCR usage callback triggered but data is incomplete:',
|
||||
JSON.stringify(usageData)
|
||||
)
|
||||
}
|
||||
},
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
// 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算
|
||||
@@ -388,6 +570,17 @@ async function handleMessagesRequest(req, res) {
|
||||
accountId
|
||||
}
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务
|
||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||
response = await ccrRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('📡 Claude API response received', {
|
||||
@@ -424,7 +617,10 @@ async function handleMessagesRequest(req, res) {
|
||||
const outputTokens = jsonData.usage.output_tokens || 0
|
||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||
const model = jsonData.model || req.body.model || 'unknown'
|
||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = baseModel || rawModel
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: responseAccountId } = response
|
||||
@@ -438,11 +634,24 @@ async function handleMessagesRequest(req, res) {
|
||||
responseAccountId
|
||||
)
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(jsonData.usage, model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
await redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageRecorded = true
|
||||
@@ -729,6 +938,14 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
|
||||
}
|
||||
)
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR不支持count_tokens
|
||||
return res.status(501).json({
|
||||
error: {
|
||||
type: 'not_supported',
|
||||
message: 'Token counting is not supported for CCR accounts'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Bedrock不支持count_tokens
|
||||
return res.status(501).json({
|
||||
|
||||
@@ -31,8 +31,8 @@ router.post('/api/get-key-id', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -146,6 +146,11 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
usage // 使用完整的 usage 数据,而不是只有 total
|
||||
}
|
||||
} else if (apiKey) {
|
||||
@@ -158,8 +163,8 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key(重用现有的验证逻辑)
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -278,21 +283,24 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 获取当前使用量
|
||||
let currentWindowRequests = 0
|
||||
let currentWindowTokens = 0
|
||||
let currentWindowCost = 0 // 新增:当前窗口费用
|
||||
let currentDailyCost = 0
|
||||
let windowStartTime = null
|
||||
let windowEndTime = null
|
||||
let windowRemainingSeconds = null
|
||||
|
||||
try {
|
||||
// 获取当前时间窗口的请求次数和Token使用量
|
||||
// 获取当前时间窗口的请求次数、Token使用量和费用
|
||||
if (fullKeyData.rateLimitWindow > 0) {
|
||||
const client = redis.getClientSafe()
|
||||
const requestCountKey = `rate_limit:requests:${keyId}`
|
||||
const tokenCountKey = `rate_limit:tokens:${keyId}`
|
||||
const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key
|
||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||
|
||||
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
|
||||
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
|
||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
@@ -313,6 +321,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 重置计数为0,因为窗口已过期
|
||||
currentWindowRequests = 0
|
||||
currentWindowTokens = 0
|
||||
currentWindowCost = 0 // 新增:重置窗口费用
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,10 +336,15 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
const responseData = {
|
||||
id: keyId,
|
||||
name: fullKeyData.name,
|
||||
description: keyData.description || '',
|
||||
description: fullKeyData.description || keyData.description || '',
|
||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdAt: fullKeyData.createdAt || keyData.createdAt,
|
||||
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: fullKeyData.expirationMode || 'fixed',
|
||||
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
|
||||
activationDays: parseInt(fullKeyData.activationDays || 0),
|
||||
activatedAt: fullKeyData.activatedAt || null,
|
||||
permissions: fullKeyData.permissions,
|
||||
|
||||
// 使用统计(使用验证结果中的完整数据)
|
||||
@@ -356,10 +370,12 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
|
||||
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
|
||||
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
|
||||
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
|
||||
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
|
||||
// 当前使用量
|
||||
currentWindowRequests,
|
||||
currentWindowTokens,
|
||||
currentWindowCost, // 新增:当前窗口费用
|
||||
currentDailyCost,
|
||||
// 时间窗口信息
|
||||
windowStartTime,
|
||||
@@ -401,6 +417,317 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 批量查询统计数据接口
|
||||
router.post('/api/batch-stats', async (req, res) => {
|
||||
try {
|
||||
const { apiIds } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'API IDs array is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制最多查询 30 个
|
||||
if (apiIds.length > 30) {
|
||||
return res.status(400).json({
|
||||
error: 'Too many keys',
|
||||
message: 'Maximum 30 API keys can be queried at once'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证所有 ID 格式
|
||||
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
||||
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id))
|
||||
if (invalidIds.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API ID format',
|
||||
message: `Invalid API IDs: ${invalidIds.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
const individualStats = []
|
||||
const aggregated = {
|
||||
totalKeys: apiIds.length,
|
||||
activeKeys: 0,
|
||||
usage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
},
|
||||
dailyUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
},
|
||||
monthlyUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
}
|
||||
|
||||
// 并行查询所有 API Key 数据(复用单key查询逻辑)
|
||||
const results = await Promise.allSettled(
|
||||
apiIds.map(async (apiId) => {
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return { error: 'Not found', apiId }
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { error: 'Disabled', apiId }
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
return { error: 'Expired', apiId }
|
||||
}
|
||||
|
||||
// 复用单key查询的逻辑:获取使用统计
|
||||
const usage = await redis.getUsageStats(apiId)
|
||||
|
||||
// 获取费用统计(与单key查询一致)
|
||||
const costStats = await redis.getCostStats(apiId)
|
||||
|
||||
return {
|
||||
apiId,
|
||||
name: keyData.name,
|
||||
description: keyData.description || '',
|
||||
isActive: true,
|
||||
createdAt: keyData.createdAt,
|
||||
usage: usage.total || {},
|
||||
dailyStats: {
|
||||
...usage.daily,
|
||||
cost: costStats.daily
|
||||
},
|
||||
monthlyStats: {
|
||||
...usage.monthly,
|
||||
cost: costStats.monthly
|
||||
},
|
||||
totalCost: costStats.total
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 处理结果并聚合
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value && !result.value.error) {
|
||||
const stats = result.value
|
||||
aggregated.activeKeys++
|
||||
|
||||
// 聚合总使用量
|
||||
if (stats.usage) {
|
||||
aggregated.usage.requests += stats.usage.requests || 0
|
||||
aggregated.usage.inputTokens += stats.usage.inputTokens || 0
|
||||
aggregated.usage.outputTokens += stats.usage.outputTokens || 0
|
||||
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0
|
||||
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0
|
||||
aggregated.usage.allTokens += stats.usage.allTokens || 0
|
||||
}
|
||||
|
||||
// 聚合总费用
|
||||
aggregated.usage.cost += stats.totalCost || 0
|
||||
|
||||
// 聚合今日使用量
|
||||
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
|
||||
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
|
||||
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
|
||||
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
|
||||
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
|
||||
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
|
||||
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
|
||||
|
||||
// 聚合本月使用量
|
||||
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
|
||||
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
|
||||
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
|
||||
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
|
||||
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
|
||||
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
|
||||
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
|
||||
|
||||
// 添加到个体统计
|
||||
individualStats.push({
|
||||
apiId: stats.apiId,
|
||||
name: stats.name,
|
||||
isActive: true,
|
||||
usage: stats.usage,
|
||||
dailyUsage: {
|
||||
...stats.dailyStats,
|
||||
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0)
|
||||
},
|
||||
monthlyUsage: {
|
||||
...stats.monthlyStats,
|
||||
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
|
||||
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
|
||||
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
|
||||
|
||||
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
aggregated,
|
||||
individual: individualStats
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to process batch stats query:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve batch statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 批量模型统计查询接口
|
||||
router.post('/api/batch-model-stats', async (req, res) => {
|
||||
try {
|
||||
const { apiIds, period = 'daily' } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'API IDs array is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制最多查询 30 个
|
||||
if (apiIds.length > 30) {
|
||||
return res.status(400).json({
|
||||
error: 'Too many keys',
|
||||
message: 'Maximum 30 API keys can be queried at once'
|
||||
})
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const modelUsageMap = new Map()
|
||||
|
||||
// 并行查询所有 API Key 的模型统计
|
||||
await Promise.all(
|
||||
apiIds.map(async (apiId) => {
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:${apiId}:model:daily:*:${today}`
|
||||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
modelUsageMap.set(model, {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
})
|
||||
}
|
||||
|
||||
const modelUsage = modelUsageMap.get(model)
|
||||
modelUsage.requests += parseInt(data.requests) || 0
|
||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 转换为数组并计算费用
|
||||
const modelStats = []
|
||||
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 costData = CostCalculator.calculateCost(usageData, model)
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: usage.requests,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheCreateTokens: usage.cacheCreateTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
allTokens: usage.allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
})
|
||||
}
|
||||
|
||||
// 按总 token 数降序排列
|
||||
modelStats.sort((a, b) => b.allTokens - a.allTokens)
|
||||
|
||||
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: modelStats,
|
||||
period
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to process batch model stats query:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve batch model statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||||
router.post('/api/user-model-stats', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -14,8 +14,11 @@ const ALLOWED_MODELS = {
|
||||
'gpt-4-turbo',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-5',
|
||||
'gpt-5-mini',
|
||||
'gpt-35-turbo',
|
||||
'gpt-35-turbo-16k'
|
||||
'gpt-35-turbo-16k',
|
||||
'codex-mini'
|
||||
],
|
||||
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
|
||||
}
|
||||
@@ -234,6 +237,99 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models)
|
||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] || null
|
||||
|
||||
logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, {
|
||||
apiKeyId: req.apiKey?.id,
|
||||
sessionId,
|
||||
model: req.body.model,
|
||||
stream: req.body.stream || false,
|
||||
messages: req.body.messages?.length || 0
|
||||
})
|
||||
|
||||
try {
|
||||
// 获取绑定的 Azure OpenAI 账户
|
||||
let account = null
|
||||
if (req.apiKey?.azureOpenaiAccountId) {
|
||||
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
|
||||
if (!account) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有绑定账户或账户不可用,选择一个可用账户
|
||||
if (!account || account.isActive !== 'true') {
|
||||
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
|
||||
}
|
||||
|
||||
// 发送请求到 Azure OpenAI
|
||||
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
|
||||
account,
|
||||
requestBody: req.body,
|
||||
headers: req.headers,
|
||||
isStream: req.body.stream || false,
|
||||
endpoint: 'responses'
|
||||
})
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body.stream) {
|
||||
await azureOpenaiRelayService.handleStreamResponse(response, res, {
|
||||
onEnd: async ({ usageData, actualModel }) => {
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`Stream error for request ${requestId}:`, error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 处理非流式响应
|
||||
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
|
||||
response,
|
||||
res
|
||||
)
|
||||
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Azure OpenAI responses request failed ${requestId}:`, error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error.response?.status || 500
|
||||
const errorMessage =
|
||||
error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: 'azure_openai_error',
|
||||
code: error.code || 'unknown'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理嵌入请求
|
||||
router.post('/embeddings', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
|
||||
@@ -50,7 +50,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
|
||||
// 提取请求参数
|
||||
const {
|
||||
messages,
|
||||
model = 'gemini-2.0-flash-exp',
|
||||
model = 'gemini-2.5-flash',
|
||||
temperature = 0.7,
|
||||
max_tokens = 4096,
|
||||
stream = false
|
||||
@@ -217,7 +217,7 @@ router.get('/models', authenticateApiKey, async (req, res) => {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp',
|
||||
id: 'gemini-2.5-flash',
|
||||
object: 'model',
|
||||
created: Date.now() / 1000,
|
||||
owned_by: 'google'
|
||||
@@ -311,8 +311,8 @@ async function handleLoadCodeAssist(req, res) {
|
||||
try {
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
@@ -331,24 +331,40 @@ async function handleLoadCodeAssist(req, res) {
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 根据账户配置决定项目ID:
|
||||
// 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖)
|
||||
// 2. 如果账户没有项目ID -> 传递 null(移除项目ID)
|
||||
let effectiveProjectId = null
|
||||
|
||||
if (projectId) {
|
||||
// 账户配置了项目ID,强制使用它
|
||||
effectiveProjectId = projectId
|
||||
logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId)
|
||||
} else {
|
||||
// 账户没有配置项目ID,确保不传递项目ID
|
||||
effectiveProjectId = null
|
||||
logger.info('No project ID in account for loadCodeAssist, removing project parameter')
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:
|
||||
// 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的)
|
||||
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
|
||||
// 3. 都没有 -> 传null
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
|
||||
logger.info('📋 loadCodeAssist项目ID处理逻辑', {
|
||||
accountProjectId: projectId,
|
||||
requestProjectId: cloudaicompanionProject,
|
||||
effectiveProjectId,
|
||||
decision: projectId
|
||||
? '使用账户配置'
|
||||
: cloudaicompanionProject
|
||||
? '使用请求参数'
|
||||
: '不使用项目ID'
|
||||
})
|
||||
|
||||
const response = await geminiAccountService.loadCodeAssist(
|
||||
client,
|
||||
effectiveProjectId,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
@@ -368,8 +384,8 @@ async function handleOnboardUser(req, res) {
|
||||
const { tierId, cloudaicompanionProject, metadata } = req.body
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
@@ -387,30 +403,43 @@ async function handleOnboardUser(req, res) {
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 根据账户配置决定项目ID:
|
||||
// 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖)
|
||||
// 2. 如果账户没有项目ID -> 传递 null(移除项目ID)
|
||||
let effectiveProjectId = null
|
||||
|
||||
if (projectId) {
|
||||
// 账户配置了项目ID,强制使用它
|
||||
effectiveProjectId = projectId
|
||||
logger.info('Using account project ID:', effectiveProjectId)
|
||||
} else {
|
||||
// 账户没有配置项目ID,确保不传递项目ID(即使客户端传了也要移除)
|
||||
effectiveProjectId = null
|
||||
logger.info('No project ID in account, removing project parameter')
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:
|
||||
// 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的)
|
||||
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
|
||||
// 3. 都没有 -> 传null
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
|
||||
logger.info('📋 onboardUser项目ID处理逻辑', {
|
||||
accountProjectId: projectId,
|
||||
requestProjectId: cloudaicompanionProject,
|
||||
effectiveProjectId,
|
||||
decision: projectId
|
||||
? '使用账户配置'
|
||||
: cloudaicompanionProject
|
||||
? '使用请求参数'
|
||||
: '不使用项目ID'
|
||||
})
|
||||
|
||||
// 如果提供了 tierId,直接调用 onboardUser
|
||||
if (tierId) {
|
||||
const response = await geminiAccountService.onboardUser(
|
||||
client,
|
||||
tierId,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
metadata,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
@@ -419,7 +448,8 @@ async function handleOnboardUser(req, res) {
|
||||
const response = await geminiAccountService.setupUser(
|
||||
client,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
metadata,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
@@ -439,7 +469,9 @@ async function handleCountTokens(req, res) {
|
||||
try {
|
||||
// 处理请求体结构,支持直接 contents 或 request.contents
|
||||
const requestData = req.body.request || req.body
|
||||
const { contents, model = 'gemini-2.0-flash-exp' } = requestData
|
||||
const { contents } = requestData
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const model = requestData.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 验证必需参数
|
||||
@@ -458,7 +490,8 @@ async function handleCountTokens(req, res) {
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`CountTokens request (${version})`, {
|
||||
@@ -467,8 +500,18 @@ async function handleCountTokens(req, res) {
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
const response = await geminiAccountService.countTokens(client, contents, model)
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
const response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
@@ -487,7 +530,9 @@ async function handleCountTokens(req, res) {
|
||||
// 共用的 generateContent 处理函数
|
||||
async function handleGenerateContent(req, res) {
|
||||
try {
|
||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
||||
const { project, user_prompt_id, request: requestData } = req.body
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
@@ -540,8 +585,6 @@ async function handleGenerateContent(req, res) {
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
@@ -552,11 +595,26 @@ async function handleGenerateContent(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:
|
||||
// 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的)
|
||||
// 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话)
|
||||
// 3. 都没有 -> 传null
|
||||
const effectiveProjectId = account.projectId || project || null
|
||||
|
||||
logger.info('📋 项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
requestProjectId: project,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
|
||||
})
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
effectiveProjectId, // 使用智能决策的项目ID
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
@@ -582,7 +640,7 @@ async function handleGenerateContent(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
res.json(version === 'v1beta' ? response.response : response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
// 打印详细的错误信息
|
||||
@@ -610,7 +668,9 @@ async function handleStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
||||
const { project, user_prompt_id, request: requestData } = req.body
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
@@ -674,8 +734,6 @@ async function handleStreamGenerateContent(req, res) {
|
||||
}
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
@@ -686,11 +744,26 @@ async function handleStreamGenerateContent(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:
|
||||
// 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的)
|
||||
// 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话)
|
||||
// 3. 都没有 -> 传null
|
||||
const effectiveProjectId = account.projectId || project || null
|
||||
|
||||
logger.info('📋 流式请求项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
requestProjectId: project,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
|
||||
})
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
effectiveProjectId, // 使用智能决策的项目ID
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
@@ -702,8 +775,28 @@ async function handleStreamGenerateContent(req, res) {
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
|
||||
// SSE 解析函数
|
||||
const parseSSELine = (line) => {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return { type: 'other', line, data: null }
|
||||
}
|
||||
|
||||
const jsonStr = line.substring(6).trim()
|
||||
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
return { type: 'control', line, data: null, jsonStr }
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
return { type: 'data', line, data, jsonStr }
|
||||
} catch (e) {
|
||||
return { type: 'invalid', line, data: null, jsonStr, error: e }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式响应并捕获usage数据
|
||||
let buffer = ''
|
||||
let streamBuffer = '' // 统一的流处理缓冲区
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
@@ -715,32 +808,60 @@ async function handleStreamGenerateContent(req, res) {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 直接转发数据到客户端
|
||||
if (!res.destroyed) {
|
||||
res.write(chunkStr)
|
||||
if (!chunkStr.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时解析数据以捕获usage信息
|
||||
buffer += chunkStr
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
// 使用统一缓冲区处理不完整的行
|
||||
streamBuffer += chunkStr
|
||||
const lines = streamBuffer.split('\n')
|
||||
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行
|
||||
|
||||
const processedLines = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ') && line.length > 6) {
|
||||
try {
|
||||
const jsonStr = line.slice(6)
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (!line.trim()) {
|
||||
continue // 跳过空行,不添加到处理队列
|
||||
}
|
||||
|
||||
// 从响应中提取usage数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||
}
|
||||
// 解析 SSE 行
|
||||
const parsed = parseSSELine(line)
|
||||
|
||||
// 提取 usage 数据(适用于所有版本)
|
||||
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
||||
totalUsage = parsed.data.response.usageMetadata
|
||||
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||
}
|
||||
|
||||
// 根据版本处理输出
|
||||
if (version === 'v1beta') {
|
||||
if (parsed.type === 'data') {
|
||||
if (parsed.data.response) {
|
||||
// 有 response 字段,只返回 response 的内容
|
||||
processedLines.push(`data: ${JSON.stringify(parsed.data.response)}`)
|
||||
} else {
|
||||
// 没有 response 字段,返回整个数据对象
|
||||
processedLines.push(`data: ${JSON.stringify(parsed.data)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
} else if (parsed.type === 'control') {
|
||||
// 控制消息(如 [DONE])保持原样
|
||||
processedLines.push(line)
|
||||
}
|
||||
// 跳过其他类型的行('other', 'invalid')
|
||||
}
|
||||
}
|
||||
|
||||
// 发送数据到客户端
|
||||
if (version === 'v1beta') {
|
||||
for (const line of processedLines) {
|
||||
if (!res.destroyed) {
|
||||
res.write(`${line}\n\n`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// v1internal 直接转发原始数据
|
||||
if (!res.destroyed) {
|
||||
res.write(chunkStr)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -840,4 +961,10 @@ router.post(
|
||||
handleStreamGenerateContent
|
||||
)
|
||||
|
||||
// 导出处理函数供标准路由使用
|
||||
module.exports = router
|
||||
module.exports.handleLoadCodeAssist = handleLoadCodeAssist
|
||||
module.exports.handleOnboardUser = handleOnboardUser
|
||||
module.exports.handleCountTokens = handleCountTokens
|
||||
module.exports.handleGenerateContent = handleGenerateContent
|
||||
module.exports.handleStreamGenerateContent = handleStreamGenerateContent
|
||||
|
||||
@@ -311,6 +311,16 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 标记账户被使用
|
||||
await geminiAccountService.markAccountUsed(account.id)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
@@ -325,7 +335,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 获取OAuth客户端
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
account.accessToken,
|
||||
account.refreshToken
|
||||
account.refreshToken,
|
||||
proxyConfig
|
||||
)
|
||||
if (actualStream) {
|
||||
// 流式响应
|
||||
@@ -341,7 +352,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal // 传递中止信号
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置流式响应头
|
||||
@@ -541,7 +553,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id // 使用 API Key ID 作为 session ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 转换为 OpenAI 格式并返回
|
||||
|
||||
@@ -2,10 +2,12 @@ const express = require('express')
|
||||
const axios = require('axios')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
@@ -34,33 +36,81 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
throw new Error('No available OpenAI account found')
|
||||
}
|
||||
|
||||
// 获取账户详情
|
||||
const account = await openaiAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.accessToken) {
|
||||
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
|
||||
}
|
||||
// 根据账户类型获取账户详情
|
||||
let account,
|
||||
accessToken,
|
||||
proxy = null
|
||||
|
||||
// 解密 accessToken
|
||||
const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to decrypt OpenAI accessToken')
|
||||
}
|
||||
|
||||
// 解析代理配置
|
||||
let proxy = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
if (result.accountType === 'openai-responses') {
|
||||
// 处理 OpenAI-Responses 账户
|
||||
account = await openaiResponsesAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.apiKey) {
|
||||
throw new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`)
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账户不需要 accessToken,直接返回账户信息
|
||||
accessToken = null // OpenAI-Responses 使用账户内的 apiKey
|
||||
|
||||
// 解析代理配置
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`)
|
||||
} else {
|
||||
// 处理普通 OpenAI 账户
|
||||
account = await openaiAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.accessToken) {
|
||||
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
|
||||
}
|
||||
|
||||
// 检查 token 是否过期并自动刷新(双重保护)
|
||||
if (openaiAccountService.isTokenExpired(account)) {
|
||||
if (account.refreshToken) {
|
||||
logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
|
||||
try {
|
||||
await openaiAccountService.refreshAccountToken(result.accountId)
|
||||
// 重新获取更新后的账户
|
||||
account = await openaiAccountService.getAccount(result.accountId)
|
||||
logger.info(`✅ Token refreshed successfully in route handler`)
|
||||
} catch (refreshError) {
|
||||
logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
|
||||
throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Token expired and no refresh token available for account ${account.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密 accessToken(account.accessToken 是加密的)
|
||||
accessToken = openaiAccountService.decrypt(account.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to decrypt OpenAI accessToken')
|
||||
}
|
||||
|
||||
// 解析代理配置
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
|
||||
}
|
||||
|
||||
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
|
||||
return {
|
||||
accessToken,
|
||||
accountId: result.accountId,
|
||||
accountName: account.name,
|
||||
accountType: result.accountType,
|
||||
proxy,
|
||||
account
|
||||
}
|
||||
@@ -70,7 +120,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
// 主处理函数,供两个路由共享
|
||||
const handleResponses = async (req, res) => {
|
||||
let upstream = null
|
||||
try {
|
||||
// 从中间件获取 API Key 数据
|
||||
@@ -132,9 +183,16 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
accessToken,
|
||||
accountId,
|
||||
accountName: _accountName,
|
||||
accountType,
|
||||
proxy,
|
||||
account
|
||||
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
|
||||
|
||||
// 如果是 OpenAI-Responses 账户,使用专门的中继服务处理
|
||||
if (accountType === 'openai-responses') {
|
||||
logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`)
|
||||
return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData)
|
||||
}
|
||||
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
|
||||
const incoming = req.headers || {}
|
||||
|
||||
@@ -161,7 +219,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
// 配置请求选项
|
||||
const axiosConfig = {
|
||||
headers,
|
||||
timeout: 60000,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
validateStatus: () => true
|
||||
}
|
||||
|
||||
@@ -188,6 +246,96 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
axiosConfig
|
||||
)
|
||||
}
|
||||
|
||||
// 处理 429 限流错误
|
||||
if (upstream.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
|
||||
|
||||
// 解析响应体中的限流信息
|
||||
let resetsInSeconds = null
|
||||
let errorData = null
|
||||
|
||||
try {
|
||||
// 对于429错误,无论是否是流式请求,响应都会是完整的JSON错误对象
|
||||
if (isStream && upstream.data) {
|
||||
// 流式响应需要先收集数据
|
||||
const chunks = []
|
||||
await new Promise((resolve, reject) => {
|
||||
upstream.data.on('data', (chunk) => chunks.push(chunk))
|
||||
upstream.data.on('end', resolve)
|
||||
upstream.data.on('error', reject)
|
||||
// 设置超时防止无限等待
|
||||
setTimeout(resolve, 5000)
|
||||
})
|
||||
|
||||
const fullResponse = Buffer.concat(chunks).toString()
|
||||
try {
|
||||
errorData = JSON.parse(fullResponse)
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse 429 error response:', e)
|
||||
logger.debug('Raw response:', fullResponse)
|
||||
}
|
||||
} else {
|
||||
// 非流式响应直接使用data
|
||||
errorData = upstream.data
|
||||
}
|
||||
|
||||
// 提取重置时间
|
||||
if (errorData && errorData.error && errorData.error.resets_in_seconds) {
|
||||
resetsInSeconds = errorData.error.resets_in_seconds
|
||||
logger.info(
|
||||
`🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('⚠️ Failed to parse rate limit error:', e)
|
||||
}
|
||||
|
||||
// 标记账户为限流状态
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
accountId,
|
||||
'openai',
|
||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||
resetsInSeconds
|
||||
)
|
||||
|
||||
// 返回错误响应给客户端
|
||||
const errorResponse = errorData || {
|
||||
error: {
|
||||
type: 'usage_limit_reached',
|
||||
message: 'The usage limit has been reached',
|
||||
resets_in_seconds: resetsInSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
// 流式响应也需要设置正确的状态码
|
||||
res.status(429)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
res.end()
|
||||
} else {
|
||||
res.status(429).json(errorResponse)
|
||||
}
|
||||
|
||||
return
|
||||
} else if (upstream.status === 200 || upstream.status === 201) {
|
||||
// 请求成功,检查并移除限流状态
|
||||
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
logger.info(
|
||||
`✅ Removing rate limit for OpenAI account ${accountId} after successful request`
|
||||
)
|
||||
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
res.status(upstream.status)
|
||||
|
||||
if (isStream) {
|
||||
@@ -222,6 +370,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let usageReported = false
|
||||
let rateLimitDetected = false
|
||||
let rateLimitResetsInSeconds = null
|
||||
|
||||
if (!isStream) {
|
||||
// 非流式响应处理
|
||||
@@ -300,6 +450,17 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||
rateLimitDetected = true
|
||||
if (eventData.error.resets_in_seconds) {
|
||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
@@ -371,6 +532,26 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在流式响应中检测到限流
|
||||
if (rateLimitDetected) {
|
||||
logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`)
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
accountId,
|
||||
'openai',
|
||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||
rateLimitResetsInSeconds
|
||||
)
|
||||
} else if (upstream.status === 200) {
|
||||
// 流式请求成功,检查并移除限流状态
|
||||
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
logger.info(
|
||||
`✅ Removing rate limit for OpenAI account ${accountId} after successful stream`
|
||||
)
|
||||
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
@@ -402,7 +583,11 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
res.status(status).json({ error: { message } })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 注册两个路由路径,都使用相同的处理函数
|
||||
router.post('/responses', authenticateApiKey, handleResponses)
|
||||
router.post('/v1/responses', authenticateApiKey, handleResponses)
|
||||
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
|
||||
638
src/routes/standardGeminiRoutes.js
Normal file
638
src/routes/standardGeminiRoutes.js
Normal file
@@ -0,0 +1,638 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
// 导入 geminiRoutes 中导出的处理函数
|
||||
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
|
||||
|
||||
// 标准 Gemini API 路由处理器
|
||||
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
|
||||
// 标准格式: /gemini/v1beta/models/{model}:generateContent
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 generateContent
|
||||
async function handleStandardGenerateContent(req, res) {
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API generateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
try {
|
||||
const usage = response.response.usageMetadata
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
usage.promptTokenCount || 0,
|
||||
usage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回标准 Gemini API 格式的响应
|
||||
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
|
||||
if (response.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
const standardResponse = { ...response.response }
|
||||
if (standardResponse.candidates) {
|
||||
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
res.json(standardResponse)
|
||||
} else {
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard generateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 streamGenerateContent
|
||||
async function handleStandardStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('Client disconnected, aborting stream request')
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 流式项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)的流式接口
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 设置 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')
|
||||
|
||||
// 处理流式响应并捕获usage数据
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
if (!res.destroyed) {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 处理 SSE 格式的数据
|
||||
const lines = chunkStr.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 捕获 usage 数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
}
|
||||
|
||||
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
|
||||
if (data.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
if (data.response.candidates) {
|
||||
const filteredCandidates = data.response.candidates
|
||||
.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter(
|
||||
(part) => !part.thought
|
||||
)
|
||||
if (filteredParts.length > 0) {
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// 只有当有有效内容时才发送
|
||||
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
|
||||
const standardResponse = {
|
||||
candidates: filteredCandidates,
|
||||
...(data.response.usageMetadata && {
|
||||
usageMetadata: data.response.usageMetadata
|
||||
}),
|
||||
...(data.response.modelVersion && {
|
||||
modelVersion: data.response.modelVersion
|
||||
}),
|
||||
...(data.response.createTime && { createTime: data.response.createTime }),
|
||||
...(data.response.responseId && { responseId: data.response.responseId })
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有 response 包装,直接发送
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
// 保持 [DONE] 标记
|
||||
res.write(`${line}\n\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
streamResponse.on('end', async () => {
|
||||
logger.info('Stream completed successfully')
|
||||
|
||||
// 记录使用统计
|
||||
if (totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
streamResponse.on('error', (error) => {
|
||||
logger.error('Stream error:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard streamGenerateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1beta 版本的标准路由 - 支持动态模型名称
|
||||
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// 使用专门的处理函数处理标准 Gemini API 格式
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta)
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
|
||||
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
|
||||
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleGenerateContent } = require('./geminiRoutes')
|
||||
handleGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleStreamGenerateContent } = require('./geminiRoutes')
|
||||
handleStreamGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
// 添加标准 Gemini API 的模型列表端点
|
||||
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint (v1):', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模型详情端点
|
||||
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request: ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
logger.info('Standard Gemini API routes initialized')
|
||||
|
||||
module.exports = router
|
||||
748
src/routes/userRoutes.js
Normal file
748
src/routes/userRoutes.js
Normal file
@@ -0,0 +1,748 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const ldapService = require('../services/ldapService')
|
||||
const userService = require('../services/userService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const inputValidator = require('../utils/inputValidator')
|
||||
const { RateLimiterRedis } = require('rate-limiter-flexible')
|
||||
const redis = require('../models/redis')
|
||||
const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth')
|
||||
|
||||
// 🚦 配置登录速率限制
|
||||
// 只基于IP地址限制,避免攻击者恶意锁定特定账户
|
||||
|
||||
// 延迟初始化速率限制器,确保 Redis 已连接
|
||||
let ipRateLimiter = null
|
||||
let strictIpRateLimiter = null
|
||||
|
||||
// 初始化速率限制器函数
|
||||
function initRateLimiters() {
|
||||
if (!ipRateLimiter) {
|
||||
try {
|
||||
const redisClient = redis.getClientSafe()
|
||||
|
||||
// IP地址速率限制 - 正常限制
|
||||
ipRateLimiter = new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: 'login_ip_limiter',
|
||||
points: 30, // 每个IP允许30次尝试
|
||||
duration: 900, // 15分钟窗口期
|
||||
blockDuration: 900 // 超限后封禁15分钟
|
||||
})
|
||||
|
||||
// IP地址速率限制 - 严格限制(用于检测暴力破解)
|
||||
strictIpRateLimiter = new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: 'login_ip_strict',
|
||||
points: 100, // 每个IP允许100次尝试
|
||||
duration: 3600, // 1小时窗口期
|
||||
blockDuration: 3600 // 超限后封禁1小时
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ 初始化速率限制器失败:', error)
|
||||
// 速率限制器初始化失败时继续运行,但记录错误
|
||||
}
|
||||
}
|
||||
return { ipRateLimiter, strictIpRateLimiter }
|
||||
}
|
||||
|
||||
// 🔐 用户登录端点
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
|
||||
|
||||
// 初始化速率限制器(如果尚未初始化)
|
||||
const limiters = initRateLimiters()
|
||||
|
||||
// 检查IP速率限制 - 基础限制
|
||||
if (limiters.ipRateLimiter) {
|
||||
try {
|
||||
await limiters.ipRateLimiter.consume(clientIp)
|
||||
} catch (rateLimiterRes) {
|
||||
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900
|
||||
logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`)
|
||||
res.set('Retry-After', String(retryAfter))
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: `Too many login attempts from this IP. Please try again later.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查IP速率限制 - 严格限制(防止暴力破解)
|
||||
if (limiters.strictIpRateLimiter) {
|
||||
try {
|
||||
await limiters.strictIpRateLimiter.consume(clientIp)
|
||||
} catch (rateLimiterRes) {
|
||||
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600
|
||||
logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`)
|
||||
res.set('Retry-After', String(retryAfter))
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: 'Too many login attempts detected. Access temporarily blocked.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing credentials',
|
||||
message: 'Username and password are required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证输入格式
|
||||
let validatedUsername
|
||||
try {
|
||||
validatedUsername = inputValidator.validateUsername(username)
|
||||
inputValidator.validatePassword(password)
|
||||
} catch (validationError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: validationError.message
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户管理是否启用
|
||||
if (!config.userManagement.enabled) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'User management is not enabled'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查LDAP是否启用
|
||||
if (!config.ldap || !config.ldap.enabled) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'LDAP authentication is not enabled'
|
||||
})
|
||||
}
|
||||
|
||||
// 尝试LDAP认证
|
||||
const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password)
|
||||
|
||||
if (!authResult.success) {
|
||||
// 登录失败
|
||||
logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`)
|
||||
return res.status(401).json({
|
||||
error: 'Authentication failed',
|
||||
message: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
id: authResult.user.id,
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
displayName: authResult.user.displayName,
|
||||
firstName: authResult.user.firstName,
|
||||
lastName: authResult.user.lastName,
|
||||
role: authResult.user.role
|
||||
},
|
||||
sessionToken: authResult.sessionToken
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ User login error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Login error',
|
||||
message: 'Internal server error during login'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🚪 用户登出端点
|
||||
router.post('/logout', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
await userService.invalidateUserSession(req.user.sessionToken)
|
||||
|
||||
logger.info(`👋 User logout: ${req.user.username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logout successful'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ User logout error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Logout error',
|
||||
message: 'Internal server error during logout'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 👤 获取当前用户信息
|
||||
router.get('/profile', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const user = await userService.getUserById(req.user.id)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User profile not found'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
apiKeyCount: user.apiKeyCount,
|
||||
totalUsage: user.totalUsage
|
||||
},
|
||||
config: {
|
||||
maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser,
|
||||
allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user profile error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Profile error',
|
||||
message: 'Failed to retrieve user profile'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 获取用户的API Keys
|
||||
router.get('/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { includeDeleted = 'false' } = req.query
|
||||
const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true')
|
||||
|
||||
// 移除敏感信息并格式化usage数据
|
||||
const safeApiKeys = apiKeys.map((key) => {
|
||||
// Flatten usage structure for frontend compatibility
|
||||
let flatUsage = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
if (key.usage && key.usage.total) {
|
||||
flatUsage = {
|
||||
requests: key.usage.total.requests || 0,
|
||||
inputTokens: key.usage.total.inputTokens || 0,
|
||||
outputTokens: key.usage.total.outputTokens || 0,
|
||||
totalCost: key.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
tokenLimit: key.tokenLimit,
|
||||
isActive: key.isActive,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
usage: flatUsage,
|
||||
dailyCost: key.dailyCost,
|
||||
dailyCostLimit: key.dailyCostLimit,
|
||||
// 不返回实际的key值,只返回前缀和后几位
|
||||
keyPreview: key.key
|
||||
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
|
||||
: null,
|
||||
// Include deletion fields for deleted keys
|
||||
isDeleted: key.isDeleted,
|
||||
deletedAt: key.deletedAt,
|
||||
deletedBy: key.deletedBy,
|
||||
deletedByType: key.deletedByType
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
apiKeys: safeApiKeys,
|
||||
total: safeApiKeys.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user API keys error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Keys error',
|
||||
message: 'Failed to retrieve API keys'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 创建新的API Key
|
||||
router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing name',
|
||||
message: 'API key name is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户API Key数量限制
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
|
||||
if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) {
|
||||
return res.status(400).json({
|
||||
error: 'API key limit exceeded',
|
||||
message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建API Key数据
|
||||
const apiKeyData = {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || '',
|
||||
userId: req.user.id,
|
||||
userUsername: req.user.username,
|
||||
tokenLimit: tokenLimit || null,
|
||||
expiresAt: expiresAt || null,
|
||||
dailyCostLimit: dailyCostLimit || null,
|
||||
createdBy: 'user',
|
||||
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
|
||||
permissions: 'all'
|
||||
}
|
||||
|
||||
const newApiKey = await apiKeyService.createApiKey(apiKeyData)
|
||||
|
||||
// 更新用户API Key数量
|
||||
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1)
|
||||
|
||||
logger.info(`🔑 User ${req.user.username} created API key: ${name}`)
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'API key created successfully',
|
||||
apiKey: {
|
||||
id: newApiKey.id,
|
||||
name: newApiKey.name,
|
||||
description: newApiKey.description,
|
||||
key: newApiKey.apiKey, // 只在创建时返回完整key
|
||||
tokenLimit: newApiKey.tokenLimit,
|
||||
expiresAt: newApiKey.expiresAt,
|
||||
dailyCostLimit: newApiKey.dailyCostLimit,
|
||||
createdAt: newApiKey.createdAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Create user API key error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Key creation error',
|
||||
message: 'Failed to create API key'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🗑️ 删除API Key
|
||||
router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params
|
||||
|
||||
// 检查是否允许用户删除自己的API Keys
|
||||
if (!config.userManagement.allowUserDeleteApiKeys) {
|
||||
return res.status(403).json({
|
||||
error: 'Operation not allowed',
|
||||
message:
|
||||
'Users are not allowed to delete their own API keys. Please contact an administrator.'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查API Key是否属于当前用户
|
||||
const existingKey = await apiKeyService.getApiKeyById(keyId)
|
||||
if (!existingKey || existingKey.userId !== req.user.id) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'API key not found or you do not have permission to access it'
|
||||
})
|
||||
}
|
||||
|
||||
await apiKeyService.deleteApiKey(keyId, req.user.username, 'user')
|
||||
|
||||
// 更新用户API Key数量
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
|
||||
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length)
|
||||
|
||||
logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API key deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Delete user API key error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Key deletion error',
|
||||
message: 'Failed to delete API key'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户使用统计
|
||||
router.get('/usage-stats', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { period = 'week', model } = req.query
|
||||
|
||||
// 获取用户的API Keys (including deleted ones for complete usage stats)
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true)
|
||||
const apiKeyIds = userApiKeys.map((key) => key.id)
|
||||
|
||||
if (apiKeyIds.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取使用统计
|
||||
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user usage stats error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Usage stats error',
|
||||
message: 'Failed to retrieve usage statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// === 管理员用户管理端点 ===
|
||||
|
||||
// 📋 获取用户列表(管理员)
|
||||
router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, role, isActive, search } = req.query
|
||||
|
||||
const options = {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
role,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined
|
||||
}
|
||||
|
||||
const result = await userService.getAllUsers(options)
|
||||
|
||||
// 如果有搜索条件,进行过滤
|
||||
let filteredUsers = result.users
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
filteredUsers = result.users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchLower) ||
|
||||
user.displayName.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: filteredUsers,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get users list error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Users list error',
|
||||
message: 'Failed to retrieve users list'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 👤 获取特定用户信息(管理员)
|
||||
router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户的API Keys(包括已删除的以保留统计数据)
|
||||
const apiKeys = await apiKeyService.getUserApiKeys(userId, true)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
...user,
|
||||
apiKeys: apiKeys.map((key) => {
|
||||
// Flatten usage structure for frontend compatibility
|
||||
let flatUsage = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
if (key.usage && key.usage.total) {
|
||||
flatUsage = {
|
||||
requests: key.usage.total.requests || 0,
|
||||
inputTokens: key.usage.total.inputTokens || 0,
|
||||
outputTokens: key.usage.total.outputTokens || 0,
|
||||
totalCost: key.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
isActive: key.isActive,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
usage: flatUsage,
|
||||
keyPreview: key.key
|
||||
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user details error:', error)
|
||||
res.status(500).json({
|
||||
error: 'User details error',
|
||||
message: 'Failed to retrieve user details'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔄 更新用户状态(管理员)
|
||||
router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { isActive } = req.body
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid status',
|
||||
message: 'isActive must be a boolean value'
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUserStatus(userId, isActive)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(
|
||||
`🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}`
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${isActive ? 'enabled' : 'disabled'} successfully`,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
isActive: updatedUser.isActive,
|
||||
updatedAt: updatedUser.updatedAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Update user status error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Update status error',
|
||||
message: error.message || 'Failed to update user status'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔄 更新用户角色(管理员)
|
||||
router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { role } = req.body
|
||||
|
||||
const validRoles = ['user', 'admin']
|
||||
if (!role || !validRoles.includes(role)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid role',
|
||||
message: `Role must be one of: ${validRoles.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUserRole(userId, role)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User role updated to ${role} successfully`,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
role: updatedUser.role,
|
||||
updatedAt: updatedUser.updatedAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Update user role error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Update role error',
|
||||
message: error.message || 'Failed to update user role'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 禁用用户的所有API Keys(管理员)
|
||||
router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.disableUserApiKeys(userId)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Disabled ${result.count} API keys for user ${user.username}`,
|
||||
disabledCount: result.count
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Disable user API keys error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Disable keys error',
|
||||
message: 'Failed to disable user API keys'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户使用统计(管理员)
|
||||
router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { period = 'week', model } = req.query
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户的API Keys(包括已删除的以保留统计数据)
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(userId, true)
|
||||
const apiKeyIds = userApiKeys.map((key) => key.id)
|
||||
|
||||
if (apiKeyIds.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
},
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取使用统计
|
||||
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
},
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user usage stats (admin) error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Usage stats error',
|
||||
message: 'Failed to retrieve user usage statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户管理统计(管理员)
|
||||
router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await userService.getUserStats()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user stats overview error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Stats error',
|
||||
message: 'Failed to retrieve user statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔧 测试LDAP连接(管理员)
|
||||
router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const testResult = await ldapService.testConnection()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ldapTest: testResult,
|
||||
config: ldapService.getConfigInfo()
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ LDAP test error:', error)
|
||||
res.status(500).json({
|
||||
error: 'LDAP test error',
|
||||
message: 'Failed to test LDAP connection'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const webhookConfigService = require('../services/webhookConfigService')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
// 获取webhook配置
|
||||
router.get('/config', authenticateAdmin, async (req, res) => {
|
||||
@@ -114,27 +115,99 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
// 测试Webhook连通性
|
||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url, type = 'custom', secret, enableSign } = req.body
|
||||
const {
|
||||
url,
|
||||
type = 'custom',
|
||||
secret,
|
||||
enableSign,
|
||||
deviceKey,
|
||||
serverUrl,
|
||||
level,
|
||||
sound,
|
||||
group,
|
||||
// SMTP 相关字段
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
user,
|
||||
pass,
|
||||
from,
|
||||
to,
|
||||
ignoreTLS
|
||||
} = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
// Bark平台特殊处理
|
||||
if (type === 'bark') {
|
||||
if (!deviceKey) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing device key',
|
||||
message: '请提供Bark设备密钥'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证服务器URL(如果提供)
|
||||
if (serverUrl) {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid server URL format',
|
||||
message: '请提供有效的Bark服务器URL'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
|
||||
} else if (type === 'smtp') {
|
||||
// SMTP平台验证
|
||||
if (!host) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP host',
|
||||
message: '请提供SMTP服务器地址'
|
||||
})
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP user',
|
||||
message: '请提供SMTP用户名'
|
||||
})
|
||||
}
|
||||
if (!pass) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP password',
|
||||
message: '请提供SMTP密码'
|
||||
})
|
||||
}
|
||||
if (!to) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing recipient email',
|
||||
message: '请提供收件人邮箱'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
|
||||
} else {
|
||||
// 其他平台验证URL
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
|
||||
// 创建临时平台配置
|
||||
const platform = {
|
||||
type,
|
||||
@@ -145,21 +218,44 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
// 添加Bark特有字段
|
||||
if (type === 'bark') {
|
||||
platform.deviceKey = deviceKey
|
||||
platform.serverUrl = serverUrl
|
||||
platform.level = level
|
||||
platform.sound = sound
|
||||
platform.group = group
|
||||
} else if (type === 'smtp') {
|
||||
// 添加SMTP特有字段
|
||||
platform.host = host
|
||||
platform.port = port || 587
|
||||
platform.secure = secure || false
|
||||
platform.user = user
|
||||
platform.pass = pass
|
||||
platform.from = from
|
||||
platform.to = to
|
||||
platform.ignoreTLS = ignoreTLS || false
|
||||
}
|
||||
|
||||
const result = await webhookService.testWebhook(platform)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`✅ Webhook测试成功: ${url}`)
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.info(`✅ Webhook测试成功: ${identifier}`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook测试成功',
|
||||
url
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
|
||||
})
|
||||
} else {
|
||||
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook测试失败',
|
||||
url,
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
@@ -218,7 +314,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||
errorCode,
|
||||
reason,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
}
|
||||
|
||||
const result = await webhookService.sendNotification(type, testData)
|
||||
|
||||
@@ -13,7 +13,7 @@ class AccountGroupService {
|
||||
* 创建账户分组
|
||||
* @param {Object} groupData - 分组数据
|
||||
* @param {string} groupData.name - 分组名称
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini)
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini/openai)
|
||||
* @param {string} groupData.description - 分组描述
|
||||
* @returns {Object} 创建的分组
|
||||
*/
|
||||
@@ -327,12 +327,36 @@ class AccountGroupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户ID获取其所属的分组(兼容性方法,返回单个分组)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Object|null} 分组信息
|
||||
*/
|
||||
async getAccountGroup(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
if (isMember) {
|
||||
return await this.getGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取账户所属分组失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户ID获取其所属的所有分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Array} 分组信息数组
|
||||
*/
|
||||
async getAccountGroup(accountId) {
|
||||
async getAccountGroups(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
@@ -357,6 +381,49 @@ class AccountGroupService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置账户的分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {Array} groupIds - 分组ID数组
|
||||
* @param {string} accountPlatform - 账户平台
|
||||
*/
|
||||
async setAccountGroups(accountId, groupIds, accountPlatform) {
|
||||
try {
|
||||
// 首先移除账户的所有现有分组
|
||||
await this.removeAccountFromAllGroups(accountId)
|
||||
|
||||
// 然后添加到新的分组中
|
||||
for (const groupId of groupIds) {
|
||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||
}
|
||||
|
||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量设置账户分组失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从所有分组中移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
*/
|
||||
async removeAccountFromAllGroups(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
}
|
||||
|
||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService()
|
||||
|
||||
@@ -14,7 +14,7 @@ class ApiKeyService {
|
||||
const {
|
||||
name = 'Unnamed Key',
|
||||
description = '',
|
||||
tokenLimit = config.limits.defaultTokenLimit,
|
||||
tokenLimit = 0, // 默认为0,不再使用token限制
|
||||
expiresAt = null,
|
||||
claudeAccountId = null,
|
||||
claudeConsoleAccountId = null,
|
||||
@@ -27,12 +27,17 @@ class ApiKeyService {
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
rateLimitRequests = null,
|
||||
rateLimitCost = null, // 新增:速率限制费用字段
|
||||
enableModelRestriction = false,
|
||||
restrictedModels = [],
|
||||
enableClientRestriction = false,
|
||||
allowedClients = [],
|
||||
dailyCostLimit = 0,
|
||||
tags = []
|
||||
weeklyOpusCostLimit = 0,
|
||||
tags = [],
|
||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||
icon = '' // 新增:图标(base64编码)
|
||||
} = options
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -49,6 +54,7 @@ class ApiKeyService {
|
||||
concurrencyLimit: String(concurrencyLimit ?? 0),
|
||||
rateLimitWindow: String(rateLimitWindow ?? 0),
|
||||
rateLimitRequests: String(rateLimitRequests ?? 0),
|
||||
rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段
|
||||
isActive: String(isActive),
|
||||
claudeAccountId: claudeAccountId || '',
|
||||
claudeConsoleAccountId: claudeConsoleAccountId || '',
|
||||
@@ -62,11 +68,19 @@ class ApiKeyService {
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
allowedClients: JSON.stringify(allowedClients || []),
|
||||
dailyCostLimit: String(dailyCostLimit || 0),
|
||||
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
||||
tags: JSON.stringify(tags || []),
|
||||
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
||||
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
||||
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
||||
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
expiresAt: expiresAt || '',
|
||||
createdBy: 'admin' // 可以根据需要扩展用户系统
|
||||
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
|
||||
createdBy: options.createdBy || 'admin',
|
||||
userId: options.userId || '',
|
||||
userUsername: options.userUsername || '',
|
||||
icon: icon || '' // 新增:图标(base64编码)
|
||||
}
|
||||
|
||||
// 保存API Key数据并建立哈希映射
|
||||
@@ -83,6 +97,7 @@ class ApiKeyService {
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
|
||||
isActive: keyData.isActive === 'true',
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
@@ -96,7 +111,12 @@ class ApiKeyService {
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
tags: JSON.parse(keyData.tags || '[]'),
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activatedAt: keyData.activatedAt,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
@@ -125,11 +145,46 @@ class ApiKeyService {
|
||||
return { valid: false, error: 'API key is disabled' }
|
||||
}
|
||||
|
||||
// 处理激活逻辑(仅在 activation 模式下)
|
||||
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
||||
// 首次使用,需要激活
|
||||
const now = new Date()
|
||||
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
|
||||
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
// 更新激活状态和过期时间
|
||||
keyData.isActivated = 'true'
|
||||
keyData.activatedAt = now.toISOString()
|
||||
keyData.expiresAt = expiresAt.toISOString()
|
||||
keyData.lastUsedAt = now.toISOString()
|
||||
|
||||
// 保存到Redis
|
||||
await redis.setApiKey(keyData.id, keyData)
|
||||
|
||||
logger.success(
|
||||
`🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
|
||||
)
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
return { valid: false, error: 'API key has expired' }
|
||||
}
|
||||
|
||||
// 如果API Key属于某个用户,检查用户是否被禁用
|
||||
if (keyData.userId) {
|
||||
try {
|
||||
const userService = require('./userService')
|
||||
const user = await userService.getUserById(keyData.userId, false)
|
||||
if (!user || !user.isActive) {
|
||||
return { valid: false, error: 'User account is disabled' }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error checking user status during API key validation:', error)
|
||||
return { valid: false, error: 'Unable to validate user status' }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
@@ -184,12 +239,15 @@ class ApiKeyService {
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
tags,
|
||||
usage
|
||||
}
|
||||
@@ -200,35 +258,177 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有API Keys
|
||||
async getAllApiKeys() {
|
||||
// 🔍 验证API Key(仅用于统计查询,不触发激活)
|
||||
async validateApiKeyForStats(apiKey) {
|
||||
try {
|
||||
const apiKeys = await redis.getAllApiKeys()
|
||||
if (!apiKey || !apiKey.startsWith(this.prefix)) {
|
||||
return { valid: false, error: 'Invalid API key format' }
|
||||
}
|
||||
|
||||
// 计算API Key的哈希值
|
||||
const hashedKey = this._hashApiKey(apiKey)
|
||||
|
||||
// 通过哈希值直接查找API Key(性能优化)
|
||||
const keyData = await redis.findApiKeyByHash(hashedKey)
|
||||
|
||||
if (!keyData) {
|
||||
return { valid: false, error: 'API key not found' }
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { valid: false, error: 'API key is disabled' }
|
||||
}
|
||||
|
||||
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
|
||||
|
||||
// 检查是否过期(仅对已激活的 Key 检查)
|
||||
if (
|
||||
keyData.isActivated === 'true' &&
|
||||
keyData.expiresAt &&
|
||||
new Date() > new Date(keyData.expiresAt)
|
||||
) {
|
||||
return { valid: false, error: 'API key has expired' }
|
||||
}
|
||||
|
||||
// 如果API Key属于某个用户,检查用户是否被禁用
|
||||
if (keyData.userId) {
|
||||
try {
|
||||
const userService = require('./userService')
|
||||
const user = await userService.getUserById(keyData.userId, false)
|
||||
if (!user || !user.isActive) {
|
||||
return { valid: false, error: 'User account is disabled' }
|
||||
}
|
||||
} catch (userError) {
|
||||
// 如果用户服务出错,记录但不影响API Key验证
|
||||
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当日费用
|
||||
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
|
||||
|
||||
// 获取使用统计
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
// 解析限制模型数据
|
||||
let restrictedModels = []
|
||||
try {
|
||||
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
|
||||
} catch (e) {
|
||||
restrictedModels = []
|
||||
}
|
||||
|
||||
// 解析允许的客户端
|
||||
let allowedClients = []
|
||||
try {
|
||||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
|
||||
} catch (e) {
|
||||
allowedClients = []
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
let tags = []
|
||||
try {
|
||||
tags = keyData.tags ? JSON.parse(keyData.tags) : []
|
||||
} catch (e) {
|
||||
tags = []
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
tags,
|
||||
usage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ API key validation error (stats):', error)
|
||||
return { valid: false, error: 'Internal validation error' }
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有API Keys
|
||||
async getAllApiKeys(includeDeleted = false) {
|
||||
try {
|
||||
let apiKeys = await redis.getAllApiKeys()
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 默认过滤掉已删除的API Keys
|
||||
if (!includeDeleted) {
|
||||
apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true')
|
||||
}
|
||||
|
||||
// 为每个key添加使用统计和当前并发数
|
||||
for (const key of apiKeys) {
|
||||
key.usage = await redis.getUsageStats(key.id)
|
||||
const costStats = await redis.getCostStats(key.id)
|
||||
// Add cost information to usage object for frontend compatibility
|
||||
if (key.usage && costStats) {
|
||||
key.usage.total = key.usage.total || {}
|
||||
key.usage.total.cost = costStats.total
|
||||
key.usage.totalCost = costStats.total
|
||||
}
|
||||
key.totalCost = costStats ? costStats.total : 0
|
||||
key.tokenLimit = parseInt(key.tokenLimit)
|
||||
key.concurrencyLimit = parseInt(key.concurrencyLimit || 0)
|
||||
key.rateLimitWindow = parseInt(key.rateLimitWindow || 0)
|
||||
key.rateLimitRequests = parseInt(key.rateLimitRequests || 0)
|
||||
key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段
|
||||
key.currentConcurrency = await redis.getConcurrency(key.id)
|
||||
key.isActive = key.isActive === 'true'
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
||||
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
||||
key.activationDays = parseInt(key.activationDays || 0)
|
||||
key.expirationMode = key.expirationMode || 'fixed'
|
||||
key.isActivated = key.isActivated === 'true'
|
||||
key.activatedAt = key.activatedAt || null
|
||||
|
||||
// 获取当前时间窗口的请求次数和Token使用量
|
||||
// 获取当前时间窗口的请求次数、Token使用量和费用
|
||||
if (key.rateLimitWindow > 0) {
|
||||
const requestCountKey = `rate_limit:requests:${key.id}`
|
||||
const tokenCountKey = `rate_limit:tokens:${key.id}`
|
||||
const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器
|
||||
const windowStartKey = `rate_limit:window_start:${key.id}`
|
||||
|
||||
key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
|
||||
key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
|
||||
key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
@@ -251,6 +451,7 @@ class ApiKeyService {
|
||||
// 重置计数为0,因为窗口已过期
|
||||
key.currentWindowRequests = 0
|
||||
key.currentWindowTokens = 0
|
||||
key.currentWindowCost = 0 // 新增:重置费用
|
||||
}
|
||||
} else {
|
||||
// 窗口还未开始(没有任何请求)
|
||||
@@ -261,6 +462,7 @@ class ApiKeyService {
|
||||
} else {
|
||||
key.currentWindowRequests = 0
|
||||
key.currentWindowTokens = 0
|
||||
key.currentWindowCost = 0 // 新增:重置费用
|
||||
key.windowStartTime = null
|
||||
key.windowEndTime = null
|
||||
key.windowRemainingSeconds = null
|
||||
@@ -281,6 +483,10 @@ class ApiKeyService {
|
||||
} catch (e) {
|
||||
key.tags = []
|
||||
}
|
||||
// 不暴露已弃用字段
|
||||
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
||||
delete key.ccrAccountId
|
||||
}
|
||||
delete key.apiKey // 不返回哈希后的key
|
||||
}
|
||||
|
||||
@@ -307,6 +513,7 @@ class ApiKeyService {
|
||||
'concurrencyLimit',
|
||||
'rateLimitWindow',
|
||||
'rateLimitRequests',
|
||||
'rateLimitCost', // 新增:速率限制费用字段
|
||||
'isActive',
|
||||
'claudeAccountId',
|
||||
'claudeConsoleAccountId',
|
||||
@@ -316,12 +523,20 @@ class ApiKeyService {
|
||||
'bedrockAccountId', // 添加 Bedrock 账号ID
|
||||
'permissions',
|
||||
'expiresAt',
|
||||
'activationDays', // 新增:激活后有效天数
|
||||
'expirationMode', // 新增:过期模式
|
||||
'isActivated', // 新增:是否已激活
|
||||
'activatedAt', // 新增:激活时间
|
||||
'enableModelRestriction',
|
||||
'restrictedModels',
|
||||
'enableClientRestriction',
|
||||
'allowedClients',
|
||||
'dailyCostLimit',
|
||||
'tags'
|
||||
'weeklyOpusCostLimit',
|
||||
'tags',
|
||||
'userId', // 新增:用户ID(所有者变更)
|
||||
'userUsername', // 新增:用户名(所有者变更)
|
||||
'createdBy' // 新增:创建者(所有者变更)
|
||||
]
|
||||
const updatedData = { ...keyData }
|
||||
|
||||
@@ -330,9 +545,16 @@ class ApiKeyService {
|
||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||
// 特殊处理数组字段
|
||||
updatedData[field] = JSON.stringify(value || [])
|
||||
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
|
||||
} else if (
|
||||
field === 'enableModelRestriction' ||
|
||||
field === 'enableClientRestriction' ||
|
||||
field === 'isActivated'
|
||||
) {
|
||||
// 布尔值转字符串
|
||||
updatedData[field] = String(value)
|
||||
} else if (field === 'expiresAt' || field === 'activatedAt') {
|
||||
// 日期字段保持原样,不要toString()
|
||||
updatedData[field] = value || ''
|
||||
} else {
|
||||
updatedData[field] = (value !== null && value !== undefined ? value : '').toString()
|
||||
}
|
||||
@@ -353,16 +575,32 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除API Key
|
||||
async deleteApiKey(keyId) {
|
||||
// 🗑️ 软删除API Key (保留使用统计)
|
||||
async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') {
|
||||
try {
|
||||
const result = await redis.deleteApiKey(keyId)
|
||||
|
||||
if (result === 0) {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted API key: ${keyId}`)
|
||||
// 标记为已删除,保留所有数据和统计信息
|
||||
const updatedData = {
|
||||
...keyData,
|
||||
isDeleted: 'true',
|
||||
deletedAt: new Date().toISOString(),
|
||||
deletedBy,
|
||||
deletedByType, // 'user', 'admin', 'system'
|
||||
isActive: 'false' // 同时禁用
|
||||
}
|
||||
|
||||
await redis.setApiKey(keyId, updatedData)
|
||||
|
||||
// 从哈希映射中移除(这样就不能再使用这个key进行API调用)
|
||||
if (keyData.apiKey) {
|
||||
await redis.deleteApiKeyHash(keyData.apiKey)
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -371,6 +609,139 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 恢复已删除的API Key
|
||||
async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 检查是否确实是已删除的key
|
||||
if (keyData.isDeleted !== 'true') {
|
||||
throw new Error('API key is not deleted')
|
||||
}
|
||||
|
||||
// 准备更新的数据
|
||||
const updatedData = { ...keyData }
|
||||
updatedData.isActive = 'true'
|
||||
updatedData.restoredAt = new Date().toISOString()
|
||||
updatedData.restoredBy = restoredBy
|
||||
updatedData.restoredByType = restoredByType
|
||||
|
||||
// 从更新的数据中移除删除相关的字段
|
||||
delete updatedData.isDeleted
|
||||
delete updatedData.deletedAt
|
||||
delete updatedData.deletedBy
|
||||
delete updatedData.deletedByType
|
||||
|
||||
// 保存更新后的数据
|
||||
await redis.setApiKey(keyId, updatedData)
|
||||
|
||||
// 使用Redis的hdel命令删除不需要的字段
|
||||
const keyName = `apikey:${keyId}`
|
||||
await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType')
|
||||
|
||||
// 重新建立哈希映射(恢复API Key的使用能力)
|
||||
if (keyData.apiKey) {
|
||||
await redis.setApiKeyHash(keyData.apiKey, {
|
||||
id: keyId,
|
||||
name: keyData.name,
|
||||
isActive: 'true'
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
|
||||
|
||||
return { success: true, apiKey: updatedData }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to restore API key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 彻底删除API Key(物理删除)
|
||||
async permanentDeleteApiKey(keyId) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 确保只能彻底删除已经软删除的key
|
||||
if (keyData.isDeleted !== 'true') {
|
||||
throw new Error('只能彻底删除已经删除的API Key')
|
||||
}
|
||||
|
||||
// 删除所有相关的使用统计数据
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
|
||||
|
||||
// 删除每日统计
|
||||
await redis.client.del(`usage:daily:${today}:${keyId}`)
|
||||
await redis.client.del(`usage:daily:${yesterday}:${keyId}`)
|
||||
|
||||
// 删除月度统计
|
||||
const currentMonth = today.substring(0, 7)
|
||||
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`)
|
||||
|
||||
// 删除所有相关的统计键(通过模式匹配)
|
||||
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`)
|
||||
if (usageKeys.length > 0) {
|
||||
await redis.client.del(...usageKeys)
|
||||
}
|
||||
|
||||
// 删除API Key本身
|
||||
await redis.deleteApiKey(keyId)
|
||||
|
||||
logger.success(`🗑️ Permanently deleted API key: ${keyId}`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to permanently delete API key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清空所有已删除的API Keys
|
||||
async clearAllDeletedApiKeys() {
|
||||
try {
|
||||
const allKeys = await this.getAllApiKeys(true)
|
||||
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true')
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors = []
|
||||
|
||||
for (const key of deletedKeys) {
|
||||
try {
|
||||
await this.permanentDeleteApiKey(key.id)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
failedCount++
|
||||
errors.push({
|
||||
keyId: key.id,
|
||||
keyName: key.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
total: deletedKeys.length,
|
||||
successCount,
|
||||
failedCount,
|
||||
errors
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to clear all deleted API keys:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(支持缓存token和账户级别统计)
|
||||
async recordUsage(
|
||||
keyId,
|
||||
@@ -396,6 +767,13 @@ class ApiKeyService {
|
||||
model
|
||||
)
|
||||
|
||||
// 检查是否为 1M 上下文请求
|
||||
let isLongContextRequest = false
|
||||
if (model && model.includes('[1m]')) {
|
||||
const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens
|
||||
isLongContextRequest = totalInputTokens > 200000
|
||||
}
|
||||
|
||||
// 记录API Key级别的使用统计
|
||||
await redis.incrementTokenUsage(
|
||||
keyId,
|
||||
@@ -404,7 +782,10 @@ class ApiKeyService {
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
model,
|
||||
0, // ephemeral5mTokens - 暂时为0,后续处理
|
||||
0, // ephemeral1hTokens - 暂时为0,后续处理
|
||||
isLongContextRequest
|
||||
)
|
||||
|
||||
// 记录费用统计
|
||||
@@ -433,7 +814,8 @@ class ApiKeyService {
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
model,
|
||||
isLongContextRequest
|
||||
)
|
||||
logger.database(
|
||||
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
|
||||
@@ -460,8 +842,41 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
|
||||
async recordOpusCost(keyId, cost, model, accountType) {
|
||||
try {
|
||||
// 判断是否为 Opus 模型
|
||||
if (!model || !model.toLowerCase().includes('claude-opus')) {
|
||||
return // 不是 Opus 模型,直接返回
|
||||
}
|
||||
|
||||
// 判断是否为 claude、claude-console 或 ccr 账户
|
||||
if (
|
||||
!accountType ||
|
||||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
||||
) {
|
||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
||||
return // 不是 claude 账户,直接返回
|
||||
}
|
||||
|
||||
// 记录 Opus 周费用
|
||||
await redis.incrementWeeklyOpusCost(keyId, cost)
|
||||
logger.database(
|
||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record Opus cost:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||
async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
|
||||
async recordUsageWithDetails(
|
||||
keyId,
|
||||
usageObject,
|
||||
model = 'unknown',
|
||||
accountId = null,
|
||||
accountType = null
|
||||
) {
|
||||
try {
|
||||
// 提取 token 数量
|
||||
const inputTokens = usageObject.input_tokens || 0
|
||||
@@ -505,7 +920,8 @@ class ApiKeyService {
|
||||
cacheReadTokens,
|
||||
model,
|
||||
ephemeral5mTokens, // 传递5分钟缓存 tokens
|
||||
ephemeral1hTokens // 传递1小时缓存 tokens
|
||||
ephemeral1hTokens, // 传递1小时缓存 tokens
|
||||
costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记
|
||||
)
|
||||
|
||||
// 记录费用统计
|
||||
@@ -515,6 +931,9 @@ class ApiKeyService {
|
||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
|
||||
// 记录 Opus 周费用(如果适用)
|
||||
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
|
||||
|
||||
// 记录详细的缓存费用(如果有)
|
||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||
logger.database(
|
||||
@@ -541,7 +960,8 @@ class ApiKeyService {
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
model,
|
||||
costInfo.isLongContextRequest || false
|
||||
)
|
||||
logger.database(
|
||||
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
|
||||
@@ -608,6 +1028,225 @@ class ApiKeyService {
|
||||
return await redis.getAllAccountsUsageStats()
|
||||
}
|
||||
|
||||
// === 用户相关方法 ===
|
||||
|
||||
// 🔑 创建API Key(支持用户)
|
||||
async createApiKey(options = {}) {
|
||||
return await this.generateApiKey(options)
|
||||
}
|
||||
|
||||
// 👤 获取用户的API Keys
|
||||
async getUserApiKeys(userId, includeDeleted = false) {
|
||||
try {
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let userKeys = allKeys.filter((key) => key.userId === userId)
|
||||
|
||||
// 默认过滤掉已删除的API Keys
|
||||
if (!includeDeleted) {
|
||||
userKeys = userKeys.filter((key) => key.isDeleted !== 'true')
|
||||
}
|
||||
|
||||
// Populate usage stats for each user's API key (same as getAllApiKeys does)
|
||||
const userKeysWithUsage = []
|
||||
for (const key of userKeys) {
|
||||
const usage = await redis.getUsageStats(key.id)
|
||||
const dailyCost = (await redis.getDailyCost(key.id)) || 0
|
||||
const costStats = await redis.getCostStats(key.id)
|
||||
|
||||
userKeysWithUsage.push({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位
|
||||
tokenLimit: parseInt(key.tokenLimit || 0),
|
||||
isActive: key.isActive === 'true',
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
usage,
|
||||
dailyCost,
|
||||
totalCost: costStats.total,
|
||||
dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
|
||||
userId: key.userId,
|
||||
userUsername: key.userUsername,
|
||||
createdBy: key.createdBy,
|
||||
// Include deletion fields for deleted keys
|
||||
isDeleted: key.isDeleted,
|
||||
deletedAt: key.deletedAt,
|
||||
deletedBy: key.deletedBy,
|
||||
deletedByType: key.deletedByType
|
||||
})
|
||||
}
|
||||
|
||||
return userKeysWithUsage
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get user API keys:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 通过ID获取API Key(检查权限)
|
||||
async getApiKeyById(keyId, userId = null) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果指定了用户ID,检查权限
|
||||
if (userId && keyData.userId !== userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
key: keyData.apiKey,
|
||||
tokenLimit: parseInt(keyData.tokenLimit || 0),
|
||||
isActive: keyData.isActive === 'true',
|
||||
createdAt: keyData.createdAt,
|
||||
lastUsedAt: keyData.lastUsedAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
userId: keyData.userId,
|
||||
userUsername: keyData.userUsername,
|
||||
createdBy: keyData.createdBy,
|
||||
permissions: keyData.permissions,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API key by ID:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重新生成API Key
|
||||
async regenerateApiKey(keyId) {
|
||||
try {
|
||||
const existingKey = await redis.getApiKey(keyId)
|
||||
if (!existingKey) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 生成新的key
|
||||
const newApiKey = `${this.prefix}${this._generateSecretKey()}`
|
||||
const newHashedKey = this._hashApiKey(newApiKey)
|
||||
|
||||
// 删除旧的哈希映射
|
||||
const oldHashedKey = existingKey.apiKey
|
||||
await redis.deleteApiKeyHash(oldHashedKey)
|
||||
|
||||
// 更新key数据
|
||||
const updatedKeyData = {
|
||||
...existingKey,
|
||||
apiKey: newHashedKey,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 保存新数据并建立新的哈希映射
|
||||
await redis.setApiKey(keyId, updatedKeyData, newHashedKey)
|
||||
|
||||
logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`)
|
||||
|
||||
return {
|
||||
id: keyId,
|
||||
name: existingKey.name,
|
||||
key: newApiKey, // 返回完整的新key
|
||||
updatedAt: updatedKeyData.updatedAt
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to regenerate API key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 硬删除API Key (完全移除)
|
||||
async hardDeleteApiKey(keyId) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 删除key数据和哈希映射
|
||||
await redis.deleteApiKey(keyId)
|
||||
await redis.deleteApiKeyHash(keyData.apiKey)
|
||||
|
||||
logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete API key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 禁用用户的所有API Keys
|
||||
async disableUserApiKeys(userId) {
|
||||
try {
|
||||
const userKeys = await this.getUserApiKeys(userId)
|
||||
let disabledCount = 0
|
||||
|
||||
for (const key of userKeys) {
|
||||
if (key.isActive) {
|
||||
await this.updateApiKey(key.id, { isActive: false })
|
||||
disabledCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`)
|
||||
return { count: disabledCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to disable user API keys:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取聚合使用统计(支持多个API Key)
|
||||
async getAggregatedUsageStats(keyIds, options = {}) {
|
||||
try {
|
||||
if (!Array.isArray(keyIds)) {
|
||||
keyIds = [keyIds]
|
||||
}
|
||||
|
||||
const { period: _period = 'week', model: _model } = options
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
|
||||
// 汇总所有API Key的统计数据
|
||||
for (const keyId of keyIds) {
|
||||
const keyStats = await redis.getUsageStats(keyId)
|
||||
const costStats = await redis.getCostStats(keyId)
|
||||
if (keyStats && keyStats.total) {
|
||||
stats.totalRequests += keyStats.total.requests || 0
|
||||
stats.totalInputTokens += keyStats.total.inputTokens || 0
|
||||
stats.totalOutputTokens += keyStats.total.outputTokens || 0
|
||||
stats.totalCost += costStats?.total || 0
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 实现日期范围和模型统计
|
||||
// 这里可以根据需要添加更详细的统计逻辑
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get usage stats:', error)
|
||||
return {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
try {
|
||||
|
||||
@@ -249,6 +249,10 @@ async function updateAccount(accountId, updates) {
|
||||
|
||||
// 删除账户
|
||||
async function deleteAccount(accountId) {
|
||||
// 首先从所有分组中移除此账户
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
@@ -296,7 +300,11 @@ async function getAllAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push(accountData)
|
||||
accounts.push({
|
||||
...accountData,
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
|
||||
// 转换模型名称(去掉 azure/ 前缀)
|
||||
function normalizeModelName(model) {
|
||||
@@ -29,7 +30,7 @@ async function handleAzureOpenAIRequest({
|
||||
deploymentName = account.deploymentName || 'default'
|
||||
// Azure Responses API requires preview versions; fall back appropriately
|
||||
const apiVersion =
|
||||
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
|
||||
account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01')
|
||||
if (endpoint === 'chat/completions') {
|
||||
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
} else if (endpoint === 'responses') {
|
||||
@@ -53,7 +54,9 @@ async function handleAzureOpenAIRequest({
|
||||
const processedBody = { ...requestBody }
|
||||
|
||||
// 标准化模型名称
|
||||
if (processedBody.model) {
|
||||
if (endpoint === 'responses') {
|
||||
processedBody.model = deploymentName
|
||||
} else if (processedBody.model) {
|
||||
processedBody.model = normalizeModelName(processedBody.model)
|
||||
} else {
|
||||
processedBody.model = 'gpt-4'
|
||||
@@ -68,7 +71,7 @@ async function handleAzureOpenAIRequest({
|
||||
url: requestUrl,
|
||||
headers: requestHeaders,
|
||||
data: processedBody,
|
||||
timeout: 600000, // 10 minutes for Azure OpenAI
|
||||
timeout: config.requestTimeout || 600000,
|
||||
validateStatus: () => true,
|
||||
// 添加连接保活选项
|
||||
keepAlive: true,
|
||||
@@ -273,6 +276,11 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
let eventCount = 0
|
||||
const maxEvents = 10000 // 最大事件数量限制
|
||||
|
||||
// 专门用于保存最后几个chunks以提取usage数据
|
||||
let finalChunksBuffer = ''
|
||||
const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks
|
||||
const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取
|
||||
|
||||
// 设置响应头
|
||||
clientResponse.setHeader('Content-Type', 'text/event-stream')
|
||||
clientResponse.setHeader('Cache-Control', 'no-cache')
|
||||
@@ -297,8 +305,8 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
clientResponse.flushHeaders()
|
||||
}
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据
|
||||
const parseSSEForUsage = (data) => {
|
||||
// 强化的SSE事件解析,保存所有事件用于最终处理
|
||||
const parseSSEForUsage = (data, isFromFinalBuffer = false) => {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -310,34 +318,54 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
}
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
|
||||
// 保存所有成功解析的事件
|
||||
allParsedEvents.push(eventData)
|
||||
|
||||
// 获取模型信息
|
||||
if (eventData.model) {
|
||||
actualModel = eventData.model
|
||||
}
|
||||
|
||||
// 获取使用统计(Responses API: response.completed -> response.usage)
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
}
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
|
||||
// 使用强化的usage提取函数
|
||||
const { usageData: extractedUsage, actualModel: extractedModel } =
|
||||
extractUsageDataRobust(
|
||||
eventData,
|
||||
`stream-event-${isFromFinalBuffer ? 'final' : 'normal'}`
|
||||
)
|
||||
|
||||
if (extractedUsage && !usageData) {
|
||||
usageData = extractedUsage
|
||||
if (extractedModel) {
|
||||
actualModel = extractedModel
|
||||
}
|
||||
logger.debug(`🎯 Stream usage captured via robust extraction`, {
|
||||
isFromFinalBuffer,
|
||||
usageData,
|
||||
actualModel
|
||||
})
|
||||
}
|
||||
|
||||
// 兼容 Chat Completions 风格(顶层 usage)
|
||||
if (!usageData && eventData.usage) {
|
||||
usageData = eventData.usage
|
||||
logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
|
||||
}
|
||||
// 原有的简单提取作为备用
|
||||
if (!usageData) {
|
||||
// 获取使用统计(Responses API: response.completed -> response.usage)
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
}
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('🎯 Stream usage (backup method - response.usage):', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是完成事件
|
||||
if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
|
||||
// 这是最后一个 chunk
|
||||
// 兼容 Chat Completions 风格(顶层 usage)
|
||||
if (!usageData && eventData.usage) {
|
||||
usageData = eventData.usage
|
||||
logger.debug('🎯 Stream usage (backup method - top-level):', usageData)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
logger.debug('SSE parsing error (expected for incomplete chunks):', e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,10 +415,19 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制
|
||||
buffer += chunkStr
|
||||
|
||||
// 防止缓冲区过大
|
||||
// 保留最后的chunks用于最终usage提取(不被truncate影响)
|
||||
finalChunksBuffer += chunkStr
|
||||
if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) {
|
||||
finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE)
|
||||
}
|
||||
|
||||
// 防止主缓冲区过大 - 但保持最后部分用于usage解析
|
||||
if (buffer.length > MAX_BUFFER_SIZE) {
|
||||
logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
|
||||
buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
|
||||
logger.warn(
|
||||
`Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks`
|
||||
)
|
||||
// 保留最后1/4而不是1/2,为usage数据留更多空间
|
||||
buffer = buffer.slice(-MAX_BUFFER_SIZE / 4)
|
||||
}
|
||||
|
||||
// 处理完整的 SSE 事件
|
||||
@@ -426,9 +463,91 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
hasEnded = true
|
||||
|
||||
try {
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
|
||||
parseSSEForUsage(buffer)
|
||||
logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, {
|
||||
mainBufferSize: buffer.length,
|
||||
finalChunksBufferSize: finalChunksBuffer.length,
|
||||
parsedEventsCount: allParsedEvents.length,
|
||||
hasUsageData: !!usageData
|
||||
})
|
||||
|
||||
// 多层次的最终usage提取策略
|
||||
if (!usageData) {
|
||||
logger.debug('🔍 No usage found during stream, trying final extraction methods...')
|
||||
|
||||
// 方法1: 解析剩余的主buffer
|
||||
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
|
||||
parseSSEForUsage(buffer, false)
|
||||
}
|
||||
|
||||
// 方法2: 解析保留的final chunks buffer
|
||||
if (!usageData && finalChunksBuffer.trim()) {
|
||||
logger.debug('🔍 Trying final chunks buffer for usage extraction...')
|
||||
parseSSEForUsage(finalChunksBuffer, true)
|
||||
}
|
||||
|
||||
// 方法3: 从所有解析的事件中重新搜索usage
|
||||
if (!usageData && allParsedEvents.length > 0) {
|
||||
logger.debug('🔍 Searching through all parsed events for usage...')
|
||||
|
||||
// 倒序查找,因为usage通常在最后
|
||||
for (let i = allParsedEvents.length - 1; i >= 0; i--) {
|
||||
const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust(
|
||||
allParsedEvents[i],
|
||||
`final-event-scan-${i}`
|
||||
)
|
||||
if (foundUsage) {
|
||||
usageData = foundUsage
|
||||
if (foundModel) {
|
||||
actualModel = foundModel
|
||||
}
|
||||
logger.debug(`🎯 Usage found in event ${i} during final scan!`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法4: 尝试合并所有事件并搜索
|
||||
if (!usageData && allParsedEvents.length > 0) {
|
||||
logger.debug('🔍 Trying combined events analysis...')
|
||||
const combinedData = {
|
||||
events: allParsedEvents,
|
||||
lastEvent: allParsedEvents[allParsedEvents.length - 1],
|
||||
eventCount: allParsedEvents.length
|
||||
}
|
||||
|
||||
const { usageData: combinedUsage } = extractUsageDataRobust(
|
||||
combinedData,
|
||||
'combined-events'
|
||||
)
|
||||
if (combinedUsage) {
|
||||
usageData = combinedUsage
|
||||
logger.debug('🎯 Usage found via combined events analysis!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终usage状态报告
|
||||
if (usageData) {
|
||||
logger.debug('✅ Final stream usage extraction SUCCESS', {
|
||||
streamId,
|
||||
usageData,
|
||||
actualModel,
|
||||
totalEvents: allParsedEvents.length,
|
||||
finalBufferSize: finalChunksBuffer.length
|
||||
})
|
||||
} else {
|
||||
logger.warn('❌ Final stream usage extraction FAILED', {
|
||||
streamId,
|
||||
totalEvents: allParsedEvents.length,
|
||||
finalBufferSize: finalChunksBuffer.length,
|
||||
mainBufferSize: buffer.length,
|
||||
lastFewEvents: allParsedEvents.slice(-3).map((e) => ({
|
||||
type: e.type,
|
||||
hasUsage: !!e.usage,
|
||||
hasResponse: !!e.response,
|
||||
keys: Object.keys(e)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (onEnd) {
|
||||
@@ -484,6 +603,120 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
// 强化的用量数据提取函数
|
||||
function extractUsageDataRobust(responseData, context = 'unknown') {
|
||||
logger.debug(`🔍 Attempting usage extraction for ${context}`, {
|
||||
responseDataKeys: Object.keys(responseData || {}),
|
||||
responseDataType: typeof responseData,
|
||||
hasUsage: !!responseData?.usage,
|
||||
hasResponse: !!responseData?.response
|
||||
})
|
||||
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
|
||||
try {
|
||||
// 策略 1: 顶层 usage (标准 Chat Completions)
|
||||
if (responseData?.usage) {
|
||||
usageData = responseData.usage
|
||||
actualModel = responseData.model
|
||||
logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel })
|
||||
}
|
||||
|
||||
// 策略 2: response.usage (Responses API)
|
||||
else if (responseData?.response?.usage) {
|
||||
usageData = responseData.response.usage
|
||||
actualModel = responseData.response.model || responseData.model
|
||||
logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel })
|
||||
}
|
||||
|
||||
// 策略 3: 嵌套搜索 - 深度查找 usage 字段
|
||||
else {
|
||||
const findUsageRecursive = (obj, path = '') => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const currentPath = path ? `${path}.${key}` : key
|
||||
|
||||
if (key === 'usage' && value && typeof value === 'object') {
|
||||
logger.debug(`✅ Usage found at path: ${currentPath}`, value)
|
||||
return { usage: value, path: currentPath }
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const nested = findUsageRecursive(value, currentPath)
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const found = findUsageRecursive(responseData)
|
||||
if (found) {
|
||||
usageData = found.usage
|
||||
// Try to find model in the same parent object
|
||||
const pathParts = found.path.split('.')
|
||||
pathParts.pop() // remove 'usage'
|
||||
let modelParent = responseData
|
||||
for (const part of pathParts) {
|
||||
modelParent = modelParent?.[part]
|
||||
}
|
||||
actualModel = modelParent?.model || responseData?.model
|
||||
logger.debug('✅ Usage extracted via Strategy 3 (recursive)', {
|
||||
usageData,
|
||||
actualModel,
|
||||
foundPath: found.path
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 4: 特殊响应格式处理
|
||||
if (!usageData) {
|
||||
// 检查是否有 choices 数组,usage 可能在最后一个 choice 中
|
||||
if (responseData?.choices?.length > 0) {
|
||||
const lastChoice = responseData.choices[responseData.choices.length - 1]
|
||||
if (lastChoice?.usage) {
|
||||
usageData = lastChoice.usage
|
||||
actualModel = responseData.model || lastChoice.model
|
||||
logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证和记录
|
||||
if (usageData) {
|
||||
logger.debug('🎯 Final usage extraction result', {
|
||||
context,
|
||||
usageData,
|
||||
actualModel,
|
||||
inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0,
|
||||
outputTokens: usageData.completion_tokens || usageData.output_tokens || 0,
|
||||
totalTokens: usageData.total_tokens || 0
|
||||
})
|
||||
} else {
|
||||
logger.warn('❌ Failed to extract usage data', {
|
||||
context,
|
||||
responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`,
|
||||
availableKeys: Object.keys(responseData || {}),
|
||||
responseSize: JSON.stringify(responseData || {}).length
|
||||
})
|
||||
}
|
||||
} catch (extractionError) {
|
||||
logger.error('🚨 Error during usage extraction', {
|
||||
context,
|
||||
error: extractionError.message,
|
||||
stack: extractionError.stack,
|
||||
responseDataType: typeof responseData
|
||||
})
|
||||
}
|
||||
|
||||
return { usageData, actualModel }
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
function handleNonStreamResponse(upstreamResponse, clientResponse) {
|
||||
try {
|
||||
@@ -510,9 +743,8 @@ function handleNonStreamResponse(upstreamResponse, clientResponse) {
|
||||
const responseData = upstreamResponse.data
|
||||
clientResponse.json(responseData)
|
||||
|
||||
// 提取 usage 数据
|
||||
const usageData = responseData.usage
|
||||
const actualModel = responseData.model
|
||||
// 使用强化的用量提取
|
||||
const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream')
|
||||
|
||||
return { usageData, actualModel, responseData }
|
||||
} catch (error) {
|
||||
|
||||
903
src/services/ccrAccountService.js
Normal file
903
src/services/ccrAccountService.js
Normal file
@@ -0,0 +1,903 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建CCR账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'CCR Account',
|
||||
description = '',
|
||||
apiUrl = '',
|
||||
apiKey = '',
|
||||
priority = 50, // 默认优先级50(1-100)
|
||||
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
||||
userAgent = 'claude-relay-service/1.0.0',
|
||||
rateLimitDuration = 60, // 限流时间(分钟)
|
||||
proxy = null,
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error('API URL and API Key are required for CCR account')
|
||||
}
|
||||
|
||||
const accountId = uuidv4()
|
||||
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(supportedModels)
|
||||
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
platform: 'ccr',
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey: this._encryptSensitiveData(apiKey),
|
||||
priority: priority.toString(),
|
||||
supportedModels: JSON.stringify(processedModels),
|
||||
userAgent,
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
// 调度控制
|
||||
schedulable: schedulable.toString(),
|
||||
// 额度管理相关
|
||||
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
|
||||
dailyUsage: '0', // 当日使用金额(美元)
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(
|
||||
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
logger.success(`🏢 Created CCR account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
isActive,
|
||||
proxy,
|
||||
accountType,
|
||||
status: 'active',
|
||||
createdAt: accountData.createdAt,
|
||||
dailyQuota,
|
||||
dailyUsage: 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accounts = []
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
accounts.push({
|
||||
id: accountData.id,
|
||||
platform: accountData.platform,
|
||||
name: accountData.name,
|
||||
description: accountData.description,
|
||||
apiUrl: accountData.apiUrl,
|
||||
priority: parseInt(accountData.priority) || 50,
|
||||
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
|
||||
userAgent: accountData.userAgent,
|
||||
rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration))
|
||||
? 60
|
||||
: parseInt(accountData.rateLimitDuration),
|
||||
isActive: accountData.isActive === 'true',
|
||||
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||
accountType: accountData.accountType || 'shared',
|
||||
createdAt: accountData.createdAt,
|
||||
lastUsedAt: accountData.lastUsedAt,
|
||||
status: accountData.status || 'active',
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||
async getAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`)
|
||||
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`)
|
||||
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`)
|
||||
|
||||
// 解密敏感字段(只解密apiKey,apiUrl不加密)
|
||||
const decryptedKey = this._decryptSensitiveData(accountData.apiKey)
|
||||
logger.debug(
|
||||
`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`
|
||||
)
|
||||
|
||||
accountData.apiKey = decryptedKey
|
||||
|
||||
// 解析JSON字段
|
||||
const parsedModels = JSON.parse(accountData.supportedModels || '[]')
|
||||
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`)
|
||||
|
||||
accountData.supportedModels = parsedModels
|
||||
accountData.priority = parseInt(accountData.priority) || 50
|
||||
{
|
||||
const _parsedDuration = parseInt(accountData.rateLimitDuration)
|
||||
accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration
|
||||
}
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||
)
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 📝 更新账户
|
||||
async updateAccount(accountId, updates) {
|
||||
try {
|
||||
const existingAccount = await this.getAccount(accountId)
|
||||
if (!existingAccount) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const updatedData = {}
|
||||
|
||||
// 处理各个字段的更新
|
||||
logger.debug(
|
||||
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`)
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updatedData.name = updates.name
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
updatedData.description = updates.description
|
||||
}
|
||||
if (updates.apiUrl !== undefined) {
|
||||
updatedData.apiUrl = updates.apiUrl
|
||||
}
|
||||
if (updates.apiKey !== undefined) {
|
||||
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
updatedData.priority = updates.priority.toString()
|
||||
}
|
||||
if (updates.supportedModels !== undefined) {
|
||||
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`)
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(updates.supportedModels)
|
||||
updatedData.supportedModels = JSON.stringify(processedModels)
|
||||
}
|
||||
if (updates.userAgent !== undefined) {
|
||||
updatedData.userAgent = updates.userAgent
|
||||
}
|
||||
if (updates.rateLimitDuration !== undefined) {
|
||||
updatedData.rateLimitDuration = updates.rateLimitDuration.toString()
|
||||
}
|
||||
if (updates.proxy !== undefined) {
|
||||
updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
updatedData.isActive = updates.isActive.toString()
|
||||
}
|
||||
if (updates.schedulable !== undefined) {
|
||||
updatedData.schedulable = updates.schedulable.toString()
|
||||
}
|
||||
if (updates.dailyQuota !== undefined) {
|
||||
updatedData.dailyQuota = updates.dailyQuota.toString()
|
||||
}
|
||||
if (updates.quotaResetTime !== undefined) {
|
||||
updatedData.quotaResetTime = updates.quotaResetTime
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||
|
||||
// 处理共享账户集合变更
|
||||
if (updates.accountType !== undefined) {
|
||||
updatedData.accountType = updates.accountType
|
||||
if (updates.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
} else {
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`📝 Updated CCR account: ${accountId}`)
|
||||
return await this.getAccount(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除账户
|
||||
async deleteAccount(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error('CCR Account not found or already deleted')
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
// 如果限流时间设置为 0,表示不启用限流机制,直接返回
|
||||
if (account.rateLimitDuration === 0) {
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit`
|
||||
)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'rate_limited',
|
||||
rateLimitedAt: now,
|
||||
rateLimitStatus: 'active',
|
||||
errorMessage: 'Rate limited by upstream service'
|
||||
})
|
||||
|
||||
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`)
|
||||
return { success: true, rateLimitedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户限流状态
|
||||
async removeAccountRateLimit(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 获取账户当前状态和额度信息
|
||||
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt')
|
||||
|
||||
// 删除限流相关字段
|
||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
|
||||
|
||||
// 根据不同情况决定是否恢复账户
|
||||
let newStatus = 'active'
|
||||
let errorMessage = ''
|
||||
|
||||
// 如果因额度问题停用,不要自动激活
|
||||
if (quotaStoppedAt) {
|
||||
newStatus = 'quota_exceeded'
|
||||
errorMessage = 'Account stopped due to quota exceeded'
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: newStatus,
|
||||
errorMessage
|
||||
})
|
||||
|
||||
return { success: true, newStatus }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否被限流
|
||||
async isAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const [rateLimitedAt, rateLimitDuration] = await client.hmget(
|
||||
accountKey,
|
||||
'rateLimitedAt',
|
||||
'rateLimitDuration'
|
||||
)
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const duration = parseInt(rateLimitDuration) || 60
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000)
|
||||
|
||||
if (now < expireTime) {
|
||||
return true
|
||||
} else {
|
||||
// 限流时间已过,自动移除限流状态
|
||||
await this.removeAccountRateLimit(accountId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 标记账户为过载状态
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'overloaded',
|
||||
overloadedAt: now,
|
||||
errorMessage: 'Account overloaded'
|
||||
})
|
||||
|
||||
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`)
|
||||
return { success: true, overloadedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户过载状态
|
||||
async removeAccountOverload(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 删除过载相关字段
|
||||
await client.hdel(accountKey, 'overloadedAt')
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否过载
|
||||
async isAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const status = await client.hget(accountKey, 'status')
|
||||
return status === 'overloaded'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态
|
||||
async markAccountUnauthorized(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'unauthorized',
|
||||
errorMessage: 'API key invalid or unauthorized'
|
||||
})
|
||||
|
||||
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 处理模型映射
|
||||
_processModelMapping(supportedModels) {
|
||||
// 如果是空值,返回空对象(支持所有模型)
|
||||
if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 如果已经是对象格式(新的映射表格式),直接返回
|
||||
if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) {
|
||||
return supportedModels
|
||||
}
|
||||
|
||||
// 如果是数组格式(旧格式),转换为映射表
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
supportedModels.forEach((model) => {
|
||||
if (model && typeof model === 'string') {
|
||||
mapping[model] = model // 默认映射:原模型名 -> 原模型名
|
||||
}
|
||||
})
|
||||
return mapping
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// 🔍 检查模型是否被支持
|
||||
isModelSupported(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,支持所有模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
getMappedModel(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,返回原模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
const { rateLimitedAt } = accountData
|
||||
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000)
|
||||
const remainingMs = expireTime.getTime() - now.getTime()
|
||||
|
||||
return {
|
||||
isRateLimited: remainingMs > 0,
|
||||
rateLimitedAt,
|
||||
rateLimitExpireAt: expireTime.toISOString(),
|
||||
remainingTimeMs: Math.max(0, remainingMs),
|
||||
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000)))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitExpireAt: null,
|
||||
remainingTimeMs: 0,
|
||||
remainingTimeMinutes: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 创建代理客户端
|
||||
_createProxyAgent(proxy) {
|
||||
return ProxyHelper.createProxyAgent(proxy)
|
||||
}
|
||||
|
||||
// 💰 检查配额使用情况(可选实现)
|
||||
async checkQuotaUsage(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用量
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(accountId)
|
||||
return false // 刚重置,不会超额
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(
|
||||
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'quota_exceeded',
|
||||
errorCode: 'QUOTA_EXCEEDED',
|
||||
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置每日使用量(可选实现)
|
||||
async resetDailyUsage(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 检查账户是否超额
|
||||
async isAccountQuotaExceeded(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded && !account.quotaStoppedAt) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`)
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置所有CCR账户的每日使用量
|
||||
async resetAllDailyUsage() {
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
let resetCount = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(account.id)
|
||||
resetCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取CCR账户使用统计(含每日费用)
|
||||
async getAccountUsageStats(accountId) {
|
||||
try {
|
||||
// 使用统一的 Redis 统计
|
||||
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||
|
||||
// 叠加账户自身的额度配置
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||
const currentDailyCost = usageStats?.daily?.cost || 0
|
||||
|
||||
return {
|
||||
dailyQuota,
|
||||
dailyUsage: currentDailyCost,
|
||||
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
|
||||
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime: accountData.quotaResetTime,
|
||||
quotaStoppedAt: accountData.quotaStoppedAt,
|
||||
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
|
||||
fullUsageStats: usageStats
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置CCR账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const updates = {
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true',
|
||||
isActive: 'true'
|
||||
}
|
||||
|
||||
const fieldsToDelete = [
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'unauthorizedAt',
|
||||
'unauthorizedCount',
|
||||
'overloadedAt',
|
||||
'overloadStatus',
|
||||
'blockedAt',
|
||||
'quotaStoppedAt'
|
||||
]
|
||||
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, accountId }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrAccountService()
|
||||
641
src/services/ccrRelayService.js
Normal file
641
src/services/ccrRelayService.js
Normal file
@@ -0,0 +1,641 @@
|
||||
const axios = require('axios')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
|
||||
class CcrRelayService {
|
||||
constructor() {
|
||||
this.defaultUserAgent = 'claude-relay-service/1.0.0'
|
||||
}
|
||||
|
||||
// 🚀 转发请求到CCR API
|
||||
async relayRequest(
|
||||
requestBody,
|
||||
apiKeyData,
|
||||
clientRequest,
|
||||
clientResponse,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
options = {}
|
||||
) {
|
||||
let abortController = null
|
||||
let account = null
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR account not found')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||
)
|
||||
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`)
|
||||
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`)
|
||||
logger.debug(`📝 Request model: ${requestBody.model}`)
|
||||
|
||||
// 处理模型前缀解析和映射
|
||||
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||
|
||||
let mappedModel = baseModel
|
||||
if (
|
||||
account.supportedModels &&
|
||||
typeof account.supportedModels === 'object' &&
|
||||
!Array.isArray(account.supportedModels)
|
||||
) {
|
||||
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||
if (newModel !== baseModel) {
|
||||
logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`)
|
||||
mappedModel = newModel
|
||||
}
|
||||
}
|
||||
|
||||
// 创建修改后的请求体,使用去前缀后的模型名
|
||||
const modifiedRequestBody = {
|
||||
...requestBody,
|
||||
model: mappedModel
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 创建AbortController用于取消请求
|
||||
abortController = new AbortController()
|
||||
|
||||
// 设置客户端断开监听器
|
||||
const handleClientDisconnect = () => {
|
||||
logger.info('🔌 Client disconnected, aborting CCR request')
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听客户端断开事件
|
||||
if (clientRequest) {
|
||||
clientRequest.once('close', handleClientDisconnect)
|
||||
}
|
||||
if (clientResponse) {
|
||||
clientResponse.once('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||
let apiEndpoint
|
||||
|
||||
if (options.customPath) {
|
||||
// 如果指定了自定义路径(如 count_tokens),使用它
|
||||
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
|
||||
apiEndpoint = `${baseUrl}${options.customPath}`
|
||||
} else {
|
||||
// 默认使用 messages 端点
|
||||
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
}
|
||||
|
||||
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
|
||||
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
|
||||
logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`)
|
||||
|
||||
// 过滤客户端请求头
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||
|
||||
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||
const userAgent =
|
||||
account.userAgent ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: modifiedRequestBody,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
signal: abortController.signal,
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||
} else {
|
||||
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`
|
||||
)
|
||||
|
||||
// 添加beta header如果需要
|
||||
if (options.betaHeader) {
|
||||
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`)
|
||||
requestConfig.headers['anthropic-beta'] = options.betaHeader
|
||||
} else {
|
||||
logger.debug('[DEBUG] No beta header to add')
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
logger.debug(
|
||||
'📤 Sending request to CCR API with headers:',
|
||||
JSON.stringify(requestConfig.headers, null, 2)
|
||||
)
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
// 移除监听器(请求成功完成)
|
||||
if (clientRequest) {
|
||||
clientRequest.removeListener('close', handleClientDisconnect)
|
||||
}
|
||||
if (clientResponse) {
|
||||
clientResponse.removeListener('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
logger.debug(`🔗 CCR API response: ${response.status}`)
|
||||
logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`)
|
||||
logger.debug(`[DEBUG] Response data type: ${typeof response.data}`)
|
||||
logger.debug(
|
||||
`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
|
||||
)
|
||||
logger.debug(
|
||||
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
|
||||
)
|
||||
|
||||
// 检查错误状态并相应处理
|
||||
if (response.status === 401) {
|
||||
logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`)
|
||||
await ccrAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
|
||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||
await ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
|
||||
await ccrAccountService.markAccountRateLimited(accountId)
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(`🚫 Overload error detected for CCR account ${accountId}`)
|
||||
await ccrAccountService.markAccountOverloaded(accountId)
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId)
|
||||
if (isOverloaded) {
|
||||
await ccrAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
|
||||
const responseBody =
|
||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: response.headers,
|
||||
body: responseBody,
|
||||
accountId
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理特定错误
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
|
||||
logger.info('Request aborted due to client disconnect')
|
||||
throw new Error('Client disconnected')
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`❌ CCR relay request failed (Account: ${account?.name || accountId}):`,
|
||||
error.message
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🌊 处理流式响应
|
||||
async relayStreamRequestWithUsageCapture(
|
||||
requestBody,
|
||||
apiKeyData,
|
||||
responseStream,
|
||||
clientHeaders,
|
||||
usageCallback,
|
||||
accountId,
|
||||
streamTransformer = null,
|
||||
options = {}
|
||||
) {
|
||||
let account = null
|
||||
try {
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR account not found')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||
)
|
||||
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||
|
||||
// 处理模型前缀解析和映射
|
||||
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||
|
||||
let mappedModel = baseModel
|
||||
if (
|
||||
account.supportedModels &&
|
||||
typeof account.supportedModels === 'object' &&
|
||||
!Array.isArray(account.supportedModels)
|
||||
) {
|
||||
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||
if (newModel !== baseModel) {
|
||||
logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`)
|
||||
mappedModel = newModel
|
||||
}
|
||||
}
|
||||
|
||||
// 创建修改后的请求体,使用去前缀后的模型名
|
||||
const modifiedRequestBody = {
|
||||
...requestBody,
|
||||
model: mappedModel
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 发送流式请求
|
||||
await this._makeCcrStreamRequest(
|
||||
modifiedRequestBody,
|
||||
account,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
options
|
||||
)
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到CCR API
|
||||
async _makeCcrStreamRequest(
|
||||
body,
|
||||
account,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let aborted = false
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
|
||||
logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`)
|
||||
|
||||
// 过滤客户端请求头
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||
|
||||
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||
const userAgent =
|
||||
account.userAgent ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||
} else {
|
||||
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||
}
|
||||
|
||||
// 添加beta header如果需要
|
||||
if (requestOptions.betaHeader) {
|
||||
requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const request = axios(requestConfig)
|
||||
|
||||
request
|
||||
.then((response) => {
|
||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||
|
||||
// 错误响应处理
|
||||
if (response.status !== 200) {
|
||||
logger.error(
|
||||
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
if (response.status === 401) {
|
||||
ccrAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
ccrAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
} else if (response.status === 529) {
|
||||
ccrAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
|
||||
// 设置错误响应的状态码和响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const errorHeaders = {
|
||||
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||
delete errorHeaders['Transfer-Encoding']
|
||||
delete errorHeaders['Content-Length']
|
||||
responseStream.writeHead(response.status, errorHeaders)
|
||||
}
|
||||
|
||||
// 直接透传错误数据,不进行包装
|
||||
response.data.on('data', (chunk) => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write(chunk)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
resolve() // 不抛出异常,正常完成流处理
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除错误状态
|
||||
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
})
|
||||
ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
|
||||
if (isOverloaded) {
|
||||
ccrAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
})
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
}
|
||||
responseStream.writeHead(200, headers)
|
||||
}
|
||||
|
||||
// 处理流数据和使用统计收集
|
||||
let rawBuffer = ''
|
||||
const collectedUsage = {}
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
if (aborted || responseStream.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkStr = chunk.toString('utf8')
|
||||
rawBuffer += chunkStr
|
||||
|
||||
// 按行分割处理 SSE 数据
|
||||
const lines = rawBuffer.split('\n')
|
||||
rawBuffer = lines.pop() // 保留最后一个可能不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
// 解析 SSE 数据并收集使用统计
|
||||
const usageData = this._parseSSELineForUsage(line)
|
||||
if (usageData) {
|
||||
Object.assign(collectedUsage, usageData)
|
||||
}
|
||||
|
||||
// 应用流转换器(如果提供)
|
||||
let outputLine = line
|
||||
if (streamTransformer && typeof streamTransformer === 'function') {
|
||||
outputLine = streamTransformer(line)
|
||||
}
|
||||
|
||||
// 写入到响应流
|
||||
if (outputLine && !responseStream.destroyed) {
|
||||
responseStream.write(`${outputLine}\n`)
|
||||
}
|
||||
} else {
|
||||
// 空行也需要传递
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('❌ Error processing SSE chunk:', err)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
// 如果收集到使用统计数据,调用回调
|
||||
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||
try {
|
||||
logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`)
|
||||
// 在 usage 回调中包含模型信息
|
||||
usageCallback({ ...collectedUsage, accountId, model: body.model })
|
||||
} catch (err) {
|
||||
logger.error('❌ Error in usage callback:', err)
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error('❌ Stream data error:', err)
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// 客户端断开处理
|
||||
responseStream.on('close', () => {
|
||||
logger.info('🔌 Client disconnected from CCR stream')
|
||||
aborted = true
|
||||
if (response.data && typeof response.data.destroy === 'function') {
|
||||
response.data.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
responseStream.on('error', (err) => {
|
||||
logger.error('❌ Response stream error:', err)
|
||||
aborted = true
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
}
|
||||
|
||||
const errorResponse = {
|
||||
error: {
|
||||
type: 'internal_error',
|
||||
message: 'CCR API request failed'
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 📊 解析SSE行以提取使用统计信息
|
||||
_parseSSELineForUsage(line) {
|
||||
try {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6).trim()
|
||||
if (data === '[DONE]') {
|
||||
return null
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(data)
|
||||
|
||||
// 检查是否包含使用统计信息
|
||||
if (jsonData.usage) {
|
||||
return {
|
||||
input_tokens: jsonData.usage.input_tokens || 0,
|
||||
output_tokens: jsonData.usage.output_tokens || 0,
|
||||
cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0,
|
||||
cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0,
|
||||
// 支持 ephemeral cache 字段
|
||||
cache_creation_input_tokens_ephemeral_5m:
|
||||
jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||
cache_creation_input_tokens_ephemeral_1h:
|
||||
jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 message_delta 事件中的使用统计
|
||||
if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) {
|
||||
return {
|
||||
input_tokens: jsonData.delta.usage.input_tokens || 0,
|
||||
output_tokens: jsonData.delta.usage.output_tokens || 0,
|
||||
cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0,
|
||||
cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens_ephemeral_5m:
|
||||
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||
cache_creation_input_tokens_ephemeral_1h:
|
||||
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略解析错误,不是所有行都包含 JSON
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 🔍 过滤客户端请求头
|
||||
_filterClientHeaders(clientHeaders) {
|
||||
if (!clientHeaders) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const filteredHeaders = {}
|
||||
const allowedHeaders = [
|
||||
'accept-language',
|
||||
'anthropic-beta',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
]
|
||||
|
||||
// 只保留允许的头部信息
|
||||
for (const [key, value] of Object.entries(clientHeaders)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return filteredHeaders
|
||||
}
|
||||
|
||||
// ⏰ 更新账户最后使用时间
|
||||
async _updateLastUsedTime(accountId) {
|
||||
try {
|
||||
const redis = require('../models/redis')
|
||||
const client = redis.getClientSafe()
|
||||
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrRelayService()
|
||||
@@ -3,8 +3,8 @@ const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const {
|
||||
logRefreshStart,
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
@@ -57,7 +58,11 @@ class ClaudeAccountService {
|
||||
platform = 'claude',
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
schedulable = true, // 是否可被调度
|
||||
subscriptionInfo = null // 手动设置的订阅信息
|
||||
subscriptionInfo = null, // 手动设置的订阅信息
|
||||
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
|
||||
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
|
||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||
unifiedClientId = '' // 统一的客户端标识
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -88,6 +93,10 @@ class ClaudeAccountService {
|
||||
status: 'active', // 有OAuth数据的账户直接设为active
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
||||
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
||||
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
|
||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||
// 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空
|
||||
subscriptionInfo: subscriptionInfo
|
||||
? JSON.stringify(subscriptionInfo)
|
||||
@@ -118,6 +127,8 @@ class ClaudeAccountService {
|
||||
status: 'created', // created, active, expired, error
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
||||
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
||||
// 手动设置的订阅信息
|
||||
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
|
||||
}
|
||||
@@ -158,7 +169,11 @@ class ClaudeAccountService {
|
||||
status: accountData.status,
|
||||
createdAt: accountData.createdAt,
|
||||
expiresAt: accountData.expiresAt,
|
||||
scopes: claudeAiOauth ? claudeAiOauth.scopes : []
|
||||
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
|
||||
autoStopOnWarning,
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +494,16 @@ class ClaudeAccountService {
|
||||
lastRequestTime: null
|
||||
},
|
||||
// 添加调度状态
|
||||
schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据
|
||||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||||
// 添加自动停止调度设置
|
||||
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
||||
// 添加统一User-Agent设置
|
||||
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
||||
// 添加统一客户端标识设置
|
||||
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
|
||||
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
|
||||
// 添加停止原因
|
||||
stoppedReason: account.stoppedReason || null
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -512,7 +536,11 @@ class ClaudeAccountService {
|
||||
'accountType',
|
||||
'priority',
|
||||
'schedulable',
|
||||
'subscriptionInfo'
|
||||
'subscriptionInfo',
|
||||
'autoStopOnWarning',
|
||||
'useUnifiedUserAgent',
|
||||
'useUnifiedClientId',
|
||||
'unifiedClientId'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
|
||||
@@ -575,6 +603,25 @@ class ClaudeAccountService {
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString()
|
||||
|
||||
// 如果是手动修改调度状态,清除所有自动停止相关的字段
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) {
|
||||
// 清除所有自动停止的标记,防止自动恢复
|
||||
delete updatedData.rateLimitAutoStopped
|
||||
delete updatedData.fiveHourAutoStopped
|
||||
delete updatedData.fiveHourStoppedAt
|
||||
delete updatedData.tempErrorAutoStopped
|
||||
// 兼容旧的标记(逐步迁移)
|
||||
delete updatedData.autoStoppedAt
|
||||
delete updatedData.stoppedReason
|
||||
|
||||
// 如果是手动启用调度,记录日志
|
||||
if (updates.schedulable === true || updates.schedulable === 'true') {
|
||||
logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
|
||||
} else {
|
||||
logger.info(`⛔ Manually disabled scheduling for account ${accountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && accountData.isActive === 'true') {
|
||||
try {
|
||||
@@ -611,10 +658,7 @@ class ClaudeAccountService {
|
||||
try {
|
||||
// 首先从所有分组中移除此账户
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const groups = await accountGroupService.getAccountGroup(accountId)
|
||||
for (const group of groups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||
}
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
|
||||
const result = await redis.deleteClaudeAccount(accountId)
|
||||
|
||||
@@ -682,6 +726,8 @@ class ClaudeAccountService {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
|
||||
if (mappedAccount) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -708,7 +754,9 @@ class ClaudeAccountService {
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
|
||||
// 从配置获取TTL(小时),转换为秒
|
||||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -802,6 +850,8 @@ class ClaudeAccountService {
|
||||
)
|
||||
await redis.deleteSessionAccountMapping(sessionHash)
|
||||
} else {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -860,7 +910,9 @@ class ClaudeAccountService {
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
|
||||
// 从配置获取TTL(小时),转换为秒
|
||||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -1054,6 +1106,10 @@ class ClaudeAccountService {
|
||||
const updatedAccountData = { ...accountData }
|
||||
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
||||
updatedAccountData.rateLimitStatus = 'limited'
|
||||
// 限流时停止调度,与 OpenAI 账号保持一致
|
||||
updatedAccountData.schedulable = 'false'
|
||||
// 使用独立的限流自动停止标记,避免与其他自动停止冲突
|
||||
updatedAccountData.rateLimitAutoStopped = 'true'
|
||||
|
||||
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
||||
if (rateLimitResetTimestamp) {
|
||||
@@ -1112,8 +1168,8 @@ class ClaudeAccountService {
|
||||
platform: 'claude-oauth',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
|
||||
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
|
||||
timestamp: new Date().toISOString()
|
||||
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`,
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||
@@ -1138,9 +1194,17 @@ class ClaudeAccountService {
|
||||
delete accountData.rateLimitedAt
|
||||
delete accountData.rateLimitStatus
|
||||
delete accountData.rateLimitEndAt // 清除限流结束时间
|
||||
|
||||
// 只恢复因限流而自动停止的账户
|
||||
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
accountData.schedulable = 'true'
|
||||
delete accountData.rateLimitAutoStopped
|
||||
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
|
||||
}
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
|
||||
@@ -1284,6 +1348,38 @@ class ClaudeAccountService {
|
||||
accountData.sessionWindowEnd = windowEnd.toISOString()
|
||||
accountData.lastRequestTime = now.toISOString()
|
||||
|
||||
// 清除会话窗口状态,因为进入了新窗口
|
||||
if (accountData.sessionWindowStatus) {
|
||||
delete accountData.sessionWindowStatus
|
||||
delete accountData.sessionWindowStatusUpdatedAt
|
||||
}
|
||||
|
||||
// 如果账户因为5小时限制被自动停止,现在恢复调度
|
||||
if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
logger.info(
|
||||
`✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started`
|
||||
)
|
||||
accountData.schedulable = 'true'
|
||||
delete accountData.fiveHourAutoStopped
|
||||
delete accountData.fiveHourStoppedAt
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
platform: 'claude',
|
||||
status: 'resumed',
|
||||
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
|
||||
reason: '进入新的5小时窗口,已自动恢复调度',
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
|
||||
)
|
||||
@@ -1329,7 +1425,8 @@ class ClaudeAccountService {
|
||||
windowEnd: null,
|
||||
progress: 0,
|
||||
remainingTime: null,
|
||||
lastRequestTime: accountData.lastRequestTime || null
|
||||
lastRequestTime: accountData.lastRequestTime || null,
|
||||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1346,7 +1443,8 @@ class ClaudeAccountService {
|
||||
windowEnd: accountData.sessionWindowEnd,
|
||||
progress: 100,
|
||||
remainingTime: 0,
|
||||
lastRequestTime: accountData.lastRequestTime || null
|
||||
lastRequestTime: accountData.lastRequestTime || null,
|
||||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,7 +1462,8 @@ class ClaudeAccountService {
|
||||
windowEnd: accountData.sessionWindowEnd,
|
||||
progress,
|
||||
remainingTime,
|
||||
lastRequestTime: accountData.lastRequestTime || null
|
||||
lastRequestTime: accountData.lastRequestTime || null,
|
||||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get session window info for account ${accountId}:`, error)
|
||||
@@ -1643,9 +1742,31 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态(401错误)
|
||||
async markAccountUnauthorized(accountId, sessionHash = null) {
|
||||
// 🚫 通用的账户错误标记方法
|
||||
async markAccountError(accountId, errorType, sessionHash = null) {
|
||||
const ERROR_CONFIG = {
|
||||
unauthorized: {
|
||||
status: 'unauthorized',
|
||||
errorMessage: 'Account unauthorized (401 errors detected)',
|
||||
timestampField: 'unauthorizedAt',
|
||||
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||||
logMessage: 'unauthorized'
|
||||
},
|
||||
blocked: {
|
||||
status: 'blocked',
|
||||
errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)',
|
||||
timestampField: 'blockedAt',
|
||||
errorCode: 'CLAUDE_OAUTH_BLOCKED',
|
||||
logMessage: 'blocked'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const errorConfig = ERROR_CONFIG[errorType]
|
||||
if (!errorConfig) {
|
||||
throw new Error(`Unsupported error type: ${errorType}`)
|
||||
}
|
||||
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found')
|
||||
@@ -1653,10 +1774,10 @@ class ClaudeAccountService {
|
||||
|
||||
// 更新账户状态
|
||||
const updatedAccountData = { ...accountData }
|
||||
updatedAccountData.status = 'unauthorized'
|
||||
updatedAccountData.status = errorConfig.status
|
||||
updatedAccountData.schedulable = 'false' // 设置为不可调度
|
||||
updatedAccountData.errorMessage = 'Account unauthorized (401 errors detected)'
|
||||
updatedAccountData.unauthorizedAt = new Date().toISOString()
|
||||
updatedAccountData.errorMessage = errorConfig.errorMessage
|
||||
updatedAccountData[errorConfig.timestampField] = new Date().toISOString()
|
||||
|
||||
// 保存更新后的账户数据
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
@@ -1668,7 +1789,7 @@ class ClaudeAccountService {
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling`
|
||||
`⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling`
|
||||
)
|
||||
|
||||
// 发送Webhook通知
|
||||
@@ -1678,9 +1799,10 @@ class ClaudeAccountService {
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
platform: 'claude-oauth',
|
||||
status: 'unauthorized',
|
||||
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||||
reason: 'Account unauthorized (401 errors detected)'
|
||||
status: errorConfig.status,
|
||||
errorCode: errorConfig.errorCode,
|
||||
reason: errorConfig.errorMessage,
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
@@ -1688,11 +1810,21 @@ class ClaudeAccountService {
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error)
|
||||
logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态(401错误)
|
||||
async markAccountUnauthorized(accountId, sessionHash = null) {
|
||||
return this.markAccountError(accountId, 'unauthorized', sessionHash)
|
||||
}
|
||||
|
||||
// 🚫 标记账户为被封锁状态(403错误)
|
||||
async markAccountBlocked(accountId, sessionHash = null) {
|
||||
return this.markAccountError(accountId, 'blocked', sessionHash)
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
@@ -1711,19 +1843,53 @@ class ClaudeAccountService {
|
||||
updatedAccountData.status = 'created'
|
||||
}
|
||||
|
||||
// 恢复可调度状态
|
||||
// 恢复可调度状态(管理员手动重置时恢复调度是合理的)
|
||||
updatedAccountData.schedulable = 'true'
|
||||
// 清除所有自动停止相关的标记
|
||||
delete updatedAccountData.rateLimitAutoStopped
|
||||
delete updatedAccountData.fiveHourAutoStopped
|
||||
delete updatedAccountData.fiveHourStoppedAt
|
||||
delete updatedAccountData.tempErrorAutoStopped
|
||||
// 兼容旧的标记
|
||||
delete updatedAccountData.autoStoppedAt
|
||||
delete updatedAccountData.stoppedReason
|
||||
|
||||
// 清除错误相关字段
|
||||
delete updatedAccountData.errorMessage
|
||||
delete updatedAccountData.unauthorizedAt
|
||||
delete updatedAccountData.blockedAt
|
||||
delete updatedAccountData.rateLimitedAt
|
||||
delete updatedAccountData.rateLimitStatus
|
||||
delete updatedAccountData.rateLimitEndAt
|
||||
delete updatedAccountData.tempErrorAt
|
||||
delete updatedAccountData.sessionWindowStart
|
||||
delete updatedAccountData.sessionWindowEnd
|
||||
|
||||
// 保存更新后的账户数据
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
|
||||
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
|
||||
const fieldsToDelete = [
|
||||
'errorMessage',
|
||||
'unauthorizedAt',
|
||||
'blockedAt',
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'rateLimitEndAt',
|
||||
'tempErrorAt',
|
||||
'sessionWindowStart',
|
||||
'sessionWindowEnd',
|
||||
// 新的独立标记
|
||||
'rateLimitAutoStopped',
|
||||
'fiveHourAutoStopped',
|
||||
'fiveHourStoppedAt',
|
||||
'tempErrorAutoStopped',
|
||||
// 兼容旧的标记
|
||||
'autoStoppedAt',
|
||||
'stoppedReason'
|
||||
]
|
||||
await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete)
|
||||
|
||||
// 清除401错误计数
|
||||
const errorKey = `claude_account:${accountId}:401_errors`
|
||||
await redis.client.del(errorKey)
|
||||
@@ -1732,6 +1898,10 @@ class ClaudeAccountService {
|
||||
const rateLimitKey = `ratelimit:${accountId}`
|
||||
await redis.client.del(rateLimitKey)
|
||||
|
||||
// 清除5xx错误计数
|
||||
const serverErrorKey = `claude_account:${accountId}:5xx_errors`
|
||||
await redis.client.del(serverErrorKey)
|
||||
|
||||
logger.info(
|
||||
`✅ Successfully reset all error states for account ${accountData.name} (${accountId})`
|
||||
)
|
||||
@@ -1756,7 +1926,7 @@ class ClaudeAccountService {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
let cleanedCount = 0
|
||||
const TEMP_ERROR_RECOVERY_MINUTES = 60 // 临时错误状态恢复时间(分钟)
|
||||
const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟)
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.status === 'temp_error' && account.tempErrorAt) {
|
||||
@@ -1767,10 +1937,23 @@ class ClaudeAccountService {
|
||||
// 如果临时错误状态超过指定时间,尝试重新激活
|
||||
if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) {
|
||||
account.status = 'active' // 恢复为 active 状态
|
||||
account.schedulable = 'true' // 恢复为可调度
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
if (account.tempErrorAutoStopped === 'true') {
|
||||
account.schedulable = 'true' // 恢复为可调度
|
||||
delete account.tempErrorAutoStopped
|
||||
}
|
||||
delete account.errorMessage
|
||||
delete account.tempErrorAt
|
||||
await redis.setClaudeAccount(account.id, account)
|
||||
|
||||
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
|
||||
await redis.client.hdel(
|
||||
`claude:account:${account.id}`,
|
||||
'errorMessage',
|
||||
'tempErrorAt',
|
||||
'tempErrorAutoStopped'
|
||||
)
|
||||
|
||||
// 同时清除500错误计数
|
||||
await this.clearInternalErrors(account.id)
|
||||
cleanedCount++
|
||||
@@ -1854,10 +2037,63 @@ class ClaudeAccountService {
|
||||
updatedAccountData.schedulable = 'false' // 设置为不可调度
|
||||
updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors'
|
||||
updatedAccountData.tempErrorAt = new Date().toISOString()
|
||||
// 使用独立的临时错误自动停止标记
|
||||
updatedAccountData.tempErrorAutoStopped = 'true'
|
||||
|
||||
// 保存更新后的账户数据
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
|
||||
// 设置 5 分钟后自动恢复(一次性定时器)
|
||||
setTimeout(
|
||||
async () => {
|
||||
try {
|
||||
const account = await redis.getClaudeAccount(accountId)
|
||||
if (account && account.status === 'temp_error' && account.tempErrorAt) {
|
||||
// 验证是否确实过了 5 分钟(防止重复定时器)
|
||||
const tempErrorAt = new Date(account.tempErrorAt)
|
||||
const now = new Date()
|
||||
const minutesSince = (now - tempErrorAt) / (1000 * 60)
|
||||
|
||||
if (minutesSince >= 5) {
|
||||
// 恢复账户
|
||||
account.status = 'active'
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
if (account.tempErrorAutoStopped === 'true') {
|
||||
account.schedulable = 'true'
|
||||
delete account.tempErrorAutoStopped
|
||||
}
|
||||
delete account.errorMessage
|
||||
delete account.tempErrorAt
|
||||
|
||||
await redis.setClaudeAccount(accountId, account)
|
||||
|
||||
// 显式删除 Redis 字段
|
||||
await redis.client.hdel(
|
||||
`claude:account:${accountId}`,
|
||||
'errorMessage',
|
||||
'tempErrorAt',
|
||||
'tempErrorAutoStopped'
|
||||
)
|
||||
|
||||
// 清除 500 错误计数
|
||||
await this.clearInternalErrors(accountId)
|
||||
|
||||
logger.success(
|
||||
`✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error)
|
||||
}
|
||||
},
|
||||
6 * 60 * 1000
|
||||
) // 6 分钟后执行,确保已过 5 分钟
|
||||
|
||||
// 如果有sessionHash,删除粘性会话映射
|
||||
if (sessionHash) {
|
||||
await redis.client.del(`sticky_session:${sessionHash}`)
|
||||
@@ -1889,6 +2125,345 @@ class ClaudeAccountService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话窗口状态(allowed, allowed_warning, rejected)
|
||||
async updateSessionWindowStatus(accountId, status) {
|
||||
try {
|
||||
// 参数验证
|
||||
if (!accountId || !status) {
|
||||
logger.warn(
|
||||
`Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
logger.warn(`Account not found: ${accountId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证状态值是否有效
|
||||
const validStatuses = ['allowed', 'allowed_warning', 'rejected']
|
||||
if (!validStatuses.includes(status)) {
|
||||
logger.warn(`Invalid session window status: ${status} for account ${accountId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新会话窗口状态
|
||||
accountData.sessionWindowStatus = status
|
||||
accountData.sessionWindowStatusUpdatedAt = new Date().toISOString()
|
||||
|
||||
// 如果状态是 allowed_warning 且账户设置了自动停止调度
|
||||
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
|
||||
logger.warn(
|
||||
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
|
||||
)
|
||||
accountData.schedulable = 'false'
|
||||
// 使用独立的5小时限制自动停止标记
|
||||
accountData.fiveHourAutoStopped = 'true'
|
||||
accountData.fiveHourStoppedAt = new Date().toISOString()
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
platform: 'claude',
|
||||
status: 'warning',
|
||||
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
||||
reason: '5小时使用量接近限制,已自动停止调度',
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
logger.info(
|
||||
`📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update session window status for account ${accountId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为过载状态(529错误)
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 获取配置的过载处理时间(分钟)
|
||||
const overloadMinutes = config.overloadHandling?.enabled || 0
|
||||
|
||||
if (overloadMinutes === 0) {
|
||||
logger.info('⏭️ 529 error handling is disabled')
|
||||
return { success: false, error: '529 error handling is disabled' }
|
||||
}
|
||||
|
||||
const overloadKey = `account:overload:${accountId}`
|
||||
const ttl = overloadMinutes * 60 // 转换为秒
|
||||
|
||||
await redis.setex(
|
||||
overloadKey,
|
||||
ttl,
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
markedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString()
|
||||
})
|
||||
)
|
||||
|
||||
logger.warn(
|
||||
`🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes`
|
||||
)
|
||||
|
||||
// 在账号上记录最后一次529错误
|
||||
const updates = {
|
||||
lastOverloadAt: new Date().toISOString(),
|
||||
errorMessage: `529错误 - 过载${overloadMinutes}分钟`
|
||||
}
|
||||
|
||||
const updatedAccountData = { ...accountData, ...updates }
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
|
||||
return { success: true, accountName: accountData.name, duration: overloadMinutes }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error)
|
||||
// 不抛出错误,避免影响主请求流程
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 检查账号是否过载
|
||||
async isAccountOverloaded(accountId) {
|
||||
try {
|
||||
// 如果529处理未启用,直接返回false
|
||||
const overloadMinutes = config.overloadHandling?.enabled || 0
|
||||
if (overloadMinutes === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const overloadKey = `account:overload:${accountId}`
|
||||
const overloadData = await redis.get(overloadKey)
|
||||
|
||||
if (overloadData) {
|
||||
// 账号处于过载状态
|
||||
return true
|
||||
}
|
||||
|
||||
// 账号未过载
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 移除账号的过载状态
|
||||
async removeAccountOverload(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const overloadKey = `account:overload:${accountId}`
|
||||
await redis.del(overloadKey)
|
||||
|
||||
logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`)
|
||||
|
||||
// 清理账号上的错误信息
|
||||
if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) {
|
||||
const updatedAccountData = { ...accountData }
|
||||
delete updatedAccountData.errorMessage
|
||||
delete updatedAccountData.lastOverloadAt
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error)
|
||||
// 不抛出错误,移除过载状态失败不应该影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并恢复因5小时限制被自动停止的账号
|
||||
* 用于定时任务自动恢复
|
||||
* @returns {Promise<{checked: number, recovered: number, accounts: Array}>}
|
||||
*/
|
||||
async checkAndRecoverFiveHourStoppedAccounts() {
|
||||
const result = {
|
||||
checked: 0,
|
||||
recovered: 0,
|
||||
accounts: []
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const now = new Date()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查因5小时限制被自动停止的账号
|
||||
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
||||
if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') {
|
||||
result.checked++
|
||||
|
||||
// 使用分布式锁防止并发修改
|
||||
const lockKey = `lock:account:${account.id}:recovery`
|
||||
const lockValue = `${Date.now()}_${Math.random()}`
|
||||
const lockTTL = 5000 // 5秒锁超时
|
||||
|
||||
try {
|
||||
// 尝试获取锁
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL)
|
||||
if (!lockAcquired) {
|
||||
logger.debug(
|
||||
`⏭️ Account ${account.name} (${account.id}) is being processed by another instance`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 重新获取账号数据,确保是最新的
|
||||
const latestAccount = await redis.getClaudeAccount(account.id)
|
||||
if (
|
||||
!latestAccount ||
|
||||
latestAccount.fiveHourAutoStopped !== 'true' ||
|
||||
latestAccount.schedulable !== 'false'
|
||||
) {
|
||||
// 账号状态已变化,跳过
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查当前时间是否已经进入新的5小时窗口
|
||||
let shouldRecover = false
|
||||
let newWindowStart = null
|
||||
let newWindowEnd = null
|
||||
|
||||
if (latestAccount.sessionWindowEnd) {
|
||||
const windowEnd = new Date(latestAccount.sessionWindowEnd)
|
||||
|
||||
// 使用严格的时间比较,添加1分钟缓冲避免边界问题
|
||||
if (now.getTime() > windowEnd.getTime() + 60000) {
|
||||
shouldRecover = true
|
||||
|
||||
// 计算新的窗口时间(基于窗口结束时间,而不是当前时间)
|
||||
// 这样可以保证窗口时间的连续性
|
||||
newWindowStart = new Date(windowEnd)
|
||||
newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1)
|
||||
newWindowEnd = new Date(newWindowStart)
|
||||
newWindowEnd.setHours(newWindowEnd.getHours() + 5)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` +
|
||||
`Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` +
|
||||
`New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时
|
||||
if (latestAccount.fiveHourStoppedAt) {
|
||||
const stoppedAt = new Date(latestAccount.fiveHourStoppedAt)
|
||||
const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
// 使用严格的5小时判断,加上1分钟缓冲
|
||||
if (hoursSinceStopped > 5.017) {
|
||||
// 5小时1分钟
|
||||
shouldRecover = true
|
||||
newWindowStart = this._calculateSessionWindowStart(now)
|
||||
newWindowEnd = this._calculateSessionWindowEnd(newWindowStart)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecover) {
|
||||
// 恢复账号调度
|
||||
const updatedAccountData = { ...latestAccount }
|
||||
|
||||
// 恢复调度状态
|
||||
updatedAccountData.schedulable = 'true'
|
||||
delete updatedAccountData.fiveHourAutoStopped
|
||||
delete updatedAccountData.fiveHourStoppedAt
|
||||
|
||||
// 更新会话窗口(如果有新窗口)
|
||||
if (newWindowStart && newWindowEnd) {
|
||||
updatedAccountData.sessionWindowStart = newWindowStart.toISOString()
|
||||
updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString()
|
||||
|
||||
// 清除会话窗口状态
|
||||
delete updatedAccountData.sessionWindowStatus
|
||||
delete updatedAccountData.sessionWindowStatusUpdatedAt
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
await redis.setClaudeAccount(account.id, updatedAccountData)
|
||||
|
||||
result.recovered++
|
||||
result.accounts.push({
|
||||
id: latestAccount.id,
|
||||
name: latestAccount.name,
|
||||
oldWindow: latestAccount.sessionWindowEnd
|
||||
? {
|
||||
start: latestAccount.sessionWindowStart,
|
||||
end: latestAccount.sessionWindowEnd
|
||||
}
|
||||
: null,
|
||||
newWindow:
|
||||
newWindowStart && newWindowEnd
|
||||
? {
|
||||
start: newWindowStart.toISOString(),
|
||||
end: newWindowEnd.toISOString()
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired`
|
||||
)
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (error) {
|
||||
// 确保释放锁
|
||||
if (lockKey && lockValue) {
|
||||
try {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (unlockError) {
|
||||
logger.error(`Failed to release lock for account ${account.id}:`, unlockError)
|
||||
}
|
||||
}
|
||||
logger.error(
|
||||
`❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.recovered > 0) {
|
||||
logger.info(
|
||||
`🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService()
|
||||
|
||||
@@ -50,7 +50,7 @@ class ClaudeCodeHeadersService {
|
||||
if (!userAgent) {
|
||||
return null
|
||||
}
|
||||
const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/)
|
||||
const match = userAgent.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ class ClaudeCodeHeadersService {
|
||||
|
||||
// 检查是否有 user-agent
|
||||
const userAgent = extractedHeaders['user-agent']
|
||||
if (!userAgent || !userAgent.includes('claude-cli')) {
|
||||
if (!userAgent || !/^claude-cli\/[\d.]+\s+\(/i.test(userAgent)) {
|
||||
// 不是 Claude Code 的请求,不存储
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ class ClaudeConsoleAccountService {
|
||||
proxy = null,
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true // 是否可被调度
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -85,7 +87,14 @@ class ClaudeConsoleAccountService {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
// 调度控制
|
||||
schedulable: schedulable.toString()
|
||||
schedulable: schedulable.toString(),
|
||||
// 额度管理相关
|
||||
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
|
||||
dailyUsage: '0', // 当日使用金额(美元)
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -116,7 +125,12 @@ class ClaudeConsoleAccountService {
|
||||
proxy,
|
||||
accountType,
|
||||
status: 'active',
|
||||
createdAt: accountData.createdAt
|
||||
createdAt: accountData.createdAt,
|
||||
dailyQuota,
|
||||
dailyUsage: 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,12 +162,18 @@ class ClaudeConsoleAccountService {
|
||||
isActive: accountData.isActive === 'true',
|
||||
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||
accountType: accountData.accountType || 'shared',
|
||||
status: accountData.status,
|
||||
errorMessage: accountData.errorMessage,
|
||||
createdAt: accountData.createdAt,
|
||||
lastUsedAt: accountData.lastUsedAt,
|
||||
rateLimitStatus: rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度
|
||||
status: accountData.status || 'active',
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -265,6 +285,37 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
if (updates.schedulable !== undefined) {
|
||||
updatedData.schedulable = updates.schedulable.toString()
|
||||
// 如果是手动修改调度状态,清除所有自动停止相关的字段
|
||||
// 防止自动恢复
|
||||
updatedData.rateLimitAutoStopped = ''
|
||||
updatedData.quotaAutoStopped = ''
|
||||
// 兼容旧的标记
|
||||
updatedData.autoStoppedAt = ''
|
||||
updatedData.stoppedReason = ''
|
||||
|
||||
// 记录日志
|
||||
if (updates.schedulable === true || updates.schedulable === 'true') {
|
||||
logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`)
|
||||
} else {
|
||||
logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 额度管理相关字段
|
||||
if (updates.dailyQuota !== undefined) {
|
||||
updatedData.dailyQuota = updates.dailyQuota.toString()
|
||||
}
|
||||
if (updates.quotaResetTime !== undefined) {
|
||||
updatedData.quotaResetTime = updates.quotaResetTime
|
||||
}
|
||||
if (updates.dailyUsage !== undefined) {
|
||||
updatedData.dailyUsage = updates.dailyUsage.toString()
|
||||
}
|
||||
if (updates.lastResetDate !== undefined) {
|
||||
updatedData.lastResetDate = updates.lastResetDate
|
||||
}
|
||||
if (updates.quotaStoppedAt !== undefined) {
|
||||
updatedData.quotaStoppedAt = updates.quotaStoppedAt
|
||||
}
|
||||
|
||||
// 处理账户类型变更
|
||||
@@ -361,7 +412,19 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
const updates = {
|
||||
rateLimitedAt: new Date().toISOString(),
|
||||
rateLimitStatus: 'limited'
|
||||
rateLimitStatus: 'limited',
|
||||
isActive: 'false', // 禁用账户
|
||||
schedulable: 'false', // 停止调度,与其他平台保持一致
|
||||
errorMessage: `Rate limited at ${new Date().toISOString()}`,
|
||||
// 使用独立的限流自动停止标记
|
||||
rateLimitAutoStopped: 'true'
|
||||
}
|
||||
|
||||
// 只有当前状态不是quota_exceeded时才设置为rate_limited
|
||||
// 避免覆盖更重要的配额超限状态
|
||||
const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status')
|
||||
if (currentStatus !== 'quota_exceeded') {
|
||||
updates.status = 'rate_limited'
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
@@ -369,14 +432,15 @@ class ClaudeConsoleAccountService {
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
|
||||
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
|
||||
timestamp: new Date().toISOString()
|
||||
reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`,
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||
@@ -396,14 +460,53 @@ class ClaudeConsoleAccountService {
|
||||
async removeAccountRateLimit(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
await client.hdel(
|
||||
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus'
|
||||
// 获取账户当前状态和额度信息
|
||||
const [currentStatus, quotaStoppedAt] = await client.hmget(
|
||||
accountKey,
|
||||
'status',
|
||||
'quotaStoppedAt'
|
||||
)
|
||||
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
// 删除限流相关字段
|
||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
|
||||
|
||||
// 根据不同情况决定是否恢复账户
|
||||
if (currentStatus === 'rate_limited') {
|
||||
if (quotaStoppedAt) {
|
||||
// 还有额度限制,改为quota_exceeded状态
|
||||
await client.hset(accountKey, {
|
||||
status: 'quota_exceeded'
|
||||
// isActive保持false
|
||||
})
|
||||
logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`)
|
||||
} else {
|
||||
// 没有额度限制,完全恢复
|
||||
const accountData = await client.hgetall(accountKey)
|
||||
const updateData = {
|
||||
isActive: 'true',
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
}
|
||||
|
||||
// 只恢复因限流而自动停止的账户
|
||||
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
updateData.schedulable = 'true' // 恢复调度
|
||||
// 删除限流自动停止标记
|
||||
await client.hdel(accountKey, 'rateLimitAutoStopped')
|
||||
logger.info(
|
||||
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared`
|
||||
)
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error)
|
||||
@@ -453,6 +556,202 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账号是否因额度超限而被停用(懒惰检查)
|
||||
async isAccountQuotaExceeded(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果没有设置额度限制,不会超额
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
if (isNaN(dailyQuota) || dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果账户没有被额度停用,检查当前使用情况
|
||||
if (!account.quotaStoppedAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否应该重置额度(到了新的重置时间点)
|
||||
if (this._shouldResetQuota(account)) {
|
||||
await this.resetDailyUsage(accountId)
|
||||
return false
|
||||
}
|
||||
|
||||
// 仍在额度超限状态
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 判断是否应该重置账户额度
|
||||
_shouldResetQuota(account) {
|
||||
// 与 Redis 统计一致:按配置时区判断“今天”与时间点
|
||||
const tzNow = redis.getDateInTimezone(new Date())
|
||||
const today = redis.getDateStringInTimezone(tzNow)
|
||||
|
||||
// 如果已经是今天重置过的,不需要重置
|
||||
if (account.lastResetDate === today) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否到了重置时间点(按配置时区的小时/分钟)
|
||||
const resetTime = account.quotaResetTime || '00:00'
|
||||
const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n))
|
||||
|
||||
const currentHour = tzNow.getUTCHours()
|
||||
const currentMinute = tzNow.getUTCMinutes()
|
||||
|
||||
// 如果当前时间已过重置时间且不是同一天重置的,应该重置
|
||||
return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute)
|
||||
}
|
||||
|
||||
// 🚫 标记账号为未授权状态(401错误)
|
||||
async markAccountUnauthorized(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
schedulable: 'false',
|
||||
status: 'unauthorized',
|
||||
errorMessage: 'API Key无效或已过期(401错误)',
|
||||
unauthorizedAt: new Date().toISOString(),
|
||||
unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1)
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED',
|
||||
reason: 'API Key无效或已过期(401错误),账户已停止调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为过载状态(529错误)
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
overloadedAt: new Date().toISOString(),
|
||||
overloadStatus: 'overloaded',
|
||||
errorMessage: '服务过载(529错误)'
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_CONSOLE_OVERLOADED',
|
||||
reason: '服务过载(529错误)。账户将暂时停止调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send overload webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账号的过载状态
|
||||
async removeAccountOverload(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
||||
|
||||
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove overload status for Claude Console account: ${accountId}`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账号是否处于过载状态
|
||||
async isAccountOverloaded(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.overloadStatus === 'overloaded' && account.overloadedAt) {
|
||||
const overloadedAt = new Date(account.overloadedAt)
|
||||
const now = new Date()
|
||||
const minutesSinceOverload = (now - overloadedAt) / (1000 * 60)
|
||||
|
||||
// 过载状态持续10分钟后自动恢复
|
||||
if (minutesSinceOverload >= 10) {
|
||||
await this.removeAccountOverload(accountId)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to check overload status for Claude Console account: ${accountId}`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为封锁状态(模型不支持等原因)
|
||||
async blockAccount(accountId, reason) {
|
||||
try {
|
||||
@@ -681,6 +980,256 @@ class ClaudeConsoleAccountService {
|
||||
// 返回映射后的模型,如果不存在则返回原模型
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
}
|
||||
|
||||
// 💰 检查账户使用额度(基于实时统计数据)
|
||||
async checkQuotaUsage(accountId) {
|
||||
try {
|
||||
// 获取实时的使用统计(包含费用)
|
||||
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||
const currentDailyCost = usageStats.daily.cost || 0
|
||||
|
||||
// 获取账户配置
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
logger.warn(`Account not found: ${accountId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析额度配置,确保数值有效
|
||||
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||
if (isNaN(dailyQuota) || dailyQuota <= 0) {
|
||||
// 没有设置有效额度,无需检查
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已经因额度停用(避免重复操作)
|
||||
if (!accountData.isActive && accountData.quotaStoppedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否超过额度限制
|
||||
if (currentDailyCost >= dailyQuota) {
|
||||
// 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// double-check locking pattern - 检查quotaStoppedAt而不是status
|
||||
const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt')
|
||||
if (existingQuotaStop) {
|
||||
return // 已经被其他进程处理
|
||||
}
|
||||
|
||||
// 超过额度,停用账户
|
||||
const updates = {
|
||||
isActive: false,
|
||||
quotaStoppedAt: new Date().toISOString(),
|
||||
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
schedulable: false, // 停止调度
|
||||
// 使用独立的额度超限自动停止标记
|
||||
quotaAutoStopped: 'true'
|
||||
}
|
||||
|
||||
// 只有当前状态是active时才改为quota_exceeded
|
||||
// 如果是rate_limited等其他状态,保持原状态不变
|
||||
const currentStatus = await client.hget(accountKey, 'status')
|
||||
if (currentStatus === 'active') {
|
||||
updates.status = 'quota_exceeded'
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
|
||||
logger.warn(
|
||||
`💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
)
|
||||
|
||||
// 发送webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Unknown Account',
|
||||
platform: 'claude-console',
|
||||
status: 'quota_exceeded',
|
||||
errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED',
|
||||
reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification for quota exceeded:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to check quota usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户每日使用量(恢复因额度停用的账户)
|
||||
async resetDailyUsage(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
return
|
||||
}
|
||||
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const updates = {
|
||||
lastResetDate: today
|
||||
}
|
||||
|
||||
// 如果账户是因为超额被停用的,恢复账户
|
||||
// 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了)
|
||||
if (
|
||||
accountData.quotaStoppedAt &&
|
||||
accountData.isActive === false &&
|
||||
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
|
||||
) {
|
||||
updates.isActive = true
|
||||
updates.status = 'active'
|
||||
updates.errorMessage = ''
|
||||
updates.quotaStoppedAt = ''
|
||||
|
||||
// 只恢复因额度超限而自动停止的账户
|
||||
if (accountData.quotaAutoStopped === 'true') {
|
||||
updates.schedulable = true
|
||||
updates.quotaAutoStopped = ''
|
||||
}
|
||||
|
||||
// 如果是rate_limited状态,也清除限流相关字段
|
||||
if (accountData.status === 'rate_limited') {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
|
||||
)
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
|
||||
logger.debug(`🔄 Reset daily usage for account ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset daily usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置所有账户的每日使用量
|
||||
async resetAllDailyUsage() {
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
// 与统计一致使用配置时区日期
|
||||
const today = redis.getDateStringInTimezone()
|
||||
let resetCount = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只重置需要重置的账户
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(account.id)
|
||||
resetCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset all daily usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计(基于实时数据)
|
||||
async getAccountUsageStats(accountId) {
|
||||
try {
|
||||
// 获取实时的使用统计(包含费用)
|
||||
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||
const currentDailyCost = usageStats.daily.cost || 0
|
||||
|
||||
// 获取账户配置
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||
|
||||
return {
|
||||
dailyQuota,
|
||||
dailyUsage: currentDailyCost, // 使用实时计算的费用
|
||||
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
|
||||
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaStoppedAt: accountData.quotaStoppedAt,
|
||||
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
|
||||
// 额外返回完整的使用统计
|
||||
fullUsageStats: usageStats
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get account usage stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 准备要更新的字段
|
||||
const updates = {
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true',
|
||||
isActive: 'true' // 重要:必须恢复isActive状态
|
||||
}
|
||||
|
||||
// 删除所有异常状态相关的字段
|
||||
const fieldsToDelete = [
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'unauthorizedAt',
|
||||
'unauthorizedCount',
|
||||
'overloadedAt',
|
||||
'overloadStatus',
|
||||
'blockedAt',
|
||||
'quotaStoppedAt'
|
||||
]
|
||||
|
||||
// 执行更新
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
platform: 'claude-console',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, accountId }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeConsoleAccountService()
|
||||
|
||||
@@ -19,10 +19,11 @@ class ClaudeConsoleRelayService {
|
||||
options = {}
|
||||
) {
|
||||
let abortController = null
|
||||
let account = null
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Claude Console Claude account not found')
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class ClaudeConsoleRelayService {
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.proxy.timeout || 60000,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
signal: abortController.signal,
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
@@ -175,16 +176,31 @@ class ClaudeConsoleRelayService {
|
||||
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
|
||||
)
|
||||
|
||||
// 检查是否为限流错误
|
||||
if (response.status === 429) {
|
||||
// 检查错误状态并相应处理
|
||||
if (response.status === 401) {
|
||||
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
|
||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
|
||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
// 如果请求成功,检查并移除限流状态
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId)
|
||||
if (isOverloaded) {
|
||||
await claudeConsoleAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
@@ -207,7 +223,10 @@ class ClaudeConsoleRelayService {
|
||||
throw new Error('Client disconnected')
|
||||
}
|
||||
|
||||
logger.error('❌ Claude Console Claude relay request failed:', error.message)
|
||||
logger.error(
|
||||
`❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`,
|
||||
error.message
|
||||
)
|
||||
|
||||
// 不再因为模型不支持而block账号
|
||||
|
||||
@@ -226,9 +245,10 @@ class ClaudeConsoleRelayService {
|
||||
streamTransformer = null,
|
||||
options = {}
|
||||
) {
|
||||
let account = null
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Claude Console Claude account not found')
|
||||
}
|
||||
@@ -282,7 +302,10 @@ class ClaudeConsoleRelayService {
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude Console Claude stream relay failed:', error)
|
||||
logger.error(
|
||||
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -331,7 +354,7 @@ class ClaudeConsoleRelayService {
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.proxy.timeout || 60000,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
@@ -361,10 +384,20 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
// 错误响应处理
|
||||
if (response.status !== 200) {
|
||||
logger.error(`❌ Claude Console API returned error status: ${response.status}`)
|
||||
logger.error(
|
||||
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
if (response.status === 429) {
|
||||
if (response.status === 401) {
|
||||
claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
} else if (response.status === 529) {
|
||||
claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
|
||||
// 设置错误响应的状态码和响应头
|
||||
@@ -396,12 +429,17 @@ class ClaudeConsoleRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除限流状态
|
||||
// 成功响应,检查并移除错误状态
|
||||
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
})
|
||||
claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
|
||||
if (isOverloaded) {
|
||||
claudeConsoleAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
})
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
@@ -500,7 +538,10 @@ class ClaudeConsoleRelayService {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error processing Claude Console stream data:', error)
|
||||
logger.error(
|
||||
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
||||
error
|
||||
)
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write('event: error\n')
|
||||
responseStream.write(
|
||||
@@ -542,7 +583,10 @@ class ClaudeConsoleRelayService {
|
||||
})
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
logger.error('❌ Claude Console stream error:', error)
|
||||
logger.error(
|
||||
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
||||
error
|
||||
)
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write('event: error\n')
|
||||
responseStream.write(
|
||||
@@ -562,11 +606,24 @@ class ClaudeConsoleRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('❌ Claude Console Claude stream request error:', error.message)
|
||||
logger.error(
|
||||
`❌ Claude Console stream request error (Account: ${account?.name || accountId}):`,
|
||||
error.message
|
||||
)
|
||||
|
||||
// 检查是否是429错误
|
||||
if (error.response && error.response.status === 429) {
|
||||
claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
// 检查错误状态
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (error.response.status === 429) {
|
||||
claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
} else if (error.response.status === 529) {
|
||||
claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送错误响应
|
||||
|
||||
@@ -9,6 +9,7 @@ const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const claudeCodeHeadersService = require('./claudeCodeHeadersService')
|
||||
const redis = require('../models/redis')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -23,7 +24,7 @@ class ClaudeRelayService {
|
||||
isRealClaudeCodeRequest(requestBody, clientHeaders) {
|
||||
// 检查 user-agent 是否匹配 Claude Code 格式
|
||||
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || ''
|
||||
const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent)
|
||||
const isClaudeCodeUserAgent = /^claude-cli\/[\d.]+\s+\(/i.test(userAgent)
|
||||
|
||||
// 检查系统提示词是否包含 Claude Code 标识
|
||||
const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody)
|
||||
@@ -78,34 +79,6 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
apiKeyData.restrictedModels.length > 0
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
)
|
||||
return {
|
||||
statusCode: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||
|
||||
@@ -125,8 +98,11 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -180,15 +156,15 @@ class ClaudeRelayService {
|
||||
// 记录401错误
|
||||
await this.recordUnauthorizedError(accountId)
|
||||
|
||||
// 检查是否需要标记为异常(连续3次401)
|
||||
// 检查是否需要标记为异常(遇到1次401就停止调度)
|
||||
const errorCount = await this.getUnauthorizedErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
|
||||
)
|
||||
|
||||
if (errorCount >= 3) {
|
||||
if (errorCount >= 1) {
|
||||
logger.error(
|
||||
`❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized`
|
||||
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountUnauthorized(
|
||||
accountId,
|
||||
@@ -197,22 +173,35 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
else if (response.statusCode === 403) {
|
||||
logger.error(
|
||||
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
}
|
||||
// 检查是否为529状态码(服务过载)
|
||||
else if (response.statusCode === 529) {
|
||||
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
|
||||
|
||||
// 检查是否启用了529错误处理
|
||||
if (config.claude.overloadHandling.enabled > 0) {
|
||||
try {
|
||||
await claudeAccountService.markAccountOverloaded(accountId)
|
||||
logger.info(
|
||||
`🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
|
||||
)
|
||||
} catch (overloadError) {
|
||||
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError)
|
||||
}
|
||||
} else {
|
||||
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
|
||||
}
|
||||
}
|
||||
// 检查是否为5xx状态码
|
||||
else if (response.statusCode >= 500 && response.statusCode < 600) {
|
||||
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
|
||||
// 记录5xx错误
|
||||
await claudeAccountService.recordServerError(accountId, response.statusCode)
|
||||
// 检查是否需要标记为临时错误状态(连续3次500)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
|
||||
)
|
||||
if (errorCount >= 3) {
|
||||
logger.error(
|
||||
`❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
await this._handleServerError(accountId, response.statusCode, sessionHash)
|
||||
}
|
||||
// 检查是否为429状态码
|
||||
else if (response.statusCode === 429) {
|
||||
@@ -264,6 +253,27 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
||||
// 提取5小时会话窗口状态
|
||||
// 使用大小写不敏感的方式获取响应头
|
||||
const get5hStatus = (headers) => {
|
||||
if (!headers) {
|
||||
return null
|
||||
}
|
||||
// HTTP头部名称不区分大小写,需要处理不同情况
|
||||
return (
|
||||
headers['anthropic-ratelimit-unified-5h-status'] ||
|
||||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||
)
|
||||
}
|
||||
|
||||
const sessionWindowStatus = get5hStatus(response.headers)
|
||||
if (sessionWindowStatus) {
|
||||
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
|
||||
// 保存会话窗口状态到账户数据
|
||||
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
|
||||
}
|
||||
|
||||
// 请求成功,清除401和500错误计数
|
||||
await this.clearUnauthorizedErrors(accountId)
|
||||
await claudeAccountService.clearInternalErrors(accountId)
|
||||
@@ -276,6 +286,19 @@ class ClaudeRelayService {
|
||||
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
||||
}
|
||||
|
||||
// 如果请求成功,检查并移除过载状态
|
||||
try {
|
||||
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||
if (isOverloaded) {
|
||||
await claudeAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
} catch (overloadError) {
|
||||
logger.error(
|
||||
`❌ Failed to check/remove overload status for account ${accountId}:`,
|
||||
overloadError
|
||||
)
|
||||
}
|
||||
|
||||
// 只有真实的 Claude Code 请求才更新 headers
|
||||
if (
|
||||
clientHeaders &&
|
||||
@@ -327,7 +350,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🔄 处理请求体
|
||||
_processRequestBody(body, clientHeaders = {}) {
|
||||
_processRequestBody(body, clientHeaders = {}, account = null) {
|
||||
if (!body) {
|
||||
return body
|
||||
}
|
||||
@@ -429,9 +452,31 @@ class ClaudeRelayService {
|
||||
delete processedBody.top_p
|
||||
}
|
||||
|
||||
// 处理统一的客户端标识
|
||||
if (account && account.useUnifiedClientId && account.unifiedClientId) {
|
||||
this._replaceClientId(processedBody, account.unifiedClientId)
|
||||
}
|
||||
|
||||
return processedBody
|
||||
}
|
||||
|
||||
// 🔄 替换请求中的客户端标识
|
||||
_replaceClientId(body, unifiedClientId) {
|
||||
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
|
||||
return
|
||||
}
|
||||
|
||||
const userId = body.metadata.user_id
|
||||
// user_id格式:user_{64位十六进制}_account__session_{uuid}
|
||||
// 只替换第一个下划线后到_account之前的部分(客户端标识)
|
||||
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
|
||||
if (match && match[1]) {
|
||||
// 替换客户端标识部分
|
||||
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
|
||||
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔢 验证并限制max_tokens参数
|
||||
_validateAndLimitMaxTokens(body) {
|
||||
if (!body || !body.max_tokens) {
|
||||
@@ -454,7 +499,10 @@ class ClaudeRelayService {
|
||||
const modelConfig = pricingData[model]
|
||||
|
||||
if (!modelConfig) {
|
||||
logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`)
|
||||
// 如果找不到模型配置,直接透传客户端参数,不进行任何干预
|
||||
logger.info(
|
||||
`📝 Model ${model} not found in pricing file, passing through client parameters without modification`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -553,8 +601,30 @@ class ClaudeRelayService {
|
||||
'transfer-encoding'
|
||||
]
|
||||
|
||||
// 🆕 需要移除的浏览器相关 headers(避免CORS问题)
|
||||
const browserHeaders = [
|
||||
'origin',
|
||||
'referer',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-dest',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
'accept',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
|
||||
]
|
||||
|
||||
// 应该保留的 headers(用于会话一致性和追踪)
|
||||
const allowedHeaders = ['x-request-id']
|
||||
const allowedHeaders = [
|
||||
'x-request-id',
|
||||
'anthropic-version', // 保留API版本
|
||||
'anthropic-beta' // 保留beta功能
|
||||
]
|
||||
|
||||
const filteredHeaders = {}
|
||||
|
||||
@@ -565,8 +635,8 @@ class ClaudeRelayService {
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
// 如果不在敏感列表中,也保留
|
||||
else if (!sensitiveHeaders.includes(lowerKey)) {
|
||||
// 如果不在敏感列表和浏览器列表中,也保留
|
||||
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
})
|
||||
@@ -586,6 +656,12 @@ class ClaudeRelayService {
|
||||
) {
|
||||
const url = new URL(this.claudeApiUrl)
|
||||
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 获取统一的 User-Agent
|
||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
@@ -629,14 +705,19 @@ class ClaudeRelayService {
|
||||
...finalHeaders
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 如果客户端没有提供 User-Agent,使用默认值
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||
options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||
options.headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||
)
|
||||
|
||||
// 使用自定义的 betaHeader 或默认值
|
||||
const betaHeader =
|
||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
||||
@@ -685,7 +766,7 @@ class ClaudeRelayService {
|
||||
|
||||
resolve(response)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to parse Claude API response:', error)
|
||||
logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
@@ -696,9 +777,9 @@ class ClaudeRelayService {
|
||||
onRequest(req)
|
||||
}
|
||||
|
||||
req.on('error', (error) => {
|
||||
req.on('error', async (error) => {
|
||||
console.error(': ❌ ', error)
|
||||
logger.error('❌ Claude API request error:', error.message, {
|
||||
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall,
|
||||
@@ -716,14 +797,19 @@ class ClaudeRelayService {
|
||||
errorMessage = 'Connection refused by Claude API server'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Connection timed out to Claude API server'
|
||||
|
||||
await this._handleServerError(accountId, 504, null, 'Network')
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage))
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude API request timeout')
|
||||
logger.error(`❌ Claude API request timeout (Account: ${accountId})`)
|
||||
|
||||
await this._handleServerError(accountId, 504, null, 'Request')
|
||||
|
||||
reject(new Error('Request timeout'))
|
||||
})
|
||||
|
||||
@@ -752,36 +838,6 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
apiKeyData.restrictedModels.length > 0
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
)
|
||||
|
||||
// 对于流式响应,需要写入错误并结束流
|
||||
const errorResponse = JSON.stringify({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
|
||||
responseStream.writeHead(403, { 'Content-Type': 'application/json' })
|
||||
responseStream.end(errorResponse)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||
|
||||
@@ -801,8 +857,11 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -825,7 +884,7 @@ class ClaudeRelayService {
|
||||
options
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error)
|
||||
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -844,6 +903,12 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
) {
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 获取统一的 User-Agent
|
||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
@@ -881,14 +946,18 @@ class ClaudeRelayService {
|
||||
...finalHeaders
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 如果客户端没有提供 User-Agent,使用默认值
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||
options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||
options.headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||
)
|
||||
// 使用自定义的 betaHeader 或默认值
|
||||
const betaHeader =
|
||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
||||
@@ -903,24 +972,57 @@ class ClaudeRelayService {
|
||||
if (res.statusCode !== 200) {
|
||||
// 将错误处理逻辑封装在一个异步函数中
|
||||
const handleErrorResponse = async () => {
|
||||
// 增加对5xx错误的处理
|
||||
if (res.statusCode >= 500 && res.statusCode < 600) {
|
||||
if (res.statusCode === 401) {
|
||||
logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`)
|
||||
|
||||
await this.recordUnauthorizedError(accountId)
|
||||
|
||||
const errorCount = await this.getUnauthorizedErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
|
||||
)
|
||||
|
||||
if (errorCount >= 1) {
|
||||
logger.error(
|
||||
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountUnauthorized(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
logger.error(
|
||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
} else if (res.statusCode === 529) {
|
||||
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
|
||||
|
||||
// 检查是否启用了529错误处理
|
||||
if (config.claude.overloadHandling.enabled > 0) {
|
||||
try {
|
||||
await claudeAccountService.markAccountOverloaded(accountId)
|
||||
logger.info(
|
||||
`🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
|
||||
)
|
||||
} catch (overloadError) {
|
||||
logger.error(
|
||||
`❌ [Stream] Failed to mark account as overloaded: ${accountId}`,
|
||||
overloadError
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`🚫 [Stream] 529 error handling is disabled, skipping account overload marking`
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode >= 500 && res.statusCode < 600) {
|
||||
logger.warn(
|
||||
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
|
||||
)
|
||||
// 记录5xx错误
|
||||
await claudeAccountService.recordServerError(accountId, res.statusCode)
|
||||
// 检查是否需要标记为临时错误状态(连续3次500)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
|
||||
)
|
||||
if (errorCount >= 3) {
|
||||
logger.error(
|
||||
`❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,7 +1031,9 @@ class ClaudeRelayService {
|
||||
logger.error('❌ Error in stream error handler:', err)
|
||||
})
|
||||
|
||||
logger.error(`❌ Claude API returned error status: ${res.statusCode}`)
|
||||
logger.error(
|
||||
`❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
let errorData = ''
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
@@ -938,7 +1042,10 @@ class ClaudeRelayService {
|
||||
|
||||
res.on('end', () => {
|
||||
console.error(': ❌ ', errorData)
|
||||
logger.error('❌ Claude API error response:', errorData)
|
||||
logger.error(
|
||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||
errorData
|
||||
)
|
||||
if (!responseStream.destroyed) {
|
||||
// 发送错误事件
|
||||
responseStream.write('event: error\n')
|
||||
@@ -1189,6 +1296,27 @@ class ClaudeRelayService {
|
||||
usageCallback(finalUsage)
|
||||
}
|
||||
|
||||
// 提取5小时会话窗口状态
|
||||
// 使用大小写不敏感的方式获取响应头
|
||||
const get5hStatus = (headers) => {
|
||||
if (!headers) {
|
||||
return null
|
||||
}
|
||||
// HTTP头部名称不区分大小写,需要处理不同情况
|
||||
return (
|
||||
headers['anthropic-ratelimit-unified-5h-status'] ||
|
||||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||
)
|
||||
}
|
||||
|
||||
const sessionWindowStatus = get5hStatus(res.headers)
|
||||
if (sessionWindowStatus) {
|
||||
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
|
||||
// 保存会话窗口状态到账户数据
|
||||
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
|
||||
}
|
||||
|
||||
// 处理限流状态
|
||||
if (rateLimitDetected || res.statusCode === 429) {
|
||||
// 提取限流重置时间戳
|
||||
@@ -1220,6 +1348,19 @@ class ClaudeRelayService {
|
||||
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
||||
}
|
||||
|
||||
// 如果流式请求成功,检查并移除过载状态
|
||||
try {
|
||||
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||
if (isOverloaded) {
|
||||
await claudeAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
} catch (overloadError) {
|
||||
logger.error(
|
||||
`❌ [Stream] Failed to check/remove overload status for account ${accountId}:`,
|
||||
overloadError
|
||||
)
|
||||
}
|
||||
|
||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||
if (
|
||||
clientHeaders &&
|
||||
@@ -1235,12 +1376,16 @@ class ClaudeRelayService {
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('❌ Claude stream request error:', error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall
|
||||
})
|
||||
req.on('error', async (error) => {
|
||||
logger.error(
|
||||
`❌ Claude stream request error (Account: ${account?.name || accountId}):`,
|
||||
error.message,
|
||||
{
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall
|
||||
}
|
||||
)
|
||||
|
||||
// 根据错误类型提供更具体的错误信息
|
||||
let errorMessage = 'Upstream request failed'
|
||||
@@ -1282,9 +1427,10 @@ class ClaudeRelayService {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude stream request timeout')
|
||||
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`)
|
||||
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -1348,12 +1494,17 @@ class ClaudeRelayService {
|
||||
...filteredHeaders
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 如果客户端没有提供 User-Agent,使用默认值
|
||||
if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) {
|
||||
options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'
|
||||
// 第三个方法不支持统一 User-Agent,使用简化逻辑
|
||||
const userAgent =
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
'claude-cli/1.0.102 (external, cli)'
|
||||
options.headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
// 使用自定义的 betaHeader 或默认值
|
||||
@@ -1379,8 +1530,8 @@ class ClaudeRelayService {
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('❌ Claude stream request error:', error.message, {
|
||||
req.on('error', async (error) => {
|
||||
logger.error(`❌ Claude stream request error:`, error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall
|
||||
@@ -1426,9 +1577,10 @@ class ClaudeRelayService {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude stream request timeout')
|
||||
logger.error(`❌ Claude stream request timeout`)
|
||||
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -1465,6 +1617,33 @@ class ClaudeRelayService {
|
||||
})
|
||||
}
|
||||
|
||||
// 🛠️ 统一的错误处理方法
|
||||
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
|
||||
try {
|
||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
|
||||
// 根据错误类型设置不同的阈值和日志前缀
|
||||
const isTimeout = statusCode === 504
|
||||
const threshold = 3 // 统一使用3次阈值
|
||||
const prefix = context ? `${context} ` : ''
|
||||
|
||||
logger.warn(
|
||||
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
|
||||
)
|
||||
|
||||
if (errorCount > threshold) {
|
||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||
logger.error(
|
||||
`❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
} catch (handlingError) {
|
||||
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重试逻辑
|
||||
async _retryRequest(requestFunc, maxRetries = 3) {
|
||||
let lastError
|
||||
@@ -1490,7 +1669,6 @@ class ClaudeRelayService {
|
||||
async recordUnauthorizedError(accountId) {
|
||||
try {
|
||||
const key = `claude_account:${accountId}:401_errors`
|
||||
const redis = require('../models/redis')
|
||||
|
||||
// 增加错误计数,设置5分钟过期时间
|
||||
await redis.client.incr(key)
|
||||
@@ -1506,7 +1684,6 @@ class ClaudeRelayService {
|
||||
async getUnauthorizedErrorCount(accountId) {
|
||||
try {
|
||||
const key = `claude_account:${accountId}:401_errors`
|
||||
const redis = require('../models/redis')
|
||||
|
||||
const count = await redis.client.get(key)
|
||||
return parseInt(count) || 0
|
||||
@@ -1520,7 +1697,6 @@ class ClaudeRelayService {
|
||||
async clearUnauthorizedErrors(accountId) {
|
||||
try {
|
||||
const key = `claude_account:${accountId}:401_errors`
|
||||
const redis = require('../models/redis')
|
||||
|
||||
await redis.client.del(key)
|
||||
logger.info(`✅ Cleared 401 error count for account ${accountId}`)
|
||||
@@ -1529,6 +1705,103 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 动态捕获并获取统一的 User-Agent
|
||||
async captureAndGetUnifiedUserAgent(clientHeaders, account) {
|
||||
if (account.useUnifiedUserAgent !== 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||||
const TTL = 90000 // 25小时
|
||||
|
||||
// ⚠️ 重要:这里通过正则表达式判断是否为 Claude Code 客户端
|
||||
// 如果未来 Claude Code 的 User-Agent 格式发生变化,需要更新这个正则表达式
|
||||
// 当前已知格式:claude-cli/1.0.102 (external, cli)
|
||||
const CLAUDE_CODE_UA_PATTERN = /^claude-cli\/[\d.]+\s+\(/i
|
||||
|
||||
const clientUA = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
|
||||
let cachedUA = await redis.client.get(CACHE_KEY)
|
||||
|
||||
if (clientUA && CLAUDE_CODE_UA_PATTERN.test(clientUA)) {
|
||||
if (!cachedUA) {
|
||||
// 没有缓存,直接存储
|
||||
await redis.client.setex(CACHE_KEY, TTL, clientUA)
|
||||
logger.info(`📱 Captured unified Claude Code User-Agent: ${clientUA}`)
|
||||
cachedUA = clientUA
|
||||
} else {
|
||||
// 有缓存,比较版本号,保存更新的版本
|
||||
const shouldUpdate = this.compareClaudeCodeVersions(clientUA, cachedUA)
|
||||
if (shouldUpdate) {
|
||||
await redis.client.setex(CACHE_KEY, TTL, clientUA)
|
||||
logger.info(`🔄 Updated to newer Claude Code User-Agent: ${clientUA} (was: ${cachedUA})`)
|
||||
cachedUA = clientUA
|
||||
} else {
|
||||
// 当前版本不比缓存版本新,仅刷新TTL
|
||||
await redis.client.expire(CACHE_KEY, TTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cachedUA // 没有缓存返回 null
|
||||
}
|
||||
|
||||
// 🔄 比较Claude Code版本号,判断是否需要更新
|
||||
// 返回 true 表示 newUA 版本更新,需要更新缓存
|
||||
compareClaudeCodeVersions(newUA, cachedUA) {
|
||||
try {
|
||||
// 提取版本号:claude-cli/1.0.102 (external, cli) -> 1.0.102
|
||||
// 支持多段版本号格式,如 1.0.102、2.1.0.beta1 等
|
||||
const newVersionMatch = newUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
|
||||
const cachedVersionMatch = cachedUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
|
||||
|
||||
if (!newVersionMatch || !cachedVersionMatch) {
|
||||
// 无法解析版本号,优先使用新的
|
||||
logger.warn(`⚠️ Unable to parse Claude Code versions: new=${newUA}, cached=${cachedUA}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const newVersion = newVersionMatch[1]
|
||||
const cachedVersion = cachedVersionMatch[1]
|
||||
|
||||
// 比较版本号 (semantic version)
|
||||
const compareResult = this.compareSemanticVersions(newVersion, cachedVersion)
|
||||
|
||||
logger.debug(`🔍 Version comparison: ${newVersion} vs ${cachedVersion} = ${compareResult}`)
|
||||
|
||||
return compareResult > 0 // 新版本更大则返回 true
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Error comparing Claude Code versions, defaulting to update: ${error.message}`)
|
||||
return true // 出错时优先使用新的
|
||||
}
|
||||
}
|
||||
|
||||
// 🔢 比较版本号
|
||||
// 返回:1 表示 v1 > v2,-1 表示 v1 < v2,0 表示相等
|
||||
compareSemanticVersions(version1, version2) {
|
||||
// 将版本号字符串按"."分割成数字数组
|
||||
const arr1 = version1.split('.')
|
||||
const arr2 = version2.split('.')
|
||||
|
||||
// 获取两个版本号数组中的最大长度
|
||||
const maxLength = Math.max(arr1.length, arr2.length)
|
||||
|
||||
// 循环遍历,逐段比较版本号
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
// 如果某个版本号的某一段不存在,则视为0
|
||||
const num1 = parseInt(arr1[i] || 0, 10)
|
||||
const num2 = parseInt(arr2[i] || 0, 10)
|
||||
|
||||
if (num1 > num2) {
|
||||
return 1 // version1 大于 version2
|
||||
}
|
||||
if (num1 < num2) {
|
||||
return -1 // version1 小于 version2
|
||||
}
|
||||
}
|
||||
|
||||
return 0 // 两个版本号相等
|
||||
}
|
||||
|
||||
// 🎯 健康检查
|
||||
async healthCheck() {
|
||||
try {
|
||||
|
||||
@@ -138,11 +138,19 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
||||
return new OAuth2Client(clientOptions)
|
||||
}
|
||||
|
||||
// 生成授权 URL (支持 PKCE)
|
||||
async function generateAuthUrl(state = null, redirectUri = null) {
|
||||
// 生成授权 URL (支持 PKCE 和代理)
|
||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
||||
// 使用新的 redirect URI
|
||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri)
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini auth URL generation: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini auth URL generation')
|
||||
}
|
||||
|
||||
// 生成 PKCE code verifier
|
||||
const codeVerifier = await oAuth2Client.generateCodeVerifierAsync()
|
||||
@@ -965,12 +973,10 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法
|
||||
async function getOauthClient(accessToken, refreshToken) {
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET
|
||||
})
|
||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
||||
const client = createOAuth2Client(null, proxyConfig)
|
||||
|
||||
const creds = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
@@ -980,6 +986,14 @@ async function getOauthClient(accessToken, refreshToken) {
|
||||
expiry_date: 1754269905646
|
||||
}
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini OAuth client: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini OAuth client')
|
||||
}
|
||||
|
||||
// 设置凭据
|
||||
client.setCredentials(creds)
|
||||
|
||||
@@ -996,8 +1010,8 @@ async function getOauthClient(accessToken, refreshToken) {
|
||||
return client
|
||||
}
|
||||
|
||||
// 调用 Google Code Assist API 的 loadCodeAssist 方法
|
||||
async function loadCodeAssist(client, projectId = null) {
|
||||
// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理)
|
||||
async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
|
||||
const axios = require('axios')
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
|
||||
const CODE_ASSIST_API_VERSION = 'v1internal'
|
||||
@@ -1008,16 +1022,24 @@ async function loadCodeAssist(client, projectId = null) {
|
||||
const clientMetadata = {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId
|
||||
pluginType: 'GEMINI'
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加duetProject
|
||||
if (projectId) {
|
||||
clientMetadata.duetProject = projectId
|
||||
}
|
||||
|
||||
const request = {
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: clientMetadata
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
// 只有当projectId存在时才添加cloudaicompanionProject
|
||||
if (projectId) {
|
||||
request.cloudaicompanionProject = projectId
|
||||
}
|
||||
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1026,7 +1048,20 @@ async function loadCodeAssist(client, projectId = null) {
|
||||
},
|
||||
data: request,
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini loadCodeAssist')
|
||||
}
|
||||
|
||||
const response = await axios(axiosConfig)
|
||||
|
||||
logger.info('📋 loadCodeAssist API调用成功')
|
||||
return response.data
|
||||
@@ -1059,8 +1094,8 @@ function getOnboardTier(loadRes) {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑)
|
||||
async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理)
|
||||
async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) {
|
||||
const axios = require('axios')
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
|
||||
const CODE_ASSIST_API_VERSION = 'v1internal'
|
||||
@@ -1069,10 +1104,37 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
|
||||
const onboardReq = {
|
||||
tierId,
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: clientMetadata
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加cloudaicompanionProject
|
||||
if (projectId) {
|
||||
onboardReq.cloudaicompanionProject = projectId
|
||||
}
|
||||
|
||||
// 创建基础axios配置
|
||||
const baseAxiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: onboardReq,
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
baseAxiosConfig.httpsAgent = proxyAgent
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini onboardUser')
|
||||
}
|
||||
|
||||
logger.info('📋 开始onboardUser API调用', {
|
||||
tierId,
|
||||
projectId,
|
||||
@@ -1081,16 +1143,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
})
|
||||
|
||||
// 轮询onboardUser直到长运行操作完成
|
||||
let lroRes = await axios({
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: onboardReq,
|
||||
timeout: 30000
|
||||
})
|
||||
let lroRes = await axios(baseAxiosConfig)
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次)
|
||||
@@ -1099,17 +1152,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
|
||||
lroRes = await axios({
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: onboardReq,
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
lroRes = await axios(baseAxiosConfig)
|
||||
attempts++
|
||||
}
|
||||
|
||||
@@ -1121,8 +1164,13 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
return lroRes.data
|
||||
}
|
||||
|
||||
// 完整的用户设置流程 - 参考setup.ts的逻辑
|
||||
async function setupUser(client, initialProjectId = null, clientMetadata = null) {
|
||||
// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理)
|
||||
async function setupUser(
|
||||
client,
|
||||
initialProjectId = null,
|
||||
clientMetadata = null,
|
||||
proxyConfig = null
|
||||
) {
|
||||
logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata })
|
||||
|
||||
let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null
|
||||
@@ -1141,7 +1189,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null)
|
||||
|
||||
// 调用loadCodeAssist
|
||||
logger.info('📞 调用 loadCodeAssist...')
|
||||
const loadRes = await loadCodeAssist(client, projectId)
|
||||
const loadRes = await loadCodeAssist(client, projectId, proxyConfig)
|
||||
logger.info('✅ loadCodeAssist 完成', {
|
||||
hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject
|
||||
})
|
||||
@@ -1164,7 +1212,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null)
|
||||
|
||||
// 调用onboardUser
|
||||
logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId })
|
||||
const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata)
|
||||
const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig)
|
||||
logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response })
|
||||
|
||||
const result = {
|
||||
@@ -1178,8 +1226,8 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null)
|
||||
return result
|
||||
}
|
||||
|
||||
// 调用 Code Assist API 计算 token 数量
|
||||
async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') {
|
||||
// 调用 Code Assist API 计算 token 数量(支持代理)
|
||||
async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) {
|
||||
const axios = require('axios')
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
|
||||
const CODE_ASSIST_API_VERSION = 'v1internal'
|
||||
@@ -1196,7 +1244,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') {
|
||||
|
||||
logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length })
|
||||
|
||||
const response = await axios({
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1205,7 +1253,20 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') {
|
||||
},
|
||||
data: request,
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini countTokens')
|
||||
}
|
||||
|
||||
const response = await axios(axiosConfig)
|
||||
|
||||
logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens })
|
||||
return response.data
|
||||
@@ -1229,14 +1290,22 @@ async function generateContent(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
project: projectId,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
}
|
||||
|
||||
logger.info('🤖 generateContent API调用开始', {
|
||||
model: requestData.model,
|
||||
userPromptId,
|
||||
@@ -1244,6 +1313,12 @@ async function generateContent(
|
||||
sessionId
|
||||
})
|
||||
|
||||
// 添加详细的请求日志
|
||||
logger.info('📦 generateContent 请求详情', {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
requestBody: JSON.stringify(request, null, 2)
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
method: 'POST',
|
||||
@@ -1291,14 +1366,22 @@ async function generateContentStream(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
project: projectId,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
}
|
||||
|
||||
logger.info('🌊 streamGenerateContent API调用开始', {
|
||||
model: requestData.model,
|
||||
userPromptId,
|
||||
|
||||
@@ -273,7 +273,7 @@ async function sendGeminiRequest({
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: config.requestTimeout || 120000
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
@@ -382,7 +382,7 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
const proxyAgent = createProxyAgent(proxy)
|
||||
@@ -482,7 +482,7 @@ async function countTokens({
|
||||
'X-Goog-User-Project': projectId || undefined
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 30000
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
|
||||
753
src/services/ldapService.js
Normal file
753
src/services/ldapService.js
Normal file
@@ -0,0 +1,753 @@
|
||||
const ldap = require('ldapjs')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const userService = require('./userService')
|
||||
|
||||
class LdapService {
|
||||
constructor() {
|
||||
this.config = config.ldap || {}
|
||||
this.client = null
|
||||
|
||||
// 验证配置 - 只有在 LDAP 配置存在且启用时才验证
|
||||
if (this.config && this.config.enabled) {
|
||||
this.validateConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 验证LDAP配置
|
||||
validateConfiguration() {
|
||||
const errors = []
|
||||
|
||||
if (!this.config.server) {
|
||||
errors.push('LDAP server configuration is missing')
|
||||
} else {
|
||||
if (!this.config.server.url || typeof this.config.server.url !== 'string') {
|
||||
errors.push('LDAP server URL is not configured or invalid')
|
||||
}
|
||||
|
||||
if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') {
|
||||
errors.push('LDAP bind DN is not configured or invalid')
|
||||
}
|
||||
|
||||
if (
|
||||
!this.config.server.bindCredentials ||
|
||||
typeof this.config.server.bindCredentials !== 'string'
|
||||
) {
|
||||
errors.push('LDAP bind credentials are not configured or invalid')
|
||||
}
|
||||
|
||||
if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') {
|
||||
errors.push('LDAP search base is not configured or invalid')
|
||||
}
|
||||
|
||||
if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') {
|
||||
errors.push('LDAP search filter is not configured or invalid')
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error('❌ LDAP configuration validation failed:', errors)
|
||||
// Don't throw error during initialization, just log warnings
|
||||
logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors')
|
||||
} else {
|
||||
logger.info('✅ LDAP configuration validation passed')
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 提取LDAP条目的DN
|
||||
extractDN(ldapEntry) {
|
||||
if (!ldapEntry) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try different ways to get the DN
|
||||
let dn = null
|
||||
|
||||
// Method 1: Direct dn property
|
||||
if (ldapEntry.dn) {
|
||||
;({ dn } = ldapEntry)
|
||||
}
|
||||
// Method 2: objectName property (common in some LDAP implementations)
|
||||
else if (ldapEntry.objectName) {
|
||||
dn = ldapEntry.objectName
|
||||
}
|
||||
// Method 3: distinguishedName property
|
||||
else if (ldapEntry.distinguishedName) {
|
||||
dn = ldapEntry.distinguishedName
|
||||
}
|
||||
// Method 4: Check if the entry itself is a DN string
|
||||
else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) {
|
||||
dn = ldapEntry
|
||||
}
|
||||
|
||||
// Convert DN to string if it's an object
|
||||
if (dn && typeof dn === 'object') {
|
||||
if (dn.toString && typeof dn.toString === 'function') {
|
||||
dn = dn.toString()
|
||||
} else if (dn.dn && typeof dn.dn === 'string') {
|
||||
;({ dn } = dn)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the DN format
|
||||
if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) {
|
||||
return dn.trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 🌐 从DN中提取域名,用于Windows AD UPN格式认证
|
||||
extractDomainFromDN(dnString) {
|
||||
try {
|
||||
if (!dnString || typeof dnString !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
// 提取所有DC组件:DC=test,DC=demo,DC=com
|
||||
const dcMatches = dnString.match(/DC=([^,]+)/gi)
|
||||
if (!dcMatches || dcMatches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 提取DC值并连接成域名
|
||||
const domainParts = dcMatches.map((match) => {
|
||||
const value = match.replace(/DC=/i, '').trim()
|
||||
return value
|
||||
})
|
||||
|
||||
if (domainParts.length > 0) {
|
||||
const domain = domainParts.join('.')
|
||||
logger.debug(`🌐 从DN提取域名: ${domain}`)
|
||||
return domain
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.debug('⚠️ 域名提取失败:', error.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔗 创建LDAP客户端连接
|
||||
createClient() {
|
||||
try {
|
||||
const clientOptions = {
|
||||
url: this.config.server.url,
|
||||
timeout: this.config.server.timeout,
|
||||
connectTimeout: this.config.server.connectTimeout,
|
||||
reconnect: true
|
||||
}
|
||||
|
||||
// 如果使用 LDAPS (SSL/TLS),添加 TLS 选项
|
||||
if (this.config.server.url.toLowerCase().startsWith('ldaps://')) {
|
||||
const tlsOptions = {}
|
||||
|
||||
// 证书验证设置
|
||||
if (this.config.server.tls) {
|
||||
if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') {
|
||||
tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized
|
||||
}
|
||||
|
||||
// CA 证书
|
||||
if (this.config.server.tls.ca) {
|
||||
tlsOptions.ca = this.config.server.tls.ca
|
||||
}
|
||||
|
||||
// 客户端证书和私钥 (双向认证)
|
||||
if (this.config.server.tls.cert) {
|
||||
tlsOptions.cert = this.config.server.tls.cert
|
||||
}
|
||||
|
||||
if (this.config.server.tls.key) {
|
||||
tlsOptions.key = this.config.server.tls.key
|
||||
}
|
||||
|
||||
// 服务器名称 (SNI)
|
||||
if (this.config.server.tls.servername) {
|
||||
tlsOptions.servername = this.config.server.tls.servername
|
||||
}
|
||||
}
|
||||
|
||||
clientOptions.tlsOptions = tlsOptions
|
||||
|
||||
logger.debug('🔒 Creating LDAPS client with TLS options:', {
|
||||
url: this.config.server.url,
|
||||
rejectUnauthorized: tlsOptions.rejectUnauthorized,
|
||||
hasCA: !!tlsOptions.ca,
|
||||
hasCert: !!tlsOptions.cert,
|
||||
hasKey: !!tlsOptions.key,
|
||||
servername: tlsOptions.servername
|
||||
})
|
||||
}
|
||||
|
||||
const client = ldap.createClient(clientOptions)
|
||||
|
||||
// 设置错误处理
|
||||
client.on('error', (err) => {
|
||||
if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
||||
logger.error('🔒 LDAP TLS certificate error:', {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates'
|
||||
})
|
||||
} else {
|
||||
logger.error('🔌 LDAP client error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
client.on('connect', () => {
|
||||
if (this.config.server.url.toLowerCase().startsWith('ldaps://')) {
|
||||
logger.info('🔒 LDAPS client connected successfully')
|
||||
} else {
|
||||
logger.info('🔗 LDAP client connected successfully')
|
||||
}
|
||||
})
|
||||
|
||||
client.on('connectTimeout', () => {
|
||||
logger.warn('⏱️ LDAP connection timeout')
|
||||
})
|
||||
|
||||
return client
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create LDAP client:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 绑定LDAP连接(管理员认证)
|
||||
async bindClient(client) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 验证绑定凭据
|
||||
const { bindDN } = this.config.server
|
||||
const { bindCredentials } = this.config.server
|
||||
|
||||
if (!bindDN || typeof bindDN !== 'string') {
|
||||
const error = new Error('LDAP bind DN is not configured or invalid')
|
||||
logger.error('❌ LDAP configuration error:', error.message)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!bindCredentials || typeof bindCredentials !== 'string') {
|
||||
const error = new Error('LDAP bind credentials are not configured or invalid')
|
||||
logger.error('❌ LDAP configuration error:', error.message)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
client.bind(bindDN, bindCredentials, (err) => {
|
||||
if (err) {
|
||||
logger.error('❌ LDAP bind failed:', err)
|
||||
reject(err)
|
||||
} else {
|
||||
logger.debug('🔑 LDAP bind successful')
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 搜索用户
|
||||
async searchUser(client, username) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 防止LDAP注入:转义特殊字符
|
||||
// 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL
|
||||
const escapedUsername = username
|
||||
.replace(/\\/g, '\\5c') // 反斜杠必须先转义
|
||||
.replace(/\*/g, '\\2a') // 星号
|
||||
.replace(/\(/g, '\\28') // 左括号
|
||||
.replace(/\)/g, '\\29') // 右括号
|
||||
.replace(/\0/g, '\\00') // NUL字符
|
||||
.replace(/\//g, '\\2f') // 斜杠
|
||||
|
||||
const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername)
|
||||
const searchOptions = {
|
||||
scope: 'sub',
|
||||
filter: searchFilter,
|
||||
attributes: this.config.server.searchAttributes
|
||||
}
|
||||
|
||||
logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`)
|
||||
|
||||
const entries = []
|
||||
|
||||
client.search(this.config.server.searchBase, searchOptions, (err, res) => {
|
||||
if (err) {
|
||||
logger.error('❌ LDAP search error:', err)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
logger.debug('🔍 LDAP search entry received:', {
|
||||
dn: entry.dn,
|
||||
objectName: entry.objectName,
|
||||
type: typeof entry.dn,
|
||||
entryType: typeof entry,
|
||||
hasAttributes: !!entry.attributes,
|
||||
attributeCount: entry.attributes ? entry.attributes.length : 0
|
||||
})
|
||||
entries.push(entry)
|
||||
})
|
||||
|
||||
res.on('searchReference', (referral) => {
|
||||
logger.debug('🔗 LDAP search referral:', referral.uris)
|
||||
})
|
||||
|
||||
res.on('error', (error) => {
|
||||
logger.error('❌ LDAP search result error:', error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
res.on('end', (result) => {
|
||||
logger.debug(
|
||||
`✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries`
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
resolve(null)
|
||||
} else {
|
||||
// Log the structure of the first entry for debugging
|
||||
if (entries[0]) {
|
||||
logger.debug('🔍 Full LDAP entry structure:', {
|
||||
entryType: typeof entries[0],
|
||||
entryConstructor: entries[0].constructor?.name,
|
||||
entryKeys: Object.keys(entries[0]),
|
||||
entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500)
|
||||
})
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
resolve(entries[0])
|
||||
} else {
|
||||
logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`)
|
||||
resolve(entries[0]) // 使用第一个结果
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 🔐 验证用户密码
|
||||
async authenticateUser(userDN, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 验证输入参数
|
||||
if (!userDN || typeof userDN !== 'string') {
|
||||
const error = new Error('User DN is not provided or invalid')
|
||||
logger.error('❌ LDAP authentication error:', error.message)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`)
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
const authClient = this.createClient()
|
||||
|
||||
authClient.bind(userDN, password, (err) => {
|
||||
authClient.unbind() // 立即关闭认证客户端
|
||||
|
||||
if (err) {
|
||||
if (err.name === 'InvalidCredentialsError') {
|
||||
logger.debug(`🚫 Invalid credentials for DN: ${userDN}`)
|
||||
resolve(false)
|
||||
} else {
|
||||
logger.error('❌ LDAP authentication error:', err)
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`✅ Authentication successful for DN: ${userDN}`)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式
|
||||
async tryWindowsADAuthentication(username, password) {
|
||||
if (!username || !password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 从searchBase提取域名
|
||||
const domain = this.extractDomainFromDN(this.config.server.searchBase)
|
||||
|
||||
const adFormats = []
|
||||
|
||||
if (domain) {
|
||||
// UPN格式(Windows AD标准)
|
||||
adFormats.push(`${username}@${domain}`)
|
||||
|
||||
// 如果域名有多个部分,也尝试简化版本
|
||||
const domainParts = domain.split('.')
|
||||
if (domainParts.length > 1) {
|
||||
adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分
|
||||
}
|
||||
|
||||
// 域\用户名格式
|
||||
const firstDomainPart = domainParts[0]
|
||||
if (firstDomainPart) {
|
||||
adFormats.push(`${firstDomainPart}\\${username}`)
|
||||
adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 纯用户名(最后尝试)
|
||||
adFormats.push(username)
|
||||
|
||||
logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`)
|
||||
|
||||
for (const format of adFormats) {
|
||||
try {
|
||||
logger.info(`🔍 尝试格式: ${format}`)
|
||||
const result = await this.tryDirectBind(format, password)
|
||||
if (result) {
|
||||
logger.info(`✅ Windows AD认证成功: ${format}`)
|
||||
return true
|
||||
}
|
||||
logger.debug(`❌ 认证失败: ${format}`)
|
||||
} catch (error) {
|
||||
logger.debug(`认证异常 ${format}:`, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🚫 所有Windows AD格式认证都失败了`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔐 直接尝试绑定认证的辅助方法
|
||||
async tryDirectBind(identifier, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const authClient = this.createClient()
|
||||
|
||||
authClient.bind(identifier, password, (err) => {
|
||||
authClient.unbind()
|
||||
|
||||
if (err) {
|
||||
if (err.name === 'InvalidCredentialsError') {
|
||||
resolve(false)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 📝 提取用户信息
|
||||
extractUserInfo(ldapEntry, username) {
|
||||
try {
|
||||
const attributes = ldapEntry.attributes || []
|
||||
const userInfo = { username }
|
||||
|
||||
// 创建属性映射
|
||||
const attrMap = {}
|
||||
attributes.forEach((attr) => {
|
||||
const name = attr.type || attr.name
|
||||
const values = Array.isArray(attr.values) ? attr.values : [attr.values]
|
||||
attrMap[name] = values.length === 1 ? values[0] : values
|
||||
})
|
||||
|
||||
// 根据配置映射用户属性
|
||||
const mapping = this.config.userMapping
|
||||
|
||||
userInfo.displayName = attrMap[mapping.displayName] || username
|
||||
userInfo.email = attrMap[mapping.email] || ''
|
||||
userInfo.firstName = attrMap[mapping.firstName] || ''
|
||||
userInfo.lastName = attrMap[mapping.lastName] || ''
|
||||
|
||||
// 如果没有displayName,尝试组合firstName和lastName
|
||||
if (!userInfo.displayName || userInfo.displayName === username) {
|
||||
if (userInfo.firstName || userInfo.lastName) {
|
||||
userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('📋 Extracted user info:', {
|
||||
username: userInfo.username,
|
||||
displayName: userInfo.displayName,
|
||||
email: userInfo.email
|
||||
})
|
||||
|
||||
return userInfo
|
||||
} catch (error) {
|
||||
logger.error('❌ Error extracting user info:', error)
|
||||
return { username }
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 验证和清理用户名
|
||||
validateAndSanitizeUsername(username) {
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
throw new Error('Username is required and must be a non-empty string')
|
||||
}
|
||||
|
||||
const trimmedUsername = username.trim()
|
||||
|
||||
// 用户名只能包含字母、数字、下划线和连字符
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]+$/
|
||||
if (!usernameRegex.test(trimmedUsername)) {
|
||||
throw new Error('Username can only contain letters, numbers, underscores, and hyphens')
|
||||
}
|
||||
|
||||
// 长度限制 (防止过长的输入)
|
||||
if (trimmedUsername.length > 64) {
|
||||
throw new Error('Username cannot exceed 64 characters')
|
||||
}
|
||||
|
||||
// 不能以连字符开头或结尾
|
||||
if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) {
|
||||
throw new Error('Username cannot start or end with a hyphen')
|
||||
}
|
||||
|
||||
return trimmedUsername
|
||||
}
|
||||
|
||||
// 🔐 主要的登录验证方法
|
||||
async authenticateUserCredentials(username, password) {
|
||||
if (!this.config.enabled) {
|
||||
throw new Error('LDAP authentication is not enabled')
|
||||
}
|
||||
|
||||
// 验证和清理用户名 (防止LDAP注入)
|
||||
const sanitizedUsername = this.validateAndSanitizeUsername(username)
|
||||
|
||||
if (!password || typeof password !== 'string' || password.trim() === '') {
|
||||
throw new Error('Password is required and must be a non-empty string')
|
||||
}
|
||||
|
||||
// 验证LDAP服务器配置
|
||||
if (!this.config.server || !this.config.server.url) {
|
||||
throw new Error('LDAP server URL is not configured')
|
||||
}
|
||||
|
||||
if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') {
|
||||
throw new Error('LDAP bind DN is not configured')
|
||||
}
|
||||
|
||||
if (
|
||||
!this.config.server.bindCredentials ||
|
||||
typeof this.config.server.bindCredentials !== 'string'
|
||||
) {
|
||||
throw new Error('LDAP bind credentials are not configured')
|
||||
}
|
||||
|
||||
if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') {
|
||||
throw new Error('LDAP search base is not configured')
|
||||
}
|
||||
|
||||
const client = this.createClient()
|
||||
|
||||
try {
|
||||
// 1. 使用管理员凭据绑定
|
||||
await this.bindClient(client)
|
||||
|
||||
// 2. 搜索用户 (使用已验证的用户名)
|
||||
const ldapEntry = await this.searchUser(client, sanitizedUsername)
|
||||
if (!ldapEntry) {
|
||||
logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`)
|
||||
return { success: false, message: 'Invalid username or password' }
|
||||
}
|
||||
|
||||
// 3. 获取用户DN
|
||||
logger.debug('🔍 LDAP entry details for DN extraction:', {
|
||||
hasEntry: !!ldapEntry,
|
||||
entryType: typeof ldapEntry,
|
||||
entryKeys: Object.keys(ldapEntry || {}),
|
||||
dn: ldapEntry.dn,
|
||||
objectName: ldapEntry.objectName,
|
||||
dnType: typeof ldapEntry.dn,
|
||||
objectNameType: typeof ldapEntry.objectName
|
||||
})
|
||||
|
||||
// Use the helper method to extract DN
|
||||
const userDN = this.extractDN(ldapEntry)
|
||||
|
||||
logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`)
|
||||
|
||||
// 验证用户DN
|
||||
if (!userDN) {
|
||||
logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, {
|
||||
ldapEntryDn: ldapEntry.dn,
|
||||
ldapEntryObjectName: ldapEntry.objectName,
|
||||
ldapEntryType: typeof ldapEntry,
|
||||
extractedDN: userDN
|
||||
})
|
||||
return { success: false, message: 'Authentication service error' }
|
||||
}
|
||||
|
||||
// 4. 验证用户密码 - 支持传统LDAP和Windows AD
|
||||
let isPasswordValid = false
|
||||
|
||||
// 首先尝试传统的DN认证(保持原有LDAP逻辑)
|
||||
try {
|
||||
isPasswordValid = await this.authenticateUser(userDN, password)
|
||||
if (isPasswordValid) {
|
||||
logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 如果DN认证失败,尝试Windows AD多格式认证
|
||||
if (!isPasswordValid) {
|
||||
logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`)
|
||||
isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password)
|
||||
if (isPasswordValid) {
|
||||
logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPasswordValid) {
|
||||
logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`)
|
||||
return { success: false, message: 'Invalid username or password' }
|
||||
}
|
||||
|
||||
// 5. 提取用户信息
|
||||
const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername)
|
||||
|
||||
// 6. 创建或更新本地用户
|
||||
const user = await userService.createOrUpdateUser(userInfo)
|
||||
|
||||
// 7. 检查用户是否被禁用
|
||||
if (!user.isActive) {
|
||||
logger.security(
|
||||
`🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Your account has been disabled. Please contact administrator.'
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 记录登录
|
||||
await userService.recordUserLogin(user.id)
|
||||
|
||||
// 9. 创建用户会话
|
||||
const sessionToken = await userService.createUserSession(user.id)
|
||||
|
||||
logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
sessionToken,
|
||||
message: 'Authentication successful'
|
||||
}
|
||||
} catch (error) {
|
||||
// 记录详细错误供调试,但不向用户暴露
|
||||
logger.error('❌ LDAP authentication error:', {
|
||||
username: sanitizedUsername,
|
||||
error: error.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
})
|
||||
|
||||
// 返回通用错误消息,避免信息泄露
|
||||
// 不要尝试解析具体的错误信息,因为不同LDAP服务器返回的格式不同
|
||||
return {
|
||||
success: false,
|
||||
message: 'Authentication service unavailable'
|
||||
}
|
||||
} finally {
|
||||
// 确保客户端连接被关闭
|
||||
if (client) {
|
||||
client.unbind((err) => {
|
||||
if (err) {
|
||||
logger.debug('Error unbinding LDAP client:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 测试LDAP连接
|
||||
async testConnection() {
|
||||
if (!this.config.enabled) {
|
||||
return { success: false, message: 'LDAP is not enabled' }
|
||||
}
|
||||
|
||||
const client = this.createClient()
|
||||
|
||||
try {
|
||||
await this.bindClient(client)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'LDAP connection successful',
|
||||
server: this.config.server.url,
|
||||
searchBase: this.config.server.searchBase
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ LDAP connection test failed:', {
|
||||
error: error.message,
|
||||
server: this.config.server.url,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
})
|
||||
|
||||
// 提供通用错误消息,避免泄露系统细节
|
||||
let userMessage = 'LDAP connection failed'
|
||||
|
||||
// 对于某些已知错误类型,提供有用但不泄露细节的信息
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
userMessage = 'Unable to connect to LDAP server'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
userMessage = 'LDAP server connection timeout'
|
||||
} else if (error.name === 'InvalidCredentialsError') {
|
||||
userMessage = 'LDAP bind credentials are invalid'
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: userMessage,
|
||||
server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分
|
||||
}
|
||||
} finally {
|
||||
if (client) {
|
||||
client.unbind((err) => {
|
||||
if (err) {
|
||||
logger.debug('Error unbinding test LDAP client:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取LDAP配置信息(不包含敏感信息)
|
||||
getConfigInfo() {
|
||||
const configInfo = {
|
||||
enabled: this.config.enabled,
|
||||
server: {
|
||||
url: this.config.server.url,
|
||||
searchBase: this.config.server.searchBase,
|
||||
searchFilter: this.config.server.searchFilter,
|
||||
timeout: this.config.server.timeout,
|
||||
connectTimeout: this.config.server.connectTimeout
|
||||
},
|
||||
userMapping: this.config.userMapping
|
||||
}
|
||||
|
||||
// 添加 TLS 配置信息(不包含敏感数据)
|
||||
if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) {
|
||||
configInfo.server.tls = {
|
||||
rejectUnauthorized: this.config.server.tls.rejectUnauthorized,
|
||||
hasCA: !!this.config.server.tls.ca,
|
||||
hasCert: !!this.config.server.tls.cert,
|
||||
hasKey: !!this.config.server.tls.key,
|
||||
servername: this.config.server.tls.servername
|
||||
}
|
||||
}
|
||||
|
||||
return configInfo
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LdapService()
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
// const tokenRefreshService = require('./tokenRefreshService')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
@@ -57,7 +57,17 @@ function encrypt(text) {
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
|
||||
if (text.length < 33 || text.charAt(32) !== ':') {
|
||||
logger.warn('Invalid encrypted text format, returning empty string', {
|
||||
textLength: text ? text.length : 0,
|
||||
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
|
||||
first50: text ? text.substring(0, 50) : 'N/A'
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -128,13 +138,14 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
'Content-Length': requestData.length
|
||||
},
|
||||
data: requestData,
|
||||
timeout: 30000 // 30秒超时
|
||||
timeout: config.requestTimeout || 600000 // 使用统一的请求超时配置
|
||||
}
|
||||
|
||||
// 配置代理(如果有)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
|
||||
if (proxyAgent) {
|
||||
requestOptions.httpsAgent = proxyAgent
|
||||
requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
|
||||
logger.info(
|
||||
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
|
||||
)
|
||||
@@ -143,6 +154,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent)
|
||||
const response = await axios(requestOptions)
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
@@ -164,22 +176,73 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
// 服务器响应了错误状态码
|
||||
const errorData = error.response.data || {}
|
||||
logger.error('OpenAI token refresh failed:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
data: errorData,
|
||||
headers: error.response.headers
|
||||
})
|
||||
throw new Error(
|
||||
`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
|
||||
)
|
||||
|
||||
// 构建详细的错误信息
|
||||
let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})`
|
||||
|
||||
if (error.response.status === 400) {
|
||||
if (errorData.error === 'invalid_grant') {
|
||||
errorMessage = 'Refresh Token 无效或已过期,请重新授权'
|
||||
} else if (errorData.error === 'invalid_request') {
|
||||
errorMessage = `请求参数错误:${errorData.error_description || errorData.error}`
|
||||
} else {
|
||||
errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}`
|
||||
}
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage = '认证失败:Refresh Token 无效'
|
||||
} else if (error.response.status === 403) {
|
||||
errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用'
|
||||
} else if (error.response.status === 429) {
|
||||
errorMessage = '请求过于频繁,请稍后重试'
|
||||
} else if (error.response.status >= 500) {
|
||||
errorMessage = 'OpenAI 服务器内部错误,请稍后重试'
|
||||
} else if (errorData.error_description) {
|
||||
errorMessage = errorData.error_description
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message
|
||||
}
|
||||
|
||||
const fullError = new Error(errorMessage)
|
||||
fullError.status = error.response.status
|
||||
fullError.details = errorData
|
||||
throw fullError
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
logger.error('OpenAI token refresh no response:', error.message)
|
||||
throw new Error(`Token refresh failed: No response from server - ${error.message}`)
|
||||
|
||||
let errorMessage = '无法连接到 OpenAI 服务器'
|
||||
if (proxy) {
|
||||
errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})`
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
errorMessage += ' - 连接被拒绝'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage += ' - 连接超时'
|
||||
} else if (error.code === 'ENOTFOUND') {
|
||||
errorMessage += ' - 无法解析域名'
|
||||
} else if (error.code === 'EPROTO') {
|
||||
errorMessage += ' - 协议错误(可能是代理配置问题)'
|
||||
} else if (error.message) {
|
||||
errorMessage += ` - ${error.message}`
|
||||
}
|
||||
|
||||
const fullError = new Error(errorMessage)
|
||||
fullError.code = error.code
|
||||
throw fullError
|
||||
} else {
|
||||
// 设置请求时发生错误
|
||||
logger.error('OpenAI token refresh error:', error.message)
|
||||
throw new Error(`Token refresh failed: ${error.message}`)
|
||||
const fullError = new Error(`请求设置错误: ${error.message}`)
|
||||
fullError.originalError = error
|
||||
throw fullError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,34 +255,71 @@ function isTokenExpired(account) {
|
||||
return new Date(account.expiresAt) <= new Date()
|
||||
}
|
||||
|
||||
// 刷新账户的 access token
|
||||
// 刷新账户的 access token(带分布式锁)
|
||||
async function refreshAccountToken(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const accountName = account.name || accountId
|
||||
logRefreshStart(accountId, accountName, 'openai')
|
||||
|
||||
// 检查是否有 refresh token
|
||||
const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
|
||||
if (!refreshToken) {
|
||||
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
// 获取代理配置
|
||||
let proxy = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
|
||||
}
|
||||
}
|
||||
let lockAcquired = false
|
||||
let account = null
|
||||
let accountName = accountId
|
||||
|
||||
try {
|
||||
account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
accountName = account.name || accountId
|
||||
|
||||
// 检查是否有 refresh token
|
||||
// account.refreshToken 在 getAccount 中已经被解密了,直接使用即可
|
||||
const refreshToken = account.refreshToken || null
|
||||
|
||||
if (!refreshToken) {
|
||||
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
// 尝试获取分布式锁
|
||||
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai')
|
||||
|
||||
if (!lockAcquired) {
|
||||
// 如果无法获取锁,说明另一个进程正在刷新
|
||||
logger.info(
|
||||
`🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})`
|
||||
)
|
||||
logRefreshSkipped(accountId, accountName, 'openai', 'already_locked')
|
||||
|
||||
// 等待一段时间后返回,期望其他进程已完成刷新
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// 重新获取账户数据(可能已被其他进程刷新)
|
||||
const updatedAccount = await getAccount(accountId)
|
||||
if (updatedAccount && !isTokenExpired(updatedAccount)) {
|
||||
return {
|
||||
access_token: decrypt(updatedAccount.accessToken),
|
||||
id_token: updatedAccount.idToken,
|
||||
refresh_token: updatedAccount.refreshToken,
|
||||
expires_in: 3600,
|
||||
expiry_date: new Date(updatedAccount.expiresAt).getTime()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Token refresh in progress by another process')
|
||||
}
|
||||
|
||||
// 获取锁成功,开始刷新
|
||||
logRefreshStart(accountId, accountName, 'openai')
|
||||
logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`)
|
||||
|
||||
// 获取代理配置
|
||||
let proxy = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await refreshAccessToken(refreshToken, proxy)
|
||||
if (!newTokens) {
|
||||
throw new Error('Failed to refresh token')
|
||||
@@ -231,9 +331,51 @@ async function refreshAccountToken(accountId) {
|
||||
expiresAt: new Date(newTokens.expiry_date).toISOString()
|
||||
}
|
||||
|
||||
// 如果有新的 ID token,也更新它
|
||||
// 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要)
|
||||
if (newTokens.id_token) {
|
||||
updates.idToken = encrypt(newTokens.id_token)
|
||||
|
||||
// 如果之前没有 ID Token,尝试解析并更新用户信息
|
||||
if (!account.idToken || account.idToken === '') {
|
||||
try {
|
||||
const idTokenParts = newTokens.id_token.split('.')
|
||||
if (idTokenParts.length === 3) {
|
||||
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString())
|
||||
const authClaims = payload['https://api.openai.com/auth'] || {}
|
||||
|
||||
// 更新账户信息 - 使用正确的字段名
|
||||
// OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段
|
||||
if (authClaims.chatgpt_account_id) {
|
||||
updates.accountId = authClaims.chatgpt_account_id
|
||||
}
|
||||
if (authClaims.chatgpt_user_id) {
|
||||
updates.chatgptUserId = authClaims.chatgpt_user_id
|
||||
} else if (authClaims.user_id) {
|
||||
// 有些情况下可能只有user_id字段
|
||||
updates.chatgptUserId = authClaims.user_id
|
||||
}
|
||||
if (authClaims.organizations?.[0]?.id) {
|
||||
updates.organizationId = authClaims.organizations[0].id
|
||||
}
|
||||
if (authClaims.organizations?.[0]?.role) {
|
||||
updates.organizationRole = authClaims.organizations[0].role
|
||||
}
|
||||
if (authClaims.organizations?.[0]?.title) {
|
||||
updates.organizationTitle = authClaims.organizations[0].title
|
||||
}
|
||||
if (payload.email) {
|
||||
updates.email = encrypt(payload.email)
|
||||
}
|
||||
if (payload.email_verified !== undefined) {
|
||||
updates.emailVerified = payload.email_verified
|
||||
}
|
||||
|
||||
logger.info(`Updated user info from ID Token for account ${accountId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse ID Token for account ${accountId}:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果返回了新的 refresh token,更新它
|
||||
@@ -248,8 +390,34 @@ async function refreshAccountToken(accountId) {
|
||||
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
|
||||
return newTokens
|
||||
} catch (error) {
|
||||
logRefreshError(accountId, accountName, 'openai', error.message)
|
||||
logRefreshError(accountId, account?.name || accountName, 'openai', error.message)
|
||||
|
||||
// 发送 Webhook 通知(如果启用)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account?.name || accountName,
|
||||
platform: 'openai',
|
||||
status: 'error',
|
||||
errorCode: 'OPENAI_TOKEN_REFRESH_FAILED',
|
||||
reason: `Token refresh failed: ${error.message}`,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
// 确保释放锁
|
||||
if (lockAcquired) {
|
||||
await tokenRefreshService.releaseRefreshLock(accountId, 'openai')
|
||||
logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +438,10 @@ async function createAccount(accountData) {
|
||||
// 处理账户信息
|
||||
const accountInfo = accountData.accountInfo || {}
|
||||
|
||||
// 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符)
|
||||
const isEmailEncrypted =
|
||||
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
|
||||
|
||||
const account = {
|
||||
id: accountId,
|
||||
name: accountData.name,
|
||||
@@ -282,19 +454,25 @@ async function createAccount(accountData) {
|
||||
? accountData.rateLimitDuration
|
||||
: 60,
|
||||
// OAuth相关字段(加密存储)
|
||||
idToken: encrypt(oauthData.idToken || ''),
|
||||
accessToken: encrypt(oauthData.accessToken || ''),
|
||||
refreshToken: encrypt(oauthData.refreshToken || ''),
|
||||
// ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取
|
||||
idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '',
|
||||
accessToken:
|
||||
oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '',
|
||||
refreshToken:
|
||||
oauthData.refreshToken && oauthData.refreshToken.trim()
|
||||
? encrypt(oauthData.refreshToken)
|
||||
: '',
|
||||
openaiOauth: encrypt(JSON.stringify(oauthData)),
|
||||
// 账户信息字段
|
||||
// 账户信息字段 - 确保所有字段都被保存,即使是空字符串
|
||||
accountId: accountInfo.accountId || '',
|
||||
chatgptUserId: accountInfo.chatgptUserId || '',
|
||||
organizationId: accountInfo.organizationId || '',
|
||||
organizationRole: accountInfo.organizationRole || '',
|
||||
organizationTitle: accountInfo.organizationTitle || '',
|
||||
planType: accountInfo.planType || '',
|
||||
email: encrypt(accountInfo.email || ''),
|
||||
emailVerified: accountInfo.emailVerified || false,
|
||||
// 邮箱字段:检查是否已经加密,避免双重加密
|
||||
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
|
||||
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
|
||||
// 过期时间
|
||||
expiresAt: oauthData.expires_in
|
||||
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
|
||||
@@ -339,9 +517,10 @@ async function getAccount(accountId) {
|
||||
if (accountData.idToken) {
|
||||
accountData.idToken = decrypt(accountData.idToken)
|
||||
}
|
||||
if (accountData.accessToken) {
|
||||
accountData.accessToken = decrypt(accountData.accessToken)
|
||||
}
|
||||
// 注意:accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密
|
||||
// if (accountData.accessToken) {
|
||||
// accountData.accessToken = decrypt(accountData.accessToken)
|
||||
// }
|
||||
if (accountData.refreshToken) {
|
||||
accountData.refreshToken = decrypt(accountData.refreshToken)
|
||||
}
|
||||
@@ -391,7 +570,7 @@ async function updateAccount(accountId, updates) {
|
||||
if (updates.accessToken) {
|
||||
updates.accessToken = encrypt(updates.accessToken)
|
||||
}
|
||||
if (updates.refreshToken) {
|
||||
if (updates.refreshToken && updates.refreshToken.trim()) {
|
||||
updates.refreshToken = encrypt(updates.refreshToken)
|
||||
}
|
||||
if (updates.email) {
|
||||
@@ -476,6 +655,9 @@ async function getAllAccounts() {
|
||||
accountData.email = decrypt(accountData.email)
|
||||
}
|
||||
|
||||
// 先保存 refreshToken 是否存在的标记
|
||||
const hasRefreshTokenFlag = !!accountData.refreshToken
|
||||
|
||||
// 屏蔽敏感信息(token等不应该返回给前端)
|
||||
delete accountData.idToken
|
||||
delete accountData.accessToken
|
||||
@@ -502,6 +684,8 @@ async function getAllAccounts() {
|
||||
// 不解密敏感字段,只返回基本信息
|
||||
accounts.push({
|
||||
...accountData,
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false',
|
||||
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
@@ -510,7 +694,7 @@ async function getAllAccounts() {
|
||||
scopes:
|
||||
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
|
||||
// 添加 hasRefreshToken 标记
|
||||
hasRefreshToken: !!accountData.refreshToken,
|
||||
hasRefreshToken: hasRefreshTokenFlag,
|
||||
// 添加限流状态信息(统一格式)
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
@@ -630,14 +814,101 @@ function isRateLimited(account) {
|
||||
}
|
||||
|
||||
// 设置账户限流状态
|
||||
async function setAccountRateLimited(accountId, isLimited) {
|
||||
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) {
|
||||
const updates = {
|
||||
rateLimitStatus: isLimited ? 'limited' : 'normal',
|
||||
rateLimitedAt: isLimited ? new Date().toISOString() : null
|
||||
rateLimitedAt: isLimited ? new Date().toISOString() : null,
|
||||
// 限流时停止调度,解除限流时恢复调度
|
||||
schedulable: isLimited ? 'false' : 'true'
|
||||
}
|
||||
|
||||
// 如果提供了重置时间(秒数),计算重置时间戳
|
||||
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
|
||||
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
|
||||
updates.rateLimitResetAt = resetTime
|
||||
logger.info(
|
||||
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
|
||||
)
|
||||
} else if (isLimited) {
|
||||
// 如果没有提供重置时间,使用默认的60分钟
|
||||
const defaultResetSeconds = 60 * 60 // 1小时
|
||||
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
|
||||
updates.rateLimitResetAt = resetTime
|
||||
logger.warn(
|
||||
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
|
||||
)
|
||||
} else if (!isLimited) {
|
||||
updates.rateLimitResetAt = null
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
|
||||
logger.info(
|
||||
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
|
||||
)
|
||||
|
||||
// 如果被限流,发送 Webhook 通知
|
||||
if (isLimited) {
|
||||
try {
|
||||
const account = await getAccount(accountId)
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai',
|
||||
status: 'blocked',
|
||||
errorCode: 'OPENAI_RATE_LIMITED',
|
||||
reason: resetsInSeconds
|
||||
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
|
||||
: 'Account rate limited (429 error). Estimated reset in 1 hour',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async function resetAccountStatus(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
// 根据是否有有效的 accessToken 来设置 status
|
||||
status: account.accessToken ? 'active' : 'created',
|
||||
// 恢复可调度状态
|
||||
schedulable: 'true',
|
||||
// 清除错误相关字段
|
||||
errorMessage: null,
|
||||
rateLimitedAt: null,
|
||||
rateLimitStatus: 'normal',
|
||||
rateLimitResetAt: null
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, message: 'Account status reset successfully' }
|
||||
}
|
||||
|
||||
// 切换账户调度状态
|
||||
@@ -669,15 +940,26 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
let remainingTime = 0
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetAt = new Date(account.rateLimitResetAt).getTime()
|
||||
remainingTime = Math.max(0, resetAt - now)
|
||||
}
|
||||
// 回退到使用 rateLimitedAt + 默认1小时
|
||||
else if (account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const limitDuration = 60 * 60 * 1000 // 默认1小时
|
||||
remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: remainingTime > 0,
|
||||
rateLimitedAt: account.rateLimitedAt,
|
||||
rateLimitResetAt: account.rateLimitResetAt,
|
||||
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
||||
}
|
||||
}
|
||||
@@ -685,6 +967,7 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitResetAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
}
|
||||
@@ -722,6 +1005,7 @@ module.exports = {
|
||||
refreshAccountToken,
|
||||
isTokenExpired,
|
||||
setAccountRateLimited,
|
||||
resetAccountStatus,
|
||||
toggleSchedulable,
|
||||
getAccountRateLimitInfo,
|
||||
updateAccountUsage,
|
||||
|
||||
574
src/services/openaiResponsesAccountService.js
Normal file
574
src/services/openaiResponsesAccountService.js
Normal file
@@ -0,0 +1,574 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class OpenAIResponsesAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'openai-responses-salt'
|
||||
|
||||
// Redis 键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info(
|
||||
'🧹 OpenAI-Responses decrypt cache cleanup completed',
|
||||
this._decryptCache.getStats()
|
||||
)
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'OpenAI Responses Account',
|
||||
description = '',
|
||||
baseApi = '', // 必填:API 基础地址
|
||||
apiKey = '', // 必填:API 密钥
|
||||
userAgent = '', // 可选:自定义 User-Agent,空则透传原始请求
|
||||
priority = 50, // 调度优先级 (1-100)
|
||||
proxy = null,
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
rateLimitDuration = 60 // 限流时间(分钟)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
if (!baseApi || !apiKey) {
|
||||
throw new Error('Base API URL and API Key are required for OpenAI-Responses account')
|
||||
}
|
||||
|
||||
// 规范化 baseApi(确保不以 / 结尾)
|
||||
const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi
|
||||
|
||||
const accountId = uuidv4()
|
||||
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
platform: 'openai-responses',
|
||||
name,
|
||||
description,
|
||||
baseApi: normalizedBaseApi,
|
||||
apiKey: this._encryptSensitiveData(apiKey),
|
||||
userAgent,
|
||||
priority: priority.toString(),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
schedulable: schedulable.toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
// 额度管理
|
||||
dailyQuota: dailyQuota.toString(),
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: ''
|
||||
}
|
||||
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
apiKey: '***' // 返回时隐藏敏感信息
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
async getAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const accountData = await client.hgetall(key)
|
||||
|
||||
if (!accountData || !accountData.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey)
|
||||
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
async updateAccount(accountId, updates) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 处理敏感字段加密
|
||||
if (updates.apiKey) {
|
||||
updates.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||
}
|
||||
|
||||
// 处理 JSON 字段
|
||||
if (updates.proxy !== undefined) {
|
||||
updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||
}
|
||||
|
||||
// 规范化 baseApi
|
||||
if (updates.baseApi) {
|
||||
updates.baseApi = updates.baseApi.endsWith('/')
|
||||
? updates.baseApi.slice(0, -1)
|
||||
: updates.baseApi
|
||||
}
|
||||
|
||||
// 更新 Redis
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
await client.hset(key, updates)
|
||||
|
||||
logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 删除账户
|
||||
async deleteAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
async getAllAccounts(includeInactive = false) {
|
||||
const client = redis.getClientSafe()
|
||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
||||
const accounts = []
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (account) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || account.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: account.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
account.schedulable = account.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
accounts.push(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
accounts.push(accountData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 标记账户限流
|
||||
async markAccountRateLimited(accountId, duration = null) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60
|
||||
const now = new Date()
|
||||
const resetAt = new Date(now.getTime() + rateLimitDuration * 60000)
|
||||
|
||||
await this.updateAccount(accountId, {
|
||||
rateLimitedAt: now.toISOString(),
|
||||
rateLimitStatus: 'limited',
|
||||
rateLimitResetAt: resetAt.toISOString(),
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
status: 'rateLimited',
|
||||
schedulable: 'false', // 防止被调度
|
||||
errorMessage: `Rate limited until ${resetAt.toISOString()}`
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})`
|
||||
)
|
||||
}
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
async checkAndClearRateLimit(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account || account.rateLimitStatus !== 'limited') {
|
||||
return false
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let shouldClear = false
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetAt = new Date(account.rateLimitResetAt)
|
||||
shouldClear = now >= resetAt
|
||||
} else {
|
||||
// 如果没有 rateLimitResetAt,使用旧的逻辑
|
||||
const rateLimitedAt = new Date(account.rateLimitedAt)
|
||||
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60
|
||||
shouldClear = now - rateLimitedAt > rateLimitDuration * 60000
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// 限流已过期,清除状态
|
||||
await this.updateAccount(accountId, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitResetAt: '',
|
||||
status: 'active',
|
||||
schedulable: 'true', // 恢复调度
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.info(`✅ Rate limit cleared for account ${account.name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 切换调度状态
|
||||
async toggleSchedulable(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true'
|
||||
await this.updateAccount(accountId, {
|
||||
schedulable: newSchedulableStatus
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schedulable: newSchedulableStatus === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用额度
|
||||
async updateUsageQuota(accountId, amount) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要重置额度
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
// 重置额度
|
||||
await this.updateAccount(accountId, {
|
||||
dailyUsage: amount.toString(),
|
||||
lastResetDate: today,
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
} else {
|
||||
// 累加使用额度
|
||||
const currentUsage = parseFloat(account.dailyUsage) || 0
|
||||
const newUsage = currentUsage + amount
|
||||
const dailyQuota = parseFloat(account.dailyQuota) || 0
|
||||
|
||||
const updates = {
|
||||
dailyUsage: newUsage.toString()
|
||||
}
|
||||
|
||||
// 检查是否超出额度
|
||||
if (dailyQuota > 0 && newUsage >= dailyQuota) {
|
||||
updates.status = 'quotaExceeded'
|
||||
updates.quotaStoppedAt = new Date().toISOString()
|
||||
updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
logger.warn(`💸 Account ${account.name} exceeded daily quota`)
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户使用统计(记录 token 使用量)
|
||||
async updateAccountUsage(accountId, tokens = 0) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
const updates = {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 如果有 tokens 参数且大于0,同时更新使用统计
|
||||
if (tokens > 0) {
|
||||
const currentTokens = parseInt(account.totalUsedTokens) || 0
|
||||
updates.totalUsedTokens = (currentTokens + tokens).toString()
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
}
|
||||
|
||||
// 记录使用量(为了兼容性的别名)
|
||||
async recordUsage(accountId, tokens = 0) {
|
||||
return this.updateAccountUsage(accountId, tokens)
|
||||
}
|
||||
|
||||
// 重置账户状态(清除所有异常状态)
|
||||
async resetAccountStatus(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
// 根据是否有有效的 apiKey 来设置 status
|
||||
status: account.apiKey ? 'active' : 'created',
|
||||
// 恢复可调度状态
|
||||
schedulable: 'true',
|
||||
// 清除错误相关字段
|
||||
errorMessage: '',
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitResetAt: '',
|
||||
rateLimitDuration: ''
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai-responses',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, message: 'Account status reset successfully' }
|
||||
}
|
||||
|
||||
// 获取限流信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
if (accountData.rateLimitStatus !== 'limited') {
|
||||
return { isRateLimited: false }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let willBeAvailableAt
|
||||
let remainingMinutes
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段
|
||||
if (accountData.rateLimitResetAt) {
|
||||
willBeAvailableAt = new Date(accountData.rateLimitResetAt)
|
||||
remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000))
|
||||
} else {
|
||||
// 如果没有 rateLimitResetAt,使用旧的逻辑
|
||||
const rateLimitedAt = new Date(accountData.rateLimitedAt)
|
||||
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||
const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000)
|
||||
remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes)
|
||||
willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000)
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: remainingMinutes > 0,
|
||||
remainingMinutes,
|
||||
willBeAvailableAt
|
||||
}
|
||||
}
|
||||
|
||||
// 加密敏感数据
|
||||
_encryptSensitiveData(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = this._getEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
_decryptSensitiveData(text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this._getEncryptionKey()
|
||||
const [ivHex, encryptedHex] = text.split(':')
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 获取加密密钥
|
||||
_getEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 保存账户到 Redis
|
||||
async _saveAccount(accountId, accountData) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OpenAIResponsesAccountService()
|
||||
708
src/services/openaiResponsesRelayService.js
Normal file
708
src/services/openaiResponsesRelayService.js
Normal file
@@ -0,0 +1,708 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const config = require('../../config/config')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class OpenAIResponsesRelayService {
|
||||
constructor() {
|
||||
this.defaultTimeout = config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 处理请求转发
|
||||
async handleRequest(req, res, account, apiKeyData) {
|
||||
let abortController = null
|
||||
// 获取会话哈希(如果有的话)
|
||||
const sessionId = req.headers['session_id'] || req.body?.session_id
|
||||
const sessionHash = sessionId
|
||||
? crypto.createHash('sha256').update(sessionId).digest('hex')
|
||||
: null
|
||||
|
||||
try {
|
||||
// 获取完整的账户信息(包含解密的 API Key)
|
||||
const fullAccount = await openaiResponsesAccountService.getAccount(account.id)
|
||||
if (!fullAccount) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
abortController = new AbortController()
|
||||
|
||||
// 设置客户端断开监听器
|
||||
const handleClientDisconnect = () => {
|
||||
logger.info('🔌 Client disconnected, aborting OpenAI-Responses request')
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听客户端断开事件
|
||||
req.once('close', handleClientDisconnect)
|
||||
res.once('close', handleClientDisconnect)
|
||||
|
||||
// 构建目标 URL
|
||||
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
||||
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
||||
|
||||
// 构建请求头
|
||||
const headers = {
|
||||
...this._filterRequestHeaders(req.headers),
|
||||
Authorization: `Bearer ${fullAccount.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
// 处理 User-Agent
|
||||
if (fullAccount.userAgent) {
|
||||
// 使用自定义 User-Agent
|
||||
headers['User-Agent'] = fullAccount.userAgent
|
||||
logger.debug(`📱 Using custom User-Agent: ${fullAccount.userAgent}`)
|
||||
} else if (req.headers['user-agent']) {
|
||||
// 透传原始 User-Agent
|
||||
headers['User-Agent'] = req.headers['user-agent']
|
||||
logger.debug(`📱 Forwarding original User-Agent: ${req.headers['user-agent']}`)
|
||||
}
|
||||
|
||||
// 配置请求选项
|
||||
const requestOptions = {
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers,
|
||||
data: req.body,
|
||||
timeout: this.defaultTimeout,
|
||||
responseType: req.body?.stream ? 'stream' : 'json',
|
||||
validateStatus: () => true, // 允许处理所有状态码
|
||||
signal: abortController.signal
|
||||
}
|
||||
|
||||
// 配置代理(如果有)
|
||||
if (fullAccount.proxy) {
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy)
|
||||
if (proxyAgent) {
|
||||
requestOptions.httpsAgent = proxyAgent
|
||||
requestOptions.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for OpenAI-Responses: ${ProxyHelper.getProxyDescription(fullAccount.proxy)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求信息
|
||||
logger.info('📤 OpenAI-Responses relay request', {
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
targetUrl,
|
||||
method: req.method,
|
||||
stream: req.body?.stream || false,
|
||||
model: req.body?.model || 'unknown',
|
||||
userAgent: headers['User-Agent'] || 'not set'
|
||||
})
|
||||
|
||||
// 发送请求
|
||||
const response = await axios(requestOptions)
|
||||
|
||||
// 处理 429 限流错误
|
||||
if (response.status === 429) {
|
||||
const { resetsInSeconds, errorData } = await this._handle429Error(
|
||||
account,
|
||||
response,
|
||||
req.body?.stream,
|
||||
sessionHash
|
||||
)
|
||||
|
||||
// 返回错误响应(使用处理后的数据,避免循环引用)
|
||||
const errorResponse = errorData || {
|
||||
error: {
|
||||
message: 'Rate limit exceeded',
|
||||
type: 'rate_limit_error',
|
||||
code: 'rate_limit_exceeded',
|
||||
resets_in_seconds: resetsInSeconds
|
||||
}
|
||||
}
|
||||
return res.status(429).json(errorResponse)
|
||||
}
|
||||
|
||||
// 处理其他错误状态码
|
||||
if (response.status >= 400) {
|
||||
// 处理流式错误响应
|
||||
let errorData = response.data
|
||||
if (response.data && typeof response.data.pipe === 'function') {
|
||||
// 流式响应需要先读取内容
|
||||
const chunks = []
|
||||
await new Promise((resolve) => {
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', resolve)
|
||||
response.data.on('error', resolve)
|
||||
setTimeout(resolve, 5000) // 超时保护
|
||||
})
|
||||
const fullResponse = Buffer.concat(chunks).toString()
|
||||
|
||||
// 尝试解析错误响应
|
||||
try {
|
||||
if (fullResponse.includes('data: ')) {
|
||||
// SSE格式
|
||||
const lines = fullResponse.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
errorData = JSON.parse(jsonStr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 普通JSON
|
||||
errorData = JSON.parse(fullResponse)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse error response:', e)
|
||||
errorData = { error: { message: fullResponse || 'Unknown error' } }
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('OpenAI-Responses API error', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorData
|
||||
})
|
||||
|
||||
// 清理监听器
|
||||
req.removeListener('close', handleClientDisconnect)
|
||||
res.removeListener('close', handleClientDisconnect)
|
||||
|
||||
return res.status(response.status).json(errorData)
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
||||
return this._handleStreamResponse(
|
||||
response,
|
||||
res,
|
||||
account,
|
||||
apiKeyData,
|
||||
req.body?.model,
|
||||
handleClientDisconnect,
|
||||
req
|
||||
)
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
return this._handleNormalResponse(response, res, account, apiKeyData, req.body?.model)
|
||||
} catch (error) {
|
||||
// 清理 AbortController
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
abortController.abort()
|
||||
}
|
||||
|
||||
// 安全地记录错误,避免循环引用
|
||||
const errorInfo = {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText
|
||||
}
|
||||
logger.error('OpenAI-Responses relay error:', errorInfo)
|
||||
|
||||
// 检查是否是网络错误
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
status: 'error',
|
||||
errorMessage: `Connection error: ${error.code}`
|
||||
})
|
||||
}
|
||||
|
||||
// 如果已经发送了响应头,直接结束
|
||||
if (res.headersSent) {
|
||||
return res.end()
|
||||
}
|
||||
|
||||
// 检查是否是axios错误并包含响应
|
||||
if (error.response) {
|
||||
// 处理axios错误响应
|
||||
const status = error.response.status || 500
|
||||
let errorData = {
|
||||
error: {
|
||||
message: error.response.statusText || 'Request failed',
|
||||
type: 'api_error',
|
||||
code: error.code || 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果响应包含数据,尝试使用它
|
||||
if (error.response.data) {
|
||||
// 检查是否是流
|
||||
if (typeof error.response.data === 'object' && !error.response.data.pipe) {
|
||||
errorData = error.response.data
|
||||
} else if (typeof error.response.data === 'string') {
|
||||
try {
|
||||
errorData = JSON.parse(error.response.data)
|
||||
} catch (e) {
|
||||
errorData.error.message = error.response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(status).json(errorData)
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
type: 'internal_error',
|
||||
details: error.message
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
async _handleStreamResponse(
|
||||
response,
|
||||
res,
|
||||
account,
|
||||
apiKeyData,
|
||||
requestedModel,
|
||||
handleClientDisconnect,
|
||||
req
|
||||
) {
|
||||
// 设置 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')
|
||||
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let buffer = ''
|
||||
let rateLimitDetected = false
|
||||
let rateLimitResetsInSeconds = null
|
||||
let streamEnded = false
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据和 model
|
||||
const parseSSEForUsage = (data) => {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6)
|
||||
if (jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
|
||||
// 检查是否是 response.completed 事件(OpenAI-Responses 格式)
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
// 从响应中获取真实的 model
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
logger.debug(`📊 Captured actual model from response.completed: ${actualModel}`)
|
||||
}
|
||||
|
||||
// 获取 usage 数据 - OpenAI-Responses 格式在 response.usage 下
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.info('📊 Successfully captured usage data from OpenAI-Responses:', {
|
||||
input_tokens: usageData.input_tokens,
|
||||
output_tokens: usageData.output_tokens,
|
||||
total_tokens: usageData.total_tokens
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (eventData.error) {
|
||||
// 检查多种可能的限流错误类型
|
||||
if (
|
||||
eventData.error.type === 'rate_limit_error' ||
|
||||
eventData.error.type === 'usage_limit_reached' ||
|
||||
eventData.error.type === 'rate_limit_exceeded'
|
||||
) {
|
||||
rateLimitDetected = true
|
||||
if (eventData.error.resets_in_seconds) {
|
||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds (${Math.ceil(rateLimitResetsInSeconds / 60)} minutes)`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据流
|
||||
response.data.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 转发数据给客户端
|
||||
if (!res.destroyed && !streamEnded) {
|
||||
res.write(chunk)
|
||||
}
|
||||
|
||||
// 同时解析数据以捕获 usage 信息
|
||||
buffer += chunkStr
|
||||
|
||||
// 处理完整的 SSE 事件
|
||||
if (buffer.includes('\n\n')) {
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || ''
|
||||
|
||||
for (const event of events) {
|
||||
if (event.trim()) {
|
||||
parseSSEForUsage(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', async () => {
|
||||
streamEnded = true
|
||||
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim()) {
|
||||
parseSSEForUsage(buffer)
|
||||
}
|
||||
|
||||
// 记录使用统计
|
||||
if (usageData) {
|
||||
try {
|
||||
// OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens
|
||||
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
|
||||
|
||||
// 提取缓存相关的 tokens(如果存在)
|
||||
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
|
||||
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
|
||||
|
||||
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
|
||||
const modelToRecord = actualModel || requestedModel || 'gpt-4'
|
||||
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`📊 Recorded usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${modelToRecord}`
|
||||
)
|
||||
|
||||
// 更新账户的 token 使用统计
|
||||
await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens)
|
||||
|
||||
// 更新账户使用额度(如果设置了额度限制)
|
||||
if (parseFloat(account.dailyQuota) > 0) {
|
||||
// 估算费用(根据模型和token数量)
|
||||
const estimatedCost = this._estimateCost(modelToRecord, inputTokens, outputTokens)
|
||||
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在流式响应中检测到限流
|
||||
if (rateLimitDetected) {
|
||||
// 使用统一调度器处理限流(与非流式响应保持一致)
|
||||
const sessionId = req.headers['session_id'] || req.body?.session_id
|
||||
const sessionHash = sessionId
|
||||
? crypto.createHash('sha256').update(sessionId).digest('hex')
|
||||
: null
|
||||
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
account.id,
|
||||
'openai-responses',
|
||||
sessionHash,
|
||||
rateLimitResetsInSeconds
|
||||
)
|
||||
|
||||
logger.warn(
|
||||
`🚫 Processing rate limit for OpenAI-Responses account ${account.id} from stream`
|
||||
)
|
||||
}
|
||||
|
||||
// 清理监听器
|
||||
req.removeListener('close', handleClientDisconnect)
|
||||
res.removeListener('close', handleClientDisconnect)
|
||||
|
||||
if (!res.destroyed) {
|
||||
res.end()
|
||||
}
|
||||
|
||||
logger.info('Stream response completed', {
|
||||
accountId: account.id,
|
||||
hasUsage: !!usageData,
|
||||
actualModel: actualModel || 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
streamEnded = true
|
||||
logger.error('Stream error:', error)
|
||||
|
||||
// 清理监听器
|
||||
req.removeListener('close', handleClientDisconnect)
|
||||
res.removeListener('close', handleClientDisconnect)
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: { message: 'Upstream stream error' } })
|
||||
} else if (!res.destroyed) {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 处理客户端断开连接
|
||||
const cleanup = () => {
|
||||
streamEnded = true
|
||||
try {
|
||||
response.data?.unpipe?.(res)
|
||||
response.data?.destroy?.()
|
||||
} catch (_) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
|
||||
req.on('close', cleanup)
|
||||
req.on('aborted', cleanup)
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
async _handleNormalResponse(response, res, account, apiKeyData, requestedModel) {
|
||||
const responseData = response.data
|
||||
|
||||
// 提取 usage 数据和实际 model
|
||||
// 支持两种格式:直接的 usage 或嵌套在 response 中的 usage
|
||||
const usageData = responseData?.usage || responseData?.response?.usage
|
||||
const actualModel =
|
||||
responseData?.model || responseData?.response?.model || requestedModel || 'gpt-4'
|
||||
|
||||
// 记录使用统计
|
||||
if (usageData) {
|
||||
try {
|
||||
// OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens
|
||||
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
|
||||
|
||||
// 提取缓存相关的 tokens(如果存在)
|
||||
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
|
||||
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
|
||||
|
||||
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
|
||||
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
account.id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`📊 Recorded non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${actualModel}`
|
||||
)
|
||||
|
||||
// 更新账户的 token 使用统计
|
||||
await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens)
|
||||
|
||||
// 更新账户使用额度(如果设置了额度限制)
|
||||
if (parseFloat(account.dailyQuota) > 0) {
|
||||
// 估算费用(根据模型和token数量)
|
||||
const estimatedCost = this._estimateCost(actualModel, inputTokens, outputTokens)
|
||||
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
res.status(response.status).json(responseData)
|
||||
|
||||
logger.info('Normal response completed', {
|
||||
accountId: account.id,
|
||||
status: response.status,
|
||||
hasUsage: !!usageData,
|
||||
model: actualModel
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 429 限流错误
|
||||
async _handle429Error(account, response, isStream = false, sessionHash = null) {
|
||||
let resetsInSeconds = null
|
||||
let errorData = null
|
||||
|
||||
try {
|
||||
// 对于429错误,响应可能是JSON或SSE格式
|
||||
if (isStream && response.data && typeof response.data.pipe === 'function') {
|
||||
// 流式响应需要先收集数据
|
||||
const chunks = []
|
||||
await new Promise((resolve, reject) => {
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', resolve)
|
||||
response.data.on('error', reject)
|
||||
// 设置超时防止无限等待
|
||||
setTimeout(resolve, 5000)
|
||||
})
|
||||
|
||||
const fullResponse = Buffer.concat(chunks).toString()
|
||||
|
||||
// 尝试解析SSE格式的错误响应
|
||||
if (fullResponse.includes('data: ')) {
|
||||
const lines = fullResponse.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
errorData = JSON.parse(jsonStr)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
// 继续尝试下一行
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果SSE解析失败,尝试直接解析为JSON
|
||||
if (!errorData) {
|
||||
try {
|
||||
errorData = JSON.parse(fullResponse)
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse 429 error response:', e)
|
||||
logger.debug('Raw response:', fullResponse)
|
||||
}
|
||||
}
|
||||
} else if (response.data && typeof response.data !== 'object') {
|
||||
// 如果response.data是字符串,尝试解析为JSON
|
||||
try {
|
||||
errorData = JSON.parse(response.data)
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse 429 error response as JSON:', e)
|
||||
errorData = { error: { message: response.data } }
|
||||
}
|
||||
} else if (response.data && typeof response.data === 'object' && !response.data.pipe) {
|
||||
// 非流式响应,且是对象,直接使用
|
||||
errorData = response.data
|
||||
}
|
||||
|
||||
// 从响应体中提取重置时间(OpenAI 标准格式)
|
||||
if (errorData && errorData.error) {
|
||||
if (errorData.error.resets_in_seconds) {
|
||||
resetsInSeconds = errorData.error.resets_in_seconds
|
||||
logger.info(
|
||||
`🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
|
||||
)
|
||||
} else if (errorData.error.resets_in) {
|
||||
// 某些 API 可能使用不同的字段名
|
||||
resetsInSeconds = parseInt(errorData.error.resets_in)
|
||||
logger.info(
|
||||
`🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!resetsInSeconds) {
|
||||
logger.warn('⚠️ Could not extract reset time from 429 response, using default 60 minutes')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('⚠️ Failed to parse rate limit error:', e)
|
||||
}
|
||||
|
||||
// 使用统一调度器标记账户为限流状态(与普通OpenAI账号保持一致)
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
account.id,
|
||||
'openai-responses',
|
||||
sessionHash,
|
||||
resetsInSeconds
|
||||
)
|
||||
|
||||
logger.warn('OpenAI-Responses account rate limited', {
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
resetsInSeconds: resetsInSeconds || 'unknown',
|
||||
resetInMinutes: resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : 60,
|
||||
resetInHours: resetsInSeconds ? Math.ceil(resetsInSeconds / 3600) : 1
|
||||
})
|
||||
|
||||
// 返回处理后的数据,避免循环引用
|
||||
return { resetsInSeconds, errorData }
|
||||
}
|
||||
|
||||
// 过滤请求头
|
||||
_filterRequestHeaders(headers) {
|
||||
const filtered = {}
|
||||
const skipHeaders = [
|
||||
'host',
|
||||
'content-length',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'x-cr-api-key',
|
||||
'connection',
|
||||
'upgrade',
|
||||
'sec-websocket-key',
|
||||
'sec-websocket-version',
|
||||
'sec-websocket-extensions'
|
||||
]
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
||||
_estimateCost(model, inputTokens, outputTokens) {
|
||||
// 这是一个简化的费用估算,实际应该根据不同的 API 提供商和模型定价
|
||||
const rates = {
|
||||
'gpt-4': { input: 0.03, output: 0.06 }, // per 1K tokens
|
||||
'gpt-4-turbo': { input: 0.01, output: 0.03 },
|
||||
'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 },
|
||||
'claude-3-opus': { input: 0.015, output: 0.075 },
|
||||
'claude-3-sonnet': { input: 0.003, output: 0.015 },
|
||||
'claude-3-haiku': { input: 0.00025, output: 0.00125 }
|
||||
}
|
||||
|
||||
// 查找匹配的模型定价
|
||||
let rate = rates['gpt-3.5-turbo'] // 默认使用 GPT-3.5 的价格
|
||||
for (const [modelKey, modelRate] of Object.entries(rates)) {
|
||||
if (model.toLowerCase().includes(modelKey.toLowerCase())) {
|
||||
rate = modelRate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const inputCost = (inputTokens / 1000) * rate.input
|
||||
const outputCost = (outputTokens / 1000) * rate.output
|
||||
return inputCost + outputCost
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OpenAIResponsesRelayService()
|
||||
@@ -45,6 +45,7 @@ class PricingService {
|
||||
'claude-sonnet-3-5': 0.000006,
|
||||
'claude-sonnet-3-7': 0.000006,
|
||||
'claude-sonnet-4': 0.000006,
|
||||
'claude-sonnet-4-20250514': 0.000006,
|
||||
|
||||
// Haiku 系列: $1.6/MTok
|
||||
'claude-3-5-haiku': 0.0000016,
|
||||
@@ -55,6 +56,17 @@ class PricingService {
|
||||
'claude-haiku-3': 0.0000016,
|
||||
'claude-haiku-3-5': 0.0000016
|
||||
}
|
||||
|
||||
// 硬编码的 1M 上下文模型价格(美元/token)
|
||||
// 当总输入 tokens 超过 200k 时使用这些价格
|
||||
this.longContextPricing = {
|
||||
// claude-sonnet-4-20250514[1m] 模型的 1M 上下文价格
|
||||
'claude-sonnet-4-20250514[1m]': {
|
||||
input: 0.000006, // $6/MTok
|
||||
output: 0.0000225 // $22.50/MTok
|
||||
}
|
||||
// 未来可以添加更多 1M 模型的价格
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化价格服务
|
||||
@@ -249,6 +261,7 @@ class PricingService {
|
||||
|
||||
// 尝试直接匹配
|
||||
if (this.pricingData[modelName]) {
|
||||
logger.debug(`💰 Found exact pricing match for ${modelName}`)
|
||||
return this.pricingData[modelName]
|
||||
}
|
||||
|
||||
@@ -293,6 +306,22 @@ class PricingService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 确保价格对象包含缓存价格
|
||||
ensureCachePricing(pricing) {
|
||||
if (!pricing) {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍)
|
||||
if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) {
|
||||
pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25
|
||||
}
|
||||
if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) {
|
||||
pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1
|
||||
}
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 获取 1 小时缓存价格
|
||||
getEphemeral1hPricing(modelName) {
|
||||
if (!modelName) {
|
||||
@@ -329,9 +358,40 @@ class PricingService {
|
||||
|
||||
// 计算使用费用
|
||||
calculateCost(usage, modelName) {
|
||||
// 检查是否为 1M 上下文模型
|
||||
const isLongContextModel = modelName && modelName.includes('[1m]')
|
||||
let isLongContextRequest = false
|
||||
let useLongContextPricing = false
|
||||
|
||||
if (isLongContextModel) {
|
||||
// 计算总输入 tokens
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const cacheCreationTokens = usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens
|
||||
|
||||
// 如果总输入超过 200k,使用 1M 上下文价格
|
||||
if (totalInputTokens > 200000) {
|
||||
isLongContextRequest = true
|
||||
// 检查是否有硬编码的 1M 价格
|
||||
if (this.longContextPricing[modelName]) {
|
||||
useLongContextPricing = true
|
||||
} else {
|
||||
// 如果没有找到硬编码价格,使用第一个 1M 模型的价格作为默认
|
||||
const defaultLongContextModel = Object.keys(this.longContextPricing)[0]
|
||||
if (defaultLongContextModel) {
|
||||
useLongContextPricing = true
|
||||
logger.warn(
|
||||
`⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pricing = this.getModelPricing(modelName)
|
||||
|
||||
if (!pricing) {
|
||||
if (!pricing && !useLongContextPricing) {
|
||||
return {
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
@@ -340,14 +400,35 @@ class PricingService {
|
||||
ephemeral5mCost: 0,
|
||||
ephemeral1hCost: 0,
|
||||
totalCost: 0,
|
||||
hasPricing: false
|
||||
hasPricing: false,
|
||||
isLongContextRequest: false
|
||||
}
|
||||
}
|
||||
|
||||
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
|
||||
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
|
||||
let inputCost = 0
|
||||
let outputCost = 0
|
||||
|
||||
if (useLongContextPricing) {
|
||||
// 使用 1M 上下文特殊价格(仅输入和输出价格改变)
|
||||
const longContextPrices =
|
||||
this.longContextPricing[modelName] ||
|
||||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
|
||||
|
||||
inputCost = (usage.input_tokens || 0) * longContextPrices.input
|
||||
outputCost = (usage.output_tokens || 0) * longContextPrices.output
|
||||
|
||||
logger.info(
|
||||
`💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token`
|
||||
)
|
||||
} else {
|
||||
// 使用正常价格
|
||||
inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0)
|
||||
outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0)
|
||||
}
|
||||
|
||||
// 缓存价格保持不变(即使对于 1M 模型)
|
||||
const cacheReadCost =
|
||||
(usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
|
||||
(usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0)
|
||||
|
||||
// 处理缓存创建费用:
|
||||
// 1. 如果有详细的 cache_creation 对象,使用它
|
||||
@@ -362,7 +443,7 @@ class PricingService {
|
||||
const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
|
||||
// 5分钟缓存使用标准的 cache_creation_input_token_cost
|
||||
ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0)
|
||||
ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0)
|
||||
|
||||
// 1小时缓存使用硬编码的价格
|
||||
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
|
||||
@@ -373,7 +454,7 @@ class PricingService {
|
||||
} else if (usage.cache_creation_input_tokens) {
|
||||
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
|
||||
cacheCreateCost =
|
||||
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
|
||||
(usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0)
|
||||
ephemeral5mCost = cacheCreateCost
|
||||
}
|
||||
|
||||
@@ -386,11 +467,22 @@ class PricingService {
|
||||
ephemeral1hCost,
|
||||
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
|
||||
hasPricing: true,
|
||||
isLongContextRequest,
|
||||
pricing: {
|
||||
input: pricing.input_cost_per_token || 0,
|
||||
output: pricing.output_cost_per_token || 0,
|
||||
cacheCreate: pricing.cache_creation_input_token_cost || 0,
|
||||
cacheRead: pricing.cache_read_input_token_cost || 0,
|
||||
input: useLongContextPricing
|
||||
? (
|
||||
this.longContextPricing[modelName] ||
|
||||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
|
||||
)?.input || 0
|
||||
: pricing?.input_cost_per_token || 0,
|
||||
output: useLongContextPricing
|
||||
? (
|
||||
this.longContextPricing[modelName] ||
|
||||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
|
||||
)?.output || 0
|
||||
: pricing?.output_cost_per_token || 0,
|
||||
cacheCreate: pricing?.cache_creation_input_token_cost || 0,
|
||||
cacheRead: pricing?.cache_read_input_token_cost || 0,
|
||||
ephemeral1h: this.getEphemeral1hPricing(modelName)
|
||||
}
|
||||
}
|
||||
|
||||
384
src/services/rateLimitCleanupService.js
Normal file
384
src/services/rateLimitCleanupService.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 限流状态自动清理服务
|
||||
* 定期检查并清理所有类型账号的过期限流状态
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger')
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const webhookService = require('./webhookService')
|
||||
|
||||
class RateLimitCleanupService {
|
||||
constructor() {
|
||||
this.cleanupInterval = null
|
||||
this.isRunning = false
|
||||
// 默认每5分钟检查一次
|
||||
this.intervalMs = 5 * 60 * 1000
|
||||
// 存储已清理的账户信息,用于发送恢复通知
|
||||
this.clearedAccounts = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动清理服务
|
||||
* @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟
|
||||
*/
|
||||
start(intervalMinutes = 5) {
|
||||
if (this.cleanupInterval) {
|
||||
logger.warn('⚠️ Rate limit cleanup service is already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.intervalMs = intervalMinutes * 60 * 1000
|
||||
|
||||
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
|
||||
|
||||
// 立即执行一次清理
|
||||
this.performCleanup()
|
||||
|
||||
// 设置定期执行
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.performCleanup()
|
||||
}, this.intervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动清理服务
|
||||
*/
|
||||
stop() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
logger.info('🛑 Rate limit cleanup service stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次清理检查
|
||||
*/
|
||||
async performCleanup() {
|
||||
if (this.isRunning) {
|
||||
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
logger.debug('🔍 Starting rate limit cleanup check...')
|
||||
|
||||
const results = {
|
||||
openai: { checked: 0, cleared: 0, errors: [] },
|
||||
claude: { checked: 0, cleared: 0, errors: [] },
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||||
}
|
||||
|
||||
// 清理 OpenAI 账号
|
||||
await this.cleanupOpenAIAccounts(results.openai)
|
||||
|
||||
// 清理 Claude 账号
|
||||
await this.cleanupClaudeAccounts(results.claude)
|
||||
|
||||
// 清理 Claude Console 账号
|
||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||
|
||||
const totalChecked =
|
||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||
const totalCleared =
|
||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (totalCleared > 0) {
|
||||
logger.info(
|
||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||||
)
|
||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||
logger.info(
|
||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||
)
|
||||
|
||||
// 发送 webhook 恢复通知
|
||||
if (this.clearedAccounts.length > 0) {
|
||||
await this.sendRecoveryNotifications()
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
|
||||
)
|
||||
}
|
||||
|
||||
// 清空已清理账户列表
|
||||
this.clearedAccounts = []
|
||||
|
||||
// 记录错误
|
||||
const allErrors = [
|
||||
...results.openai.errors,
|
||||
...results.claude.errors,
|
||||
...results.claudeConsole.errors
|
||||
]
|
||||
if (allErrors.length > 0) {
|
||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Rate limit cleanup failed:', error)
|
||||
} finally {
|
||||
this.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 OpenAI 账号的过期限流
|
||||
*/
|
||||
async cleanupOpenAIAccounts(result) {
|
||||
try {
|
||||
const accounts = await openaiAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查标记为限流的账号
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'OpenAI',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup OpenAI accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Claude 账号的过期限流
|
||||
*/
|
||||
async cleanupClaudeAccounts(result) {
|
||||
try {
|
||||
const accounts = await claudeAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查标记为限流的账号
|
||||
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并恢复因5小时限制被自动停止的账号
|
||||
try {
|
||||
const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts()
|
||||
|
||||
if (fiveHourResult.recovered > 0) {
|
||||
// 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知
|
||||
for (const account of fiveHourResult.accounts) {
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: '5hour_limited',
|
||||
currentStatus: 'active',
|
||||
windowInfo: account.newWindow
|
||||
})
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
result.checked += fiveHourResult.checked
|
||||
result.cleared += fiveHourResult.recovered
|
||||
|
||||
logger.info(
|
||||
`🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error)
|
||||
result.errors.push({
|
||||
type: '5hour_recovery',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Claude Console 账号的过期限流
|
||||
*/
|
||||
async cleanupClaudeConsoleAccounts(result) {
|
||||
try {
|
||||
const accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 检查两种状态字段:rateLimitStatus 和 status
|
||||
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
|
||||
const hasStatusRateLimited = account.status === 'rate_limited'
|
||||
|
||||
if (hasRateLimitStatus || hasStatusRateLimited) {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||
account.id
|
||||
)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
|
||||
// 如果 status 字段是 rate_limited,需要额外清理
|
||||
if (hasStatusRateLimited && !hasRateLimitStatus) {
|
||||
await claudeConsoleAccountService.updateAccount(account.id, {
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude Console',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude Console accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||
*/
|
||||
async manualCleanup() {
|
||||
logger.info('🧹 Manual rate limit cleanup triggered')
|
||||
await this.performCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送限流恢复通知
|
||||
*/
|
||||
async sendRecoveryNotifications() {
|
||||
try {
|
||||
// 按平台分组账户
|
||||
const groupedAccounts = {}
|
||||
for (const account of this.clearedAccounts) {
|
||||
if (!groupedAccounts[account.platform]) {
|
||||
groupedAccounts[account.platform] = []
|
||||
}
|
||||
groupedAccounts[account.platform].push(account)
|
||||
}
|
||||
|
||||
// 构建通知消息
|
||||
const platforms = Object.keys(groupedAccounts)
|
||||
const totalAccounts = this.clearedAccounts.length
|
||||
|
||||
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
|
||||
|
||||
for (const platform of platforms) {
|
||||
const accounts = groupedAccounts[platform]
|
||||
message += `**${platform}** (${accounts.length} 个):\n`
|
||||
for (const account of accounts) {
|
||||
message += `• ${account.accountName} (ID: ${account.accountId})\n`
|
||||
}
|
||||
message += '\n'
|
||||
}
|
||||
|
||||
// 发送 webhook 通知
|
||||
await webhookService.sendNotification('rateLimitRecovery', {
|
||||
title: '限流恢复通知',
|
||||
message,
|
||||
totalAccounts,
|
||||
platforms: Object.keys(groupedAccounts),
|
||||
accounts: this.clearedAccounts,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 发送限流恢复通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
running: !!this.cleanupInterval,
|
||||
intervalMinutes: this.intervalMs / (60 * 1000),
|
||||
isProcessing: this.isRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const rateLimitCleanupService = new RateLimitCleanupService()
|
||||
|
||||
module.exports = rateLimitCleanupService
|
||||
@@ -1,9 +1,11 @@
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('./bedrockAccountService')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
|
||||
class UnifiedClaudeScheduler {
|
||||
constructor() {
|
||||
@@ -20,9 +22,121 @@ class UnifiedClaudeScheduler {
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否支持请求的模型
|
||||
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
|
||||
if (!requestedModel) {
|
||||
return true // 没有指定模型时,默认支持
|
||||
}
|
||||
|
||||
// Claude OAuth 账户的 Opus 模型检查
|
||||
if (accountType === 'claude-official') {
|
||||
if (requestedModel.toLowerCase().includes('opus')) {
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info =
|
||||
typeof account.subscriptionInfo === 'string'
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
logger.debug(
|
||||
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
||||
)
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Console 账户的模型支持检查
|
||||
if (accountType === 'claude-console' && account.supportedModels) {
|
||||
// 兼容旧格式(数组)和新格式(对象)
|
||||
if (Array.isArray(account.supportedModels)) {
|
||||
// 旧格式:数组
|
||||
if (
|
||||
account.supportedModels.length > 0 &&
|
||||
!account.supportedModels.includes(requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else if (typeof account.supportedModels === 'object') {
|
||||
// 新格式:映射表
|
||||
if (
|
||||
Object.keys(account.supportedModels).length > 0 &&
|
||||
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CCR 账户的模型支持检查
|
||||
if (accountType === 'ccr' && account.supportedModels) {
|
||||
// 兼容旧格式(数组)和新格式(对象)
|
||||
if (Array.isArray(account.supportedModels)) {
|
||||
// 旧格式:数组
|
||||
if (
|
||||
account.supportedModels.length > 0 &&
|
||||
!account.supportedModels.includes(requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else if (typeof account.supportedModels === 'object') {
|
||||
// 新格式:映射表
|
||||
if (
|
||||
Object.keys(account.supportedModels).length > 0 &&
|
||||
!ccrAccountService.isModelSupported(account.supportedModels, requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 解析供应商前缀
|
||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||
|
||||
logger.debug(
|
||||
`🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}`
|
||||
)
|
||||
|
||||
// 如果是 CCR 前缀,只在 CCR 账户池中选择
|
||||
if (vendor === 'ccr') {
|
||||
logger.info(`🎯 CCR vendor prefix detected, routing to CCR accounts only`)
|
||||
return await this._selectCcrAccount(apiKeyData, sessionHash, effectiveModel)
|
||||
}
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
if (apiKeyData.claudeAccountId) {
|
||||
// 检查是否是分组
|
||||
@@ -31,12 +145,22 @@ class UnifiedClaudeScheduler {
|
||||
logger.info(
|
||||
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
|
||||
)
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel)
|
||||
return await this.selectAccountFromGroup(
|
||||
groupId,
|
||||
sessionHash,
|
||||
effectiveModel,
|
||||
vendor === 'ccr'
|
||||
)
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
if (
|
||||
boundAccount &&
|
||||
boundAccount.isActive === 'true' &&
|
||||
boundAccount.status !== 'error' &&
|
||||
this._isSchedulable(boundAccount.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -46,7 +170,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +183,8 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active'
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||
@@ -70,7 +195,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`
|
||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +205,11 @@ class UnifiedClaudeScheduler {
|
||||
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
||||
apiKeyData.bedrockAccountId
|
||||
)
|
||||
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -90,42 +219,59 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool`
|
||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由)
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射
|
||||
if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') {
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
|
||||
`ℹ️ Skipping CCR sticky session mapping for non-CCR request; removing mapping for session ${sessionHash}`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
} else {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
effectiveModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天(续期正确的 unified 映射键)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有可用账户(传递请求的模型进行过滤)
|
||||
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel)
|
||||
const availableAccounts = await this._getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
effectiveModel,
|
||||
false // 仅前缀才走 CCR:默认池不包含 CCR 账户
|
||||
)
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
// 提供更详细的错误信息
|
||||
if (requestedModel) {
|
||||
if (effectiveModel) {
|
||||
throw new Error(
|
||||
`No available Claude accounts support the requested model: ${requestedModel}`
|
||||
`No available Claude accounts support the requested model: ${effectiveModel}`
|
||||
)
|
||||
} else {
|
||||
throw new Error('No available Claude accounts (neither official nor console)')
|
||||
@@ -165,7 +311,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户(合并官方和Console)
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
@@ -177,7 +323,8 @@ class UnifiedClaudeScheduler {
|
||||
boundAccount.isActive === 'true' &&
|
||||
boundAccount.status !== 'error' &&
|
||||
boundAccount.status !== 'blocked' &&
|
||||
boundAccount.status !== 'temp_error'
|
||||
boundAccount.status !== 'temp_error' &&
|
||||
this._isSchedulable(boundAccount.schedulable)
|
||||
) {
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||
if (!isRateLimited) {
|
||||
@@ -195,7 +342,9 @@ class UnifiedClaudeScheduler {
|
||||
]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`)
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,12 +356,28 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active'
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 主动触发一次额度检查
|
||||
try {
|
||||
await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}`
|
||||
)
|
||||
// 继续使用该账号
|
||||
}
|
||||
|
||||
// 检查限流状态和额度状态
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||
boundConsoleAccount.id
|
||||
)
|
||||
if (!isRateLimited) {
|
||||
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
|
||||
boundConsoleAccount.id
|
||||
)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`
|
||||
)
|
||||
@@ -228,7 +393,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`
|
||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +403,11 @@ class UnifiedClaudeScheduler {
|
||||
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
||||
apiKeyData.bedrockAccountId
|
||||
)
|
||||
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
||||
)
|
||||
@@ -252,7 +421,9 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
]
|
||||
} else {
|
||||
logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`)
|
||||
logger.warn(
|
||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,33 +440,9 @@ class UnifiedClaudeScheduler {
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查模型支持(如果请求的是 Opus 模型)
|
||||
if (requestedModel && requestedModel.toLowerCase().includes('opus')) {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info =
|
||||
typeof account.subscriptionInfo === 'string'
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`)
|
||||
continue // Claude Pro 不支持 Opus
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model`
|
||||
)
|
||||
continue // 明确标记为 Pro 或 Free 的账号不支持
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`)
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
@@ -330,37 +477,26 @@ class UnifiedClaudeScheduler {
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查模型支持(如果有请求的模型)
|
||||
if (requestedModel && account.supportedModels) {
|
||||
// 兼容旧格式(数组)和新格式(对象)
|
||||
if (Array.isArray(account.supportedModels)) {
|
||||
// 旧格式:数组
|
||||
if (
|
||||
account.supportedModels.length > 0 &&
|
||||
!account.supportedModels.includes(requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
} else if (typeof account.supportedModels === 'object') {
|
||||
// 新格式:映射表
|
||||
if (
|
||||
Object.keys(account.supportedModels).length > 0 &&
|
||||
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 主动触发一次额度检查,确保状态即时生效
|
||||
try {
|
||||
await claudeConsoleAccountService.checkQuotaUsage(account.id)
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to check quota for Claude Console account ${account.name}: ${e.message}`
|
||||
)
|
||||
// 继续处理该账号
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
|
||||
if (!isRateLimited) {
|
||||
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
@@ -372,7 +508,12 @@ class UnifiedClaudeScheduler {
|
||||
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
|
||||
)
|
||||
} else {
|
||||
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
|
||||
if (isRateLimited) {
|
||||
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
|
||||
}
|
||||
if (isQuotaExceeded) {
|
||||
logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
@@ -417,8 +558,60 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取CCR账户(共享池)- 仅当明确要求包含时
|
||||
if (includeCcr) {
|
||||
const ccrAccounts = await ccrAccountService.getAllAccounts()
|
||||
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts`)
|
||||
|
||||
for (const account of ccrAccounts) {
|
||||
logger.info(
|
||||
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'ccr',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
logger.info(
|
||||
`✅ Added CCR account to available pool: ${account.name} (priority: ${account.priority})`
|
||||
)
|
||||
} else {
|
||||
if (isRateLimited) {
|
||||
logger.warn(`⚠️ CCR account ${account.name} is rate limited`)
|
||||
}
|
||||
if (isQuotaExceeded) {
|
||||
logger.warn(`💰 CCR account ${account.name} quota exceeded`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length})`
|
||||
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})`
|
||||
)
|
||||
return availableAccounts
|
||||
}
|
||||
@@ -439,7 +632,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
async _isAccountAvailable(accountId, accountType, requestedModel = null) {
|
||||
try {
|
||||
if (accountType === 'claude-official') {
|
||||
const account = await redis.getClaudeAccount(accountId)
|
||||
@@ -456,10 +649,34 @@ class UnifiedClaudeScheduler {
|
||||
logger.info(`🚫 Account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await claudeAccountService.isAccountRateLimited(accountId))
|
||||
|
||||
// 检查模型兼容性
|
||||
if (
|
||||
!this._isModelSupportedByAccount(
|
||||
account,
|
||||
'claude-official',
|
||||
requestedModel,
|
||||
'in session check'
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否限流或过载
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
|
||||
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||
return !isRateLimited && !isOverloaded
|
||||
} else if (accountType === 'claude-console') {
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account || !account.isActive || account.status !== 'active') {
|
||||
if (!account || !account.isActive) {
|
||||
return false
|
||||
}
|
||||
// 检查账户状态
|
||||
if (
|
||||
account.status !== 'active' &&
|
||||
account.status !== 'unauthorized' &&
|
||||
account.status !== 'overloaded'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
@@ -467,7 +684,41 @@ class UnifiedClaudeScheduler {
|
||||
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await claudeConsoleAccountService.isAccountRateLimited(accountId))
|
||||
// 检查模型支持
|
||||
if (
|
||||
!this._isModelSupportedByAccount(
|
||||
account,
|
||||
'claude-console',
|
||||
requestedModel,
|
||||
'in session check'
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// 检查是否超额
|
||||
try {
|
||||
await claudeConsoleAccountService.checkQuotaUsage(accountId)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`)
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
|
||||
return false
|
||||
}
|
||||
if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) {
|
||||
return false
|
||||
}
|
||||
// 检查是否未授权(401错误)
|
||||
if (account.status === 'unauthorized') {
|
||||
return false
|
||||
}
|
||||
// 检查是否过载(529错误)
|
||||
if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else if (accountType === 'bedrock') {
|
||||
const accountResult = await bedrockAccountService.getAccount(accountId)
|
||||
if (!accountResult.success || !accountResult.data.isActive) {
|
||||
@@ -480,6 +731,52 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
// Bedrock账户暂不需要限流检查,因为AWS管理限流
|
||||
return true
|
||||
} else if (accountType === 'ccr') {
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account || !account.isActive) {
|
||||
return false
|
||||
}
|
||||
// 检查账户状态
|
||||
if (
|
||||
account.status !== 'active' &&
|
||||
account.status !== 'unauthorized' &&
|
||||
account.status !== 'overloaded'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
|
||||
return false
|
||||
}
|
||||
// 检查是否超额
|
||||
try {
|
||||
await ccrAccountService.checkQuotaUsage(accountId)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to check quota for CCR account ${accountId}: ${e.message}`)
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
if (await ccrAccountService.isAccountRateLimited(accountId)) {
|
||||
return false
|
||||
}
|
||||
if (await ccrAccountService.isAccountQuotaExceeded(accountId)) {
|
||||
return false
|
||||
}
|
||||
// 检查是否未授权(401错误)
|
||||
if (account.status === 'unauthorized') {
|
||||
return false
|
||||
}
|
||||
// 检查是否过载(529错误)
|
||||
if (await ccrAccountService.isAccountOverloaded(accountId)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -509,9 +806,11 @@ class UnifiedClaudeScheduler {
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
|
||||
// 设置1小时过期
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -520,6 +819,50 @@ class UnifiedClaudeScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_claude_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
// -2: key 不存在;-1: 无过期时间
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
|
||||
// 阈值为0则不续期
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(
|
||||
accountId,
|
||||
@@ -536,6 +879,8 @@ class UnifiedClaudeScheduler {
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
await ccrAccountService.markAccountRateLimited(accountId)
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
@@ -560,6 +905,8 @@ class UnifiedClaudeScheduler {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId)
|
||||
} else if (accountType === 'claude-console') {
|
||||
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -579,6 +926,8 @@ class UnifiedClaudeScheduler {
|
||||
return await claudeAccountService.isAccountRateLimited(accountId)
|
||||
} else if (accountType === 'claude-console') {
|
||||
return await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
return await ccrAccountService.isAccountRateLimited(accountId)
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -616,6 +965,32 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为被封锁状态(403错误)
|
||||
async markAccountBlocked(accountId, accountType, sessionHash = null) {
|
||||
try {
|
||||
// 只处理claude-official类型的账户,不处理claude-console和gemini
|
||||
if (accountType === 'claude-official') {
|
||||
await claudeAccountService.markAccountBlocked(accountId, sessionHash)
|
||||
|
||||
// 删除会话映射
|
||||
if (sessionHash) {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
|
||||
logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`)
|
||||
} else {
|
||||
logger.info(
|
||||
`ℹ️ Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})`
|
||||
)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记Claude Console账户为封锁状态(模型不支持)
|
||||
async blockConsoleAccount(accountId, reason) {
|
||||
try {
|
||||
@@ -628,7 +1003,12 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 👥 从分组中选择账户
|
||||
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
|
||||
async selectAccountFromGroup(
|
||||
groupId,
|
||||
sessionHash = null,
|
||||
requestedModel = null,
|
||||
allowCcr = false
|
||||
) {
|
||||
try {
|
||||
// 获取分组信息
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
@@ -645,15 +1025,23 @@ class UnifiedClaudeScheduler {
|
||||
// 验证映射的账户是否属于这个分组
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
if (memberIds.includes(mappedAccount.accountId)) {
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
// 非 CCR 请求时不允许 CCR 粘性映射
|
||||
if (!allowCcr && mappedAccount.accountType === 'ccr') {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
} else {
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
return mappedAccount
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:续期 unified 映射键
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果映射的账户不可用或不在分组中,删除映射
|
||||
@@ -685,6 +1073,14 @@ class UnifiedClaudeScheduler {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||
if (account) {
|
||||
accountType = 'claude-console'
|
||||
} else {
|
||||
// 尝试CCR账户(仅允许在 allowCcr 为 true 时)
|
||||
if (allowCcr) {
|
||||
account = await ccrAccountService.getAccount(memberId)
|
||||
if (account) {
|
||||
accountType = 'ccr'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (group.platform === 'gemini') {
|
||||
@@ -707,22 +1103,14 @@ class UnifiedClaudeScheduler {
|
||||
const status =
|
||||
accountType === 'claude-official'
|
||||
? account.status !== 'error' && account.status !== 'blocked'
|
||||
: account.status === 'active'
|
||||
: accountType === 'ccr'
|
||||
? account.status === 'active'
|
||||
: account.status === 'active'
|
||||
|
||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||
// 检查模型支持(Console账户)
|
||||
if (
|
||||
accountType === 'claude-console' &&
|
||||
requestedModel &&
|
||||
account.supportedModels &&
|
||||
account.supportedModels.length > 0
|
||||
) {
|
||||
if (!account.supportedModels.includes(requestedModel)) {
|
||||
logger.info(
|
||||
`🚫 Account ${account.name} in group does not support model ${requestedModel}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
@@ -774,6 +1162,133 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 专门选择CCR账户(仅限CCR前缀路由使用)
|
||||
async _selectCcrAccount(apiKeyData, sessionHash = null, effectiveModel = null) {
|
||||
try {
|
||||
// 1. 检查会话粘性
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount && mappedAccount.accountType === 'ccr') {
|
||||
// 验证映射的CCR账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
effectiveModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:续期 unified 映射键
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky CCR session account: ${mappedAccount.accountId} for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Mapped CCR account ${mappedAccount.accountId} is no longer available, selecting new account`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取所有可用的CCR账户
|
||||
const availableCcrAccounts = await this._getAvailableCcrAccounts(effectiveModel)
|
||||
|
||||
if (availableCcrAccounts.length === 0) {
|
||||
throw new Error(
|
||||
`No available CCR accounts support the requested model: ${effectiveModel || 'unspecified'}`
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 4. 建立会话映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky CCR session mapping: ${selectedAccount.name} (${selectedAccount.accountId}) for session ${sessionHash}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected CCR account: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
|
||||
)
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to select CCR account:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有可用的CCR账户
|
||||
async _getAvailableCcrAccounts(requestedModel = null) {
|
||||
const availableAccounts = []
|
||||
|
||||
try {
|
||||
const ccrAccounts = await ccrAccountService.getAllAccounts()
|
||||
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts for CCR-only selection`)
|
||||
|
||||
for (const account of ccrAccounts) {
|
||||
logger.debug(
|
||||
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
logger.debug(`CCR account ${account.name} does not support model ${requestedModel}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流或超额
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
const isOverloaded = await ccrAccountService.isAccountOverloaded(account.id)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded && !isOverloaded) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'ccr',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
logger.debug(`✅ Added CCR account to available pool: ${account.name}`)
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ CCR account ${account.name} not available - rateLimited: ${isRateLimited}, quotaExceeded: ${isQuotaExceeded}, overloaded: ${isOverloaded}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`📊 Total available CCR accounts: ${availableAccounts.length}`)
|
||||
return availableAccounts
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get available CCR accounts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler()
|
||||
|
||||
@@ -61,6 +61,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -283,9 +285,11 @@ class UnifiedGeminiScheduler {
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
|
||||
// 设置1小时过期
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -294,6 +298,47 @@ class UnifiedGeminiScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified Gemini session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified Gemini session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified Gemini session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||
try {
|
||||
@@ -382,6 +427,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -32,19 +33,53 @@ class UnifiedOpenAIScheduler {
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
// 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型
|
||||
let boundAccount = null
|
||||
let accountType = 'openai'
|
||||
|
||||
// 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户)
|
||||
if (apiKeyData.openaiAccountId.startsWith('responses:')) {
|
||||
const accountId = apiKeyData.openaiAccountId.replace('responses:', '')
|
||||
boundAccount = await openaiResponsesAccountService.getAccount(accountId)
|
||||
accountType = 'openai-responses'
|
||||
} else {
|
||||
// 普通 OpenAI 账户
|
||||
boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
accountType = 'openai'
|
||||
}
|
||||
|
||||
if (
|
||||
boundAccount &&
|
||||
(boundAccount.isActive === true || boundAccount.isActive === 'true') &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
if (accountType === 'openai') {
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
} else if (
|
||||
accountType === 'openai-responses' &&
|
||||
boundAccount.rateLimitStatus === 'limited'
|
||||
) {
|
||||
// OpenAI-Responses 账户的限流检查
|
||||
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
boundAccount.id
|
||||
)
|
||||
if (!isRateLimitCleared) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查)
|
||||
// OpenAI-Responses 账户默认支持所有模型
|
||||
if (
|
||||
accountType === 'openai' &&
|
||||
requestedModel &&
|
||||
boundAccount.supportedModels &&
|
||||
boundAccount.supportedModels.length > 0
|
||||
@@ -58,13 +93,19 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
|
||||
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await openaiAccountService.recordUsage(apiKeyData.openaiAccountId, 0)
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.recordUsage(boundAccount.id, 0)
|
||||
} else {
|
||||
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
return {
|
||||
accountId: apiKeyData.openaiAccountId,
|
||||
accountType: 'openai'
|
||||
accountId: boundAccount.id,
|
||||
accountType
|
||||
}
|
||||
} else {
|
||||
// 专属账户不可用时直接报错,不降级到共享池
|
||||
@@ -86,6 +127,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -163,22 +206,36 @@ class UnifiedOpenAIScheduler {
|
||||
|
||||
// 获取所有OpenAI账户(共享池)
|
||||
const openaiAccounts = await openaiAccountService.getAllAccounts()
|
||||
for (const account of openaiAccounts) {
|
||||
for (let account of openaiAccounts) {
|
||||
if (
|
||||
account.isActive === 'true' &&
|
||||
account.isActive &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查token是否过期
|
||||
// 检查token是否过期并自动刷新
|
||||
const isExpired = openaiAccountService.isTokenExpired(account)
|
||||
if (isExpired && !account.refreshToken) {
|
||||
logger.warn(
|
||||
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
|
||||
)
|
||||
continue
|
||||
if (isExpired) {
|
||||
if (!account.refreshToken) {
|
||||
logger.warn(
|
||||
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 自动刷新过期的 token
|
||||
try {
|
||||
logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`)
|
||||
await openaiAccountService.refreshAccountToken(account.id)
|
||||
// 重新获取更新后的账户信息
|
||||
account = await openaiAccountService.getAccount(account.id)
|
||||
logger.info(`✅ Token refreshed successfully for ${account.name}`)
|
||||
} catch (refreshError) {
|
||||
logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message)
|
||||
continue // 刷新失败,跳过此账户
|
||||
}
|
||||
}
|
||||
|
||||
// 检查模型支持(仅在明确设置了supportedModels且不为空时才检查)
|
||||
@@ -210,6 +267,40 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有 OpenAI-Responses 账户(共享池)
|
||||
const openaiResponsesAccounts = await openaiResponsesAccountService.getAllAccounts()
|
||||
for (const account of openaiResponsesAccounts) {
|
||||
if (
|
||||
(account.isActive === true || account.isActive === 'true') &&
|
||||
account.status !== 'error' &&
|
||||
account.status !== 'rateLimited' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查并清除过期的限流状态
|
||||
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
|
||||
// 如果仍然处于限流状态,跳过
|
||||
if (account.rateLimitStatus === 'limited' && !isRateLimitCleared) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
|
||||
continue
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账户默认支持所有模型
|
||||
// 因为它们是第三方兼容 API,模型支持由第三方决定
|
||||
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'openai-responses',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
@@ -233,7 +324,7 @@ class UnifiedOpenAIScheduler {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
const account = await openaiAccountService.getAccount(accountId)
|
||||
if (!account || account.isActive !== 'true' || account.status === 'error') {
|
||||
if (!account || !account.isActive || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
@@ -242,6 +333,24 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
return !(await this.isAccountRateLimited(accountId))
|
||||
} else if (accountType === 'openai-responses') {
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
if (
|
||||
!account ||
|
||||
(account.isActive !== true && account.isActive !== 'true') ||
|
||||
account.status === 'error'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
// 检查并清除过期的限流状态
|
||||
const isRateLimitCleared =
|
||||
await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
|
||||
return account.rateLimitStatus !== 'limited' || isRateLimitCleared
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -271,9 +380,11 @@ class UnifiedOpenAIScheduler {
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
|
||||
// 设置1小时过期
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -282,11 +393,64 @@ class UnifiedOpenAIScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_openai_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified OpenAI session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.setAccountRateLimited(accountId, true)
|
||||
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds)
|
||||
} else if (accountType === 'openai-responses') {
|
||||
// 对于 OpenAI-Responses 账户,使用与普通 OpenAI 账户类似的处理方式
|
||||
const duration = resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : null
|
||||
await openaiResponsesAccountService.markAccountRateLimited(accountId, duration)
|
||||
|
||||
// 同时更新调度状态,避免继续被调度
|
||||
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||
schedulable: 'false',
|
||||
rateLimitResetAt: resetsInSeconds
|
||||
? new Date(Date.now() + resetsInSeconds * 1000).toISOString()
|
||||
: new Date(Date.now() + 3600000).toISOString() // 默认1小时
|
||||
})
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
@@ -309,6 +473,17 @@ class UnifiedOpenAIScheduler {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.setAccountRateLimited(accountId, false)
|
||||
} else if (accountType === 'openai-responses') {
|
||||
// 清除 OpenAI-Responses 账户的限流状态
|
||||
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitResetAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true'
|
||||
})
|
||||
logger.info(`✅ Rate limit cleared for OpenAI-Responses account ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -329,12 +504,30 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
// 如果有具体的重置时间,使用它
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetTime = new Date(account.rateLimitResetAt).getTime()
|
||||
const now = Date.now()
|
||||
const isStillLimited = now < resetTime
|
||||
|
||||
return now < limitedAt + limitDuration
|
||||
// 如果已经过了重置时间,自动清除限流状态
|
||||
if (!isStillLimited) {
|
||||
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
|
||||
await openaiAccountService.setAccountRateLimited(accountId, false)
|
||||
return false
|
||||
}
|
||||
|
||||
return isStillLimited
|
||||
}
|
||||
|
||||
// 如果没有具体的重置时间,使用默认的1小时
|
||||
if (account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
return now < limitedAt + limitDuration
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -370,6 +563,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
||||
)
|
||||
@@ -395,7 +590,7 @@ class UnifiedOpenAIScheduler {
|
||||
const account = await openaiAccountService.getAccount(memberId)
|
||||
if (
|
||||
account &&
|
||||
account.isActive === 'true' &&
|
||||
account.isActive &&
|
||||
account.status !== 'error' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
|
||||
593
src/services/userService.js
Normal file
593
src/services/userService.js
Normal file
@@ -0,0 +1,593 @@
|
||||
const redis = require('../models/redis')
|
||||
const crypto = require('crypto')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
|
||||
class UserService {
|
||||
constructor() {
|
||||
this.userPrefix = 'user:'
|
||||
this.usernamePrefix = 'username:'
|
||||
this.userSessionPrefix = 'user_session:'
|
||||
}
|
||||
|
||||
// 🔑 生成用户ID
|
||||
generateUserId() {
|
||||
return crypto.randomBytes(16).toString('hex')
|
||||
}
|
||||
|
||||
// 🔑 生成会话Token
|
||||
generateSessionToken() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
// 👤 创建或更新用户
|
||||
async createOrUpdateUser(userData) {
|
||||
try {
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
role = config.userManagement.defaultUserRole,
|
||||
isActive = true
|
||||
} = userData
|
||||
|
||||
// 检查用户是否已存在
|
||||
let user = await this.getUserByUsername(username)
|
||||
const isNewUser = !user
|
||||
|
||||
if (isNewUser) {
|
||||
const userId = this.generateUserId()
|
||||
user = {
|
||||
id: userId,
|
||||
username,
|
||||
email,
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
isActive,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastLoginAt: null,
|
||||
apiKeyCount: 0,
|
||||
totalUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 更新现有用户信息
|
||||
user = {
|
||||
...user,
|
||||
email,
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户信息
|
||||
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
||||
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
||||
|
||||
// 如果是新用户,尝试转移匹配的API Keys
|
||||
if (isNewUser) {
|
||||
await this.transferMatchingApiKeys(user)
|
||||
}
|
||||
|
||||
logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`)
|
||||
return user
|
||||
} catch (error) {
|
||||
logger.error('❌ Error creating/updating user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 👤 通过用户名获取用户
|
||||
async getUserByUsername(username) {
|
||||
try {
|
||||
const userId = await redis.get(`${this.usernamePrefix}${username}`)
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userData = await redis.get(`${this.userPrefix}${userId}`)
|
||||
return userData ? JSON.parse(userData) : null
|
||||
} catch (error) {
|
||||
logger.error('❌ Error getting user by username:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 👤 通过ID获取用户
|
||||
async getUserById(userId, calculateUsage = true) {
|
||||
try {
|
||||
const userData = await redis.get(`${this.userPrefix}${userId}`)
|
||||
if (!userData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = JSON.parse(userData)
|
||||
|
||||
// Calculate totalUsage by aggregating user's API keys usage (if requested)
|
||||
if (calculateUsage) {
|
||||
try {
|
||||
const usageStats = await this.calculateUserUsageStats(userId)
|
||||
user.totalUsage = usageStats.totalUsage
|
||||
user.apiKeyCount = usageStats.apiKeyCount
|
||||
} catch (error) {
|
||||
logger.error('❌ Error calculating user usage stats:', error)
|
||||
// Fallback to stored values if calculation fails
|
||||
user.totalUsage = user.totalUsage || {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
user.apiKeyCount = user.apiKeyCount || 0
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
logger.error('❌ Error getting user by ID:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 计算用户使用统计(通过聚合API Keys)
|
||||
async calculateUserUsageStats(userId) {
|
||||
try {
|
||||
// Use the existing apiKeyService method which already includes usage stats
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) // Include deleted keys for stats
|
||||
|
||||
const totalUsage = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
for (const apiKey of userApiKeys) {
|
||||
if (apiKey.usage && apiKey.usage.total) {
|
||||
totalUsage.requests += apiKey.usage.total.requests || 0
|
||||
totalUsage.inputTokens += apiKey.usage.total.inputTokens || 0
|
||||
totalUsage.outputTokens += apiKey.usage.total.outputTokens || 0
|
||||
totalUsage.totalCost += apiKey.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys`
|
||||
)
|
||||
|
||||
// Count only non-deleted API keys for the user's active count
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
|
||||
|
||||
return {
|
||||
totalUsage,
|
||||
apiKeyCount: activeApiKeyCount
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error calculating user usage stats:', error)
|
||||
return {
|
||||
totalUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
},
|
||||
apiKeyCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有用户列表(管理员功能)
|
||||
async getAllUsers(options = {}) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const { page = 1, limit = 20, role, isActive } = options
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
const users = []
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
|
||||
// 应用过滤条件
|
||||
if (role && user.role !== role) {
|
||||
continue
|
||||
}
|
||||
if (typeof isActive === 'boolean' && user.isActive !== isActive) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate dynamic usage stats for each user
|
||||
try {
|
||||
const usageStats = await this.calculateUserUsageStats(user.id)
|
||||
user.totalUsage = usageStats.totalUsage
|
||||
user.apiKeyCount = usageStats.apiKeyCount
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error calculating usage for user ${user.id}:`, error)
|
||||
// Fallback to stored values
|
||||
user.totalUsage = user.totalUsage || {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
user.apiKeyCount = user.apiKeyCount || 0
|
||||
}
|
||||
|
||||
users.push(user)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序和分页
|
||||
users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
const startIndex = (page - 1) * limit
|
||||
const endIndex = startIndex + limit
|
||||
const paginatedUsers = users.slice(startIndex, endIndex)
|
||||
|
||||
return {
|
||||
users: paginatedUsers,
|
||||
total: users.length,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(users.length / limit)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error getting all users:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 更新用户状态
|
||||
async updateUserStatus(userId, isActive) {
|
||||
try {
|
||||
const user = await this.getUserById(userId, false) // Skip usage calculation
|
||||
if (!user) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
user.isActive = isActive
|
||||
user.updatedAt = new Date().toISOString()
|
||||
|
||||
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
|
||||
logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`)
|
||||
|
||||
// 如果禁用用户,删除所有会话并禁用其所有API Keys
|
||||
if (!isActive) {
|
||||
await this.invalidateUserSessions(userId)
|
||||
|
||||
// Disable all user's API keys when user is disabled
|
||||
try {
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const result = await apiKeyService.disableUserApiKeys(userId)
|
||||
logger.info(`🔑 Disabled ${result.count} API keys for disabled user: ${user.username}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Error disabling user API keys during user disable:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
logger.error('❌ Error updating user status:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 更新用户角色
|
||||
async updateUserRole(userId, role) {
|
||||
try {
|
||||
const user = await this.getUserById(userId, false) // Skip usage calculation
|
||||
if (!user) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
user.role = role
|
||||
user.updatedAt = new Date().toISOString()
|
||||
|
||||
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
|
||||
logger.info(`🔄 Updated user role: ${user.username} -> ${role}`)
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
logger.error('❌ Error updating user role:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 更新用户API Key数量 (已废弃,现在通过聚合计算)
|
||||
async updateUserApiKeyCount(userId, _count) {
|
||||
// This method is deprecated since apiKeyCount is now calculated dynamically
|
||||
// in getUserById by aggregating the user's API keys
|
||||
logger.debug(
|
||||
`📊 updateUserApiKeyCount called for ${userId} but is now deprecated (count auto-calculated)`
|
||||
)
|
||||
}
|
||||
|
||||
// 📝 记录用户登录
|
||||
async recordUserLogin(userId) {
|
||||
try {
|
||||
const user = await this.getUserById(userId, false) // Skip usage calculation
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
|
||||
} catch (error) {
|
||||
logger.error('❌ Error recording user login:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🎫 创建用户会话
|
||||
async createUserSession(userId, sessionData = {}) {
|
||||
try {
|
||||
const sessionToken = this.generateSessionToken()
|
||||
const session = {
|
||||
token: sessionToken,
|
||||
userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(),
|
||||
...sessionData
|
||||
}
|
||||
|
||||
const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000)
|
||||
await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session))
|
||||
|
||||
logger.info(`🎫 Created session for user: ${userId}`)
|
||||
return sessionToken
|
||||
} catch (error) {
|
||||
logger.error('❌ Error creating user session:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🎫 验证用户会话
|
||||
async validateUserSession(sessionToken) {
|
||||
try {
|
||||
const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`)
|
||||
if (!sessionData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = JSON.parse(sessionData)
|
||||
|
||||
// 检查会话是否过期
|
||||
if (new Date() > new Date(session.expiresAt)) {
|
||||
await this.invalidateUserSession(sessionToken)
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await this.getUserById(session.userId, false) // Skip usage calculation for validation
|
||||
if (!user || !user.isActive) {
|
||||
await this.invalidateUserSession(sessionToken)
|
||||
return null
|
||||
}
|
||||
|
||||
return { session, user }
|
||||
} catch (error) {
|
||||
logger.error('❌ Error validating user session:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 使用户会话失效
|
||||
async invalidateUserSession(sessionToken) {
|
||||
try {
|
||||
await redis.del(`${this.userSessionPrefix}${sessionToken}`)
|
||||
logger.info(`🚫 Invalidated session: ${sessionToken}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Error invalidating user session:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 使用户所有会话失效
|
||||
async invalidateUserSessions(userId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userSessionPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
const sessionData = await client.get(key)
|
||||
if (sessionData) {
|
||||
const session = JSON.parse(sessionData)
|
||||
if (session.userId === userId) {
|
||||
await client.del(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🚫 Invalidated all sessions for user: ${userId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Error invalidating user sessions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除用户(软删除,标记为不活跃)
|
||||
async deleteUser(userId) {
|
||||
try {
|
||||
const user = await this.getUserById(userId, false) // Skip usage calculation
|
||||
if (!user) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
// 软删除:标记为不活跃并添加删除时间戳
|
||||
user.isActive = false
|
||||
user.deletedAt = new Date().toISOString()
|
||||
user.updatedAt = new Date().toISOString()
|
||||
|
||||
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
|
||||
|
||||
// 删除所有会话
|
||||
await this.invalidateUserSessions(userId)
|
||||
|
||||
// Disable all user's API keys when user is deleted
|
||||
try {
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const result = await apiKeyService.disableUserApiKeys(userId)
|
||||
logger.info(`🔑 Disabled ${result.count} API keys for deleted user: ${user.username}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Error disabling user API keys during user deletion:', error)
|
||||
}
|
||||
|
||||
logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`)
|
||||
return user
|
||||
} catch (error) {
|
||||
logger.error('❌ Error deleting user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取用户统计信息
|
||||
async getUserStats() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
const stats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
adminUsers: 0,
|
||||
regularUsers: 0,
|
||||
totalApiKeys: 0,
|
||||
totalUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
stats.totalUsers++
|
||||
|
||||
if (user.isActive) {
|
||||
stats.activeUsers++
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
stats.adminUsers++
|
||||
} else {
|
||||
stats.regularUsers++
|
||||
}
|
||||
|
||||
// Calculate dynamic usage stats for each user
|
||||
try {
|
||||
const usageStats = await this.calculateUserUsageStats(user.id)
|
||||
stats.totalApiKeys += usageStats.apiKeyCount
|
||||
stats.totalUsage.requests += usageStats.totalUsage.requests
|
||||
stats.totalUsage.inputTokens += usageStats.totalUsage.inputTokens
|
||||
stats.totalUsage.outputTokens += usageStats.totalUsage.outputTokens
|
||||
stats.totalUsage.totalCost += usageStats.totalUsage.totalCost
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error calculating usage for user ${user.id} in stats:`, error)
|
||||
// Fallback to stored values if calculation fails
|
||||
stats.totalApiKeys += user.apiKeyCount || 0
|
||||
stats.totalUsage.requests += user.totalUsage?.requests || 0
|
||||
stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0
|
||||
stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0
|
||||
stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
logger.error('❌ Error getting user stats:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 转移匹配的API Keys给新用户
|
||||
async transferMatchingApiKeys(user) {
|
||||
try {
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const { displayName, username, email } = user
|
||||
|
||||
// 获取所有API Keys
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
|
||||
// 找到没有用户ID的API Keys(即由Admin创建的)
|
||||
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
|
||||
|
||||
if (unownedApiKeys.length === 0) {
|
||||
logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值)
|
||||
const matchStrings = new Set()
|
||||
if (displayName) {
|
||||
matchStrings.add(displayName.toLowerCase().trim())
|
||||
}
|
||||
if (username) {
|
||||
matchStrings.add(username.toLowerCase().trim())
|
||||
}
|
||||
if (email) {
|
||||
matchStrings.add(email.toLowerCase().trim())
|
||||
}
|
||||
|
||||
const matchingKeys = []
|
||||
|
||||
// 查找名称匹配的API Keys(只进行完全匹配)
|
||||
for (const apiKey of unownedApiKeys) {
|
||||
const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : ''
|
||||
|
||||
// 检查API Key名称是否与用户信息完全匹配
|
||||
for (const matchString of matchStrings) {
|
||||
if (keyName === matchString) {
|
||||
matchingKeys.push(apiKey)
|
||||
break // 找到匹配后跳出内层循环
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转移匹配的API Keys
|
||||
let transferredCount = 0
|
||||
for (const apiKey of matchingKeys) {
|
||||
try {
|
||||
await apiKeyService.updateApiKey(apiKey.id, {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
createdBy: user.username
|
||||
})
|
||||
|
||||
transferredCount++
|
||||
logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (transferredCount > 0) {
|
||||
logger.success(
|
||||
`🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})`
|
||||
)
|
||||
} else if (matchingKeys.length === 0) {
|
||||
logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error transferring matching API keys:', error)
|
||||
// Don't throw error to prevent blocking user creation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserService()
|
||||
@@ -56,15 +56,27 @@ class WebhookConfigService {
|
||||
|
||||
// 验证平台配置
|
||||
if (config.platforms) {
|
||||
const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
|
||||
const validPlatforms = [
|
||||
'wechat_work',
|
||||
'dingtalk',
|
||||
'feishu',
|
||||
'slack',
|
||||
'discord',
|
||||
'custom',
|
||||
'bark',
|
||||
'smtp'
|
||||
]
|
||||
|
||||
for (const platform of config.platforms) {
|
||||
if (!validPlatforms.includes(platform.type)) {
|
||||
throw new Error(`不支持的平台类型: ${platform.type}`)
|
||||
}
|
||||
|
||||
if (!platform.url || !this.isValidUrl(platform.url)) {
|
||||
throw new Error(`无效的webhook URL: ${platform.url}`)
|
||||
// Bark和SMTP平台不使用标准URL
|
||||
if (platform.type !== 'bark' && platform.type !== 'smtp') {
|
||||
if (!platform.url || !this.isValidUrl(platform.url)) {
|
||||
throw new Error(`无效的webhook URL: ${platform.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证平台特定的配置
|
||||
@@ -108,6 +120,133 @@ class WebhookConfigService {
|
||||
case 'custom':
|
||||
// 自定义webhook,用户自行负责格式
|
||||
break
|
||||
case 'bark':
|
||||
// 验证设备密钥
|
||||
if (!platform.deviceKey) {
|
||||
throw new Error('Bark平台必须提供设备密钥')
|
||||
}
|
||||
|
||||
// 验证设备密钥格式(通常是22-24位字符)
|
||||
if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) {
|
||||
logger.warn('⚠️ Bark设备密钥长度可能不正确,请检查是否完整复制')
|
||||
}
|
||||
|
||||
// 验证服务器URL(如果提供)
|
||||
if (platform.serverUrl) {
|
||||
if (!this.isValidUrl(platform.serverUrl)) {
|
||||
throw new Error('Bark服务器URL格式无效')
|
||||
}
|
||||
if (!platform.serverUrl.includes('/push')) {
|
||||
logger.warn('⚠️ Bark服务器URL应该以/push结尾')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证声音参数(如果提供)
|
||||
if (platform.sound) {
|
||||
const validSounds = [
|
||||
'default',
|
||||
'alarm',
|
||||
'anticipate',
|
||||
'bell',
|
||||
'birdsong',
|
||||
'bloom',
|
||||
'calypso',
|
||||
'chime',
|
||||
'choo',
|
||||
'descent',
|
||||
'electronic',
|
||||
'fanfare',
|
||||
'glass',
|
||||
'gotosleep',
|
||||
'healthnotification',
|
||||
'horn',
|
||||
'ladder',
|
||||
'mailsent',
|
||||
'minuet',
|
||||
'multiwayinvitation',
|
||||
'newmail',
|
||||
'newsflash',
|
||||
'noir',
|
||||
'paymentsuccess',
|
||||
'shake',
|
||||
'sherwoodforest',
|
||||
'silence',
|
||||
'spell',
|
||||
'suspense',
|
||||
'telegraph',
|
||||
'tiptoes',
|
||||
'typewriters',
|
||||
'update',
|
||||
'alert'
|
||||
]
|
||||
if (!validSounds.includes(platform.sound)) {
|
||||
logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证级别参数
|
||||
if (platform.level) {
|
||||
const validLevels = ['active', 'timeSensitive', 'passive', 'critical']
|
||||
if (!validLevels.includes(platform.level)) {
|
||||
throw new Error(`无效的Bark通知级别: ${platform.level}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证图标URL(如果提供)
|
||||
if (platform.icon && !this.isValidUrl(platform.icon)) {
|
||||
logger.warn('⚠️ Bark图标URL格式可能不正确')
|
||||
}
|
||||
|
||||
// 验证点击跳转URL(如果提供)
|
||||
if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) {
|
||||
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
|
||||
}
|
||||
break
|
||||
case 'smtp': {
|
||||
// 验证SMTP必需配置
|
||||
if (!platform.host) {
|
||||
throw new Error('SMTP平台必须提供主机地址')
|
||||
}
|
||||
if (!platform.user) {
|
||||
throw new Error('SMTP平台必须提供用户名')
|
||||
}
|
||||
if (!platform.pass) {
|
||||
throw new Error('SMTP平台必须提供密码')
|
||||
}
|
||||
if (!platform.to) {
|
||||
throw new Error('SMTP平台必须提供接收邮箱')
|
||||
}
|
||||
|
||||
// 验证端口
|
||||
if (platform.port && (platform.port < 1 || platform.port > 65535)) {
|
||||
throw new Error('SMTP端口必须在1-65535之间')
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
// 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name <user@domain.com>
|
||||
const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
// 验证接收邮箱
|
||||
const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to]
|
||||
for (const email of toEmails) {
|
||||
// 提取实际邮箱地址(如果是 Name <email> 格式)
|
||||
const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email
|
||||
if (!actualEmail || !simpleEmailRegex.test(actualEmail)) {
|
||||
throw new Error(`无效的接收邮箱格式: ${email}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证发送邮箱(支持 Name <email> 格式)
|
||||
if (platform.from) {
|
||||
const actualFromEmail = platform.from.includes('<')
|
||||
? platform.from.match(/<([^>]+)>/)?.[1]
|
||||
: platform.from
|
||||
if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) {
|
||||
throw new Error(`无效的发送邮箱格式: ${platform.from}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
src/services/webhookService.js
Normal file → Executable file
295
src/services/webhookService.js
Normal file → Executable file
@@ -1,7 +1,10 @@
|
||||
const axios = require('axios')
|
||||
const crypto = require('crypto')
|
||||
const nodemailer = require('nodemailer')
|
||||
const logger = require('../utils/logger')
|
||||
const webhookConfigService = require('./webhookConfigService')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
const appConfig = require('../../config/config')
|
||||
|
||||
class WebhookService {
|
||||
constructor() {
|
||||
@@ -11,8 +14,11 @@ class WebhookService {
|
||||
feishu: this.sendToFeishu.bind(this),
|
||||
slack: this.sendToSlack.bind(this),
|
||||
discord: this.sendToDiscord.bind(this),
|
||||
custom: this.sendToCustom.bind(this)
|
||||
custom: this.sendToCustom.bind(this),
|
||||
bark: this.sendToBark.bind(this),
|
||||
smtp: this.sendToSMTP.bind(this)
|
||||
}
|
||||
this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,13 +211,85 @@ class WebhookService {
|
||||
const payload = {
|
||||
type,
|
||||
service: 'claude-relay-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: getISOStringWithTimezone(new Date()),
|
||||
data
|
||||
}
|
||||
|
||||
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bark webhook
|
||||
*/
|
||||
async sendToBark(platform, type, data) {
|
||||
const payload = {
|
||||
device_key: platform.deviceKey,
|
||||
title: this.getNotificationTitle(type),
|
||||
body: this.formatMessageForBark(type, data),
|
||||
level: platform.level || this.getBarkLevel(type),
|
||||
sound: platform.sound || this.getBarkSound(type),
|
||||
group: platform.group || 'claude-relay',
|
||||
badge: 1
|
||||
}
|
||||
|
||||
// 添加可选参数
|
||||
if (platform.icon) {
|
||||
payload.icon = platform.icon
|
||||
}
|
||||
|
||||
if (platform.clickUrl) {
|
||||
payload.url = platform.clickUrl
|
||||
}
|
||||
|
||||
const url = platform.serverUrl || 'https://api.day.app/push'
|
||||
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP邮件通知
|
||||
*/
|
||||
async sendToSMTP(platform, type, data) {
|
||||
try {
|
||||
// 创建SMTP传输器
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: platform.host,
|
||||
port: platform.port || 587,
|
||||
secure: platform.secure || false, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: platform.user,
|
||||
pass: platform.pass
|
||||
},
|
||||
// 可选的TLS配置
|
||||
tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined,
|
||||
// 连接超时
|
||||
connectionTimeout: platform.timeout || 10000
|
||||
})
|
||||
|
||||
// 构造邮件内容
|
||||
const subject = this.getNotificationTitle(type)
|
||||
const htmlContent = this.formatMessageForEmail(type, data)
|
||||
const textContent = this.formatMessageForEmailText(type, data)
|
||||
|
||||
// 邮件选项
|
||||
const mailOptions = {
|
||||
from: platform.from || platform.user, // 发送者
|
||||
to: platform.to, // 接收者(必填)
|
||||
subject: `[Claude Relay Service] ${subject}`,
|
||||
text: textContent,
|
||||
html: htmlContent
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
const info = await transporter.sendMail(mailOptions)
|
||||
logger.info(`✅ 邮件发送成功: ${info.messageId}`)
|
||||
|
||||
return info
|
||||
} catch (error) {
|
||||
logger.error('SMTP邮件发送失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
*/
|
||||
@@ -280,11 +358,10 @@ class WebhookService {
|
||||
formatMessageForWechatWork(type, data) {
|
||||
const title = this.getNotificationTitle(type)
|
||||
const details = this.formatNotificationDetails(data)
|
||||
|
||||
return (
|
||||
`## ${title}\n\n` +
|
||||
`> **服务**: Claude Relay Service\n` +
|
||||
`> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
|
||||
`> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,7 +373,7 @@ class WebhookService {
|
||||
|
||||
return (
|
||||
`#### 服务: Claude Relay Service\n` +
|
||||
`#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
|
||||
`#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,7 +406,7 @@ class WebhookService {
|
||||
title,
|
||||
color,
|
||||
fields,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: getISOStringWithTimezone(new Date()),
|
||||
footer: {
|
||||
text: 'Claude Relay Service'
|
||||
}
|
||||
@@ -345,12 +422,205 @@ class WebhookService {
|
||||
quotaWarning: '📊 配额警告',
|
||||
systemError: '❌ 系统错误',
|
||||
securityAlert: '🔒 安全警报',
|
||||
rateLimitRecovery: '🎉 限流恢复通知',
|
||||
test: '🧪 测试通知'
|
||||
}
|
||||
|
||||
return titles[type] || '📢 系统通知'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Bark通知级别
|
||||
*/
|
||||
getBarkLevel(type) {
|
||||
const levels = {
|
||||
accountAnomaly: 'timeSensitive',
|
||||
quotaWarning: 'active',
|
||||
systemError: 'critical',
|
||||
securityAlert: 'critical',
|
||||
rateLimitRecovery: 'active',
|
||||
test: 'passive'
|
||||
}
|
||||
|
||||
return levels[type] || 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Bark声音
|
||||
*/
|
||||
getBarkSound(type) {
|
||||
const sounds = {
|
||||
accountAnomaly: 'alarm',
|
||||
quotaWarning: 'bell',
|
||||
systemError: 'alert',
|
||||
securityAlert: 'alarm',
|
||||
rateLimitRecovery: 'success',
|
||||
test: 'default'
|
||||
}
|
||||
|
||||
return sounds[type] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化Bark消息
|
||||
*/
|
||||
formatMessageForBark(type, data) {
|
||||
const lines = []
|
||||
|
||||
if (data.accountName) {
|
||||
lines.push(`账号: ${data.accountName}`)
|
||||
}
|
||||
|
||||
if (data.platform) {
|
||||
lines.push(`平台: ${data.platform}`)
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
lines.push(`状态: ${data.status}`)
|
||||
}
|
||||
|
||||
if (data.errorCode) {
|
||||
lines.push(`错误: ${data.errorCode}`)
|
||||
}
|
||||
|
||||
if (data.reason) {
|
||||
lines.push(`原因: ${data.reason}`)
|
||||
}
|
||||
|
||||
if (data.message) {
|
||||
lines.push(`消息: ${data.message}`)
|
||||
}
|
||||
|
||||
if (data.quota) {
|
||||
lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`)
|
||||
}
|
||||
|
||||
if (data.usage) {
|
||||
lines.push(`使用率: ${data.usage}%`)
|
||||
}
|
||||
|
||||
// 添加服务标识和时间戳
|
||||
lines.push(`\n服务: Claude Relay Service`)
|
||||
lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建通知详情数据
|
||||
*/
|
||||
buildNotificationDetails(data) {
|
||||
const details = []
|
||||
|
||||
if (data.accountName) {
|
||||
details.push({ label: '账号', value: data.accountName })
|
||||
}
|
||||
if (data.platform) {
|
||||
details.push({ label: '平台', value: data.platform })
|
||||
}
|
||||
if (data.status) {
|
||||
details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) })
|
||||
}
|
||||
if (data.errorCode) {
|
||||
details.push({ label: '错误代码', value: data.errorCode, isCode: true })
|
||||
}
|
||||
if (data.reason) {
|
||||
details.push({ label: '原因', value: data.reason })
|
||||
}
|
||||
if (data.message) {
|
||||
details.push({ label: '消息', value: data.message })
|
||||
}
|
||||
if (data.quota) {
|
||||
details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` })
|
||||
}
|
||||
if (data.usage) {
|
||||
details.push({ label: '使用率', value: `${data.usage}%` })
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化邮件HTML内容
|
||||
*/
|
||||
formatMessageForEmail(type, data) {
|
||||
const title = this.getNotificationTitle(type)
|
||||
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
|
||||
const details = this.buildNotificationDetails(data)
|
||||
|
||||
let content = `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="margin: 0; font-size: 24px;">${title}</h1>
|
||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Claude Relay Service</p>
|
||||
</div>
|
||||
<div style="background: #f8f9fa; padding: 20px; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<div style="background: white; padding: 16px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
`
|
||||
|
||||
// 使用统一的详情数据渲染
|
||||
details.forEach((detail) => {
|
||||
if (detail.isCode) {
|
||||
content += `<p><strong>${detail.label}:</strong> <code style="background: #f1f3f4; padding: 2px 6px; border-radius: 4px;">${detail.value}</code></p>`
|
||||
} else if (detail.color) {
|
||||
content += `<p><strong>${detail.label}:</strong> <span style="color: ${detail.color};">${detail.value}</span></p>`
|
||||
} else {
|
||||
content += `<p><strong>${detail.label}:</strong> ${detail.value}</p>`
|
||||
}
|
||||
})
|
||||
|
||||
content += `
|
||||
</div>
|
||||
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 14px; color: #6c757d; text-align: center;">
|
||||
<p>发送时间: ${timestamp}</p>
|
||||
<p style="margin: 0;">此邮件由 Claude Relay Service 自动发送</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化邮件纯文本内容
|
||||
*/
|
||||
formatMessageForEmailText(type, data) {
|
||||
const title = this.getNotificationTitle(type)
|
||||
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
|
||||
const details = this.buildNotificationDetails(data)
|
||||
|
||||
let content = `${title}\n`
|
||||
content += `=====================================\n\n`
|
||||
|
||||
// 使用统一的详情数据渲染
|
||||
details.forEach((detail) => {
|
||||
content += `${detail.label}: ${detail.value}\n`
|
||||
})
|
||||
|
||||
content += `\n发送时间: ${timestamp}\n`
|
||||
content += `服务: Claude Relay Service\n`
|
||||
content += `=====================================\n`
|
||||
content += `此邮件由系统自动发送,请勿回复。`
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态颜色
|
||||
*/
|
||||
getStatusColor(status) {
|
||||
const colors = {
|
||||
error: '#dc3545',
|
||||
unauthorized: '#fd7e14',
|
||||
blocked: '#6f42c1',
|
||||
disabled: '#6c757d',
|
||||
active: '#28a745',
|
||||
warning: '#ffc107'
|
||||
}
|
||||
return colors[status] || '#007bff'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化通知详情
|
||||
*/
|
||||
@@ -365,6 +635,14 @@ class WebhookService {
|
||||
lines.push(`**平台**: ${data.platform}`)
|
||||
}
|
||||
|
||||
if (data.platforms) {
|
||||
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
|
||||
}
|
||||
|
||||
if (data.totalAccounts) {
|
||||
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
lines.push(`**状态**: ${data.status}`)
|
||||
}
|
||||
@@ -434,6 +712,7 @@ class WebhookService {
|
||||
quotaWarning: 'yellow',
|
||||
systemError: 'red',
|
||||
securityAlert: 'red',
|
||||
rateLimitRecovery: 'green',
|
||||
test: 'blue'
|
||||
}
|
||||
|
||||
@@ -449,6 +728,7 @@ class WebhookService {
|
||||
quotaWarning: ':chart_with_downwards_trend:',
|
||||
systemError: ':x:',
|
||||
securityAlert: ':lock:',
|
||||
rateLimitRecovery: ':tada:',
|
||||
test: ':test_tube:'
|
||||
}
|
||||
|
||||
@@ -464,6 +744,7 @@ class WebhookService {
|
||||
quotaWarning: 0xffeb3b, // 黄色
|
||||
systemError: 0xf44336, // 红色
|
||||
securityAlert: 0xf44336, // 红色
|
||||
rateLimitRecovery: 0x4caf50, // 绿色
|
||||
test: 0x2196f3 // 蓝色
|
||||
}
|
||||
|
||||
@@ -477,7 +758,7 @@ class WebhookService {
|
||||
try {
|
||||
const testData = {
|
||||
message: 'Claude Relay Service webhook测试',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
}
|
||||
|
||||
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
|
||||
|
||||
@@ -32,6 +32,14 @@ const MODEL_PRICING = {
|
||||
cacheRead: 1.5
|
||||
},
|
||||
|
||||
// Claude Opus 4.1 (新模型)
|
||||
'claude-opus-4-1-20250805': {
|
||||
input: 15.0,
|
||||
output: 75.0,
|
||||
cacheWrite: 18.75,
|
||||
cacheRead: 1.5
|
||||
},
|
||||
|
||||
// Claude 3 Sonnet
|
||||
'claude-3-sonnet-20240229': {
|
||||
input: 3.0,
|
||||
@@ -69,9 +77,57 @@ class CostCalculator {
|
||||
* @returns {Object} 费用详情
|
||||
*/
|
||||
static calculateCost(usage, model = 'unknown') {
|
||||
// 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理
|
||||
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
|
||||
return pricingService.calculateCost(usage, model)
|
||||
// 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理
|
||||
if (
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object') ||
|
||||
(model && model.includes('[1m]'))
|
||||
) {
|
||||
const result = pricingService.calculateCost(usage, model)
|
||||
// 转换 pricingService 返回的格式到 costCalculator 的格式
|
||||
return {
|
||||
model,
|
||||
pricing: {
|
||||
input: result.pricing.input * 1000000, // 转换为 per 1M tokens
|
||||
output: result.pricing.output * 1000000,
|
||||
cacheWrite: result.pricing.cacheCreate * 1000000,
|
||||
cacheRead: result.pricing.cacheRead * 1000000
|
||||
},
|
||||
usingDynamicPricing: true,
|
||||
isLongContextRequest: result.isLongContextRequest || false,
|
||||
usage: {
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
||||
totalTokens:
|
||||
(usage.input_tokens || 0) +
|
||||
(usage.output_tokens || 0) +
|
||||
(usage.cache_creation_input_tokens || 0) +
|
||||
(usage.cache_read_input_tokens || 0)
|
||||
},
|
||||
costs: {
|
||||
input: result.inputCost,
|
||||
output: result.outputCost,
|
||||
cacheWrite: result.cacheCreateCost,
|
||||
cacheRead: result.cacheReadCost,
|
||||
total: result.totalCost
|
||||
},
|
||||
formatted: {
|
||||
input: this.formatCost(result.inputCost),
|
||||
output: this.formatCost(result.outputCost),
|
||||
cacheWrite: this.formatCost(result.cacheCreateCost),
|
||||
cacheRead: this.formatCost(result.cacheReadCost),
|
||||
total: this.formatCost(result.totalCost)
|
||||
},
|
||||
debug: {
|
||||
isOpenAIModel: model.includes('gpt') || model.includes('o1'),
|
||||
hasCacheCreatePrice: !!result.pricing.cacheCreate,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
|
||||
cacheWritePriceUsed: result.pricing.cacheCreate * 1000000,
|
||||
isLongContextModel: model && model.includes('[1m]'),
|
||||
isLongContextRequest: result.isLongContextRequest || false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 否则使用旧的逻辑(向后兼容)
|
||||
|
||||
100
src/utils/dateHelper.js
Normal file
100
src/utils/dateHelper.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const config = require('../../config/config')
|
||||
|
||||
/**
|
||||
* 格式化日期时间为指定时区的本地时间字符串
|
||||
* @param {Date|number} date - Date对象或时间戳(秒或毫秒)
|
||||
* @param {boolean} includeTimezone - 是否在输出中包含时区信息
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
function formatDateWithTimezone(date, includeTimezone = true) {
|
||||
// 处理不同类型的输入
|
||||
let dateObj
|
||||
if (typeof date === 'number') {
|
||||
// 判断是秒还是毫秒时间戳
|
||||
// Unix时间戳(秒)通常小于 10^10,毫秒时间戳通常大于 10^12
|
||||
if (date < 10000000000) {
|
||||
dateObj = new Date(date * 1000) // 秒转毫秒
|
||||
} else {
|
||||
dateObj = new Date(date) // 已经是毫秒
|
||||
}
|
||||
} else if (date instanceof Date) {
|
||||
dateObj = date
|
||||
} else {
|
||||
dateObj = new Date(date)
|
||||
}
|
||||
|
||||
// 获取配置的时区偏移(小时)
|
||||
const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8
|
||||
|
||||
// 计算本地时间
|
||||
const offsetMs = timezoneOffset * 3600000 // 转换为毫秒
|
||||
const localTime = new Date(dateObj.getTime() + offsetMs)
|
||||
|
||||
// 格式化为 YYYY-MM-DD HH:mm:ss
|
||||
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')
|
||||
|
||||
let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
|
||||
// 添加时区信息
|
||||
if (includeTimezone) {
|
||||
const sign = timezoneOffset >= 0 ? '+' : ''
|
||||
formattedDate += ` (UTC${sign}${timezoneOffset})`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定时区的ISO格式时间字符串
|
||||
* @param {Date|number} date - Date对象或时间戳
|
||||
* @returns {string} ISO格式的时间字符串
|
||||
*/
|
||||
function getISOStringWithTimezone(date) {
|
||||
// 先获取本地格式的时间(不含时区后缀)
|
||||
const localTimeStr = formatDateWithTimezone(date, false)
|
||||
|
||||
// 获取时区偏移
|
||||
const timezoneOffset = config.system.timezoneOffset || 8
|
||||
|
||||
// 构建ISO格式,添加时区偏移
|
||||
const sign = timezoneOffset >= 0 ? '+' : '-'
|
||||
const absOffset = Math.abs(timezoneOffset)
|
||||
const offsetHours = String(Math.floor(absOffset)).padStart(2, '0')
|
||||
const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0')
|
||||
|
||||
// 将空格替换为T,并添加时区
|
||||
return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算时间差并格式化为人类可读的字符串
|
||||
* @param {number} seconds - 秒数
|
||||
* @returns {string} 格式化的时间差字符串
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}分钟`
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
} else {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDateWithTimezone,
|
||||
getISOStringWithTimezone,
|
||||
formatDuration
|
||||
}
|
||||
291
src/utils/inputValidator.js
Normal file
291
src/utils/inputValidator.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 输入验证工具类
|
||||
* 提供各种输入验证和清理功能,防止注入攻击
|
||||
*/
|
||||
class InputValidator {
|
||||
/**
|
||||
* 验证用户名
|
||||
* @param {string} username - 用户名
|
||||
* @returns {string} 验证后的用户名
|
||||
* @throws {Error} 如果用户名无效
|
||||
*/
|
||||
validateUsername(username) {
|
||||
if (!username || typeof username !== 'string') {
|
||||
throw new Error('用户名必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = username.trim()
|
||||
|
||||
// 长度检查
|
||||
if (trimmed.length < 3 || trimmed.length > 64) {
|
||||
throw new Error('用户名长度必须在3-64个字符之间')
|
||||
}
|
||||
|
||||
// 格式检查:只允许字母、数字、下划线、连字符
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]+$/
|
||||
if (!usernameRegex.test(trimmed)) {
|
||||
throw new Error('用户名只能包含字母、数字、下划线和连字符')
|
||||
}
|
||||
|
||||
// 不能以连字符开头或结尾
|
||||
if (trimmed.startsWith('-') || trimmed.endsWith('-')) {
|
||||
throw new Error('用户名不能以连字符开头或结尾')
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证电子邮件
|
||||
* @param {string} email - 电子邮件地址
|
||||
* @returns {string} 验证后的电子邮件
|
||||
* @throws {Error} 如果电子邮件无效
|
||||
*/
|
||||
validateEmail(email) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('电子邮件必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = email.trim().toLowerCase()
|
||||
|
||||
// 基本格式验证
|
||||
const emailRegex =
|
||||
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
|
||||
if (!emailRegex.test(trimmed)) {
|
||||
throw new Error('电子邮件格式无效')
|
||||
}
|
||||
|
||||
// 长度限制
|
||||
if (trimmed.length > 254) {
|
||||
throw new Error('电子邮件地址过长')
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度
|
||||
* @param {string} password - 密码
|
||||
* @returns {boolean} 验证结果
|
||||
*/
|
||||
validatePassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('密码必须是非空字符串')
|
||||
}
|
||||
|
||||
// 最小长度
|
||||
if (password.length < 8) {
|
||||
throw new Error('密码至少需要8个字符')
|
||||
}
|
||||
|
||||
// 最大长度(防止DoS攻击)
|
||||
if (password.length > 128) {
|
||||
throw new Error('密码不能超过128个字符')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证角色
|
||||
* @param {string} role - 用户角色
|
||||
* @returns {string} 验证后的角色
|
||||
* @throws {Error} 如果角色无效
|
||||
*/
|
||||
validateRole(role) {
|
||||
const validRoles = ['admin', 'user', 'viewer']
|
||||
|
||||
if (!role || typeof role !== 'string') {
|
||||
throw new Error('角色必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = role.trim().toLowerCase()
|
||||
|
||||
if (!validRoles.includes(trimmed)) {
|
||||
throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`)
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Webhook URL
|
||||
* @param {string} url - Webhook URL
|
||||
* @returns {string} 验证后的URL
|
||||
* @throws {Error} 如果URL无效
|
||||
*/
|
||||
validateWebhookUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error('Webhook URL必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = url.trim()
|
||||
|
||||
// URL格式验证
|
||||
try {
|
||||
const urlObj = new URL(trimmed)
|
||||
|
||||
// 只允许HTTP和HTTPS协议
|
||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
||||
throw new Error('Webhook URL必须使用HTTP或HTTPS协议')
|
||||
}
|
||||
|
||||
// 防止SSRF攻击:禁止访问内网地址
|
||||
const hostname = urlObj.hostname.toLowerCase()
|
||||
const dangerousHosts = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'169.254.169.254', // AWS元数据服务
|
||||
'metadata.google.internal' // GCP元数据服务
|
||||
]
|
||||
|
||||
if (dangerousHosts.includes(hostname)) {
|
||||
throw new Error('Webhook URL不能指向内部服务')
|
||||
}
|
||||
|
||||
// 检查是否是内网IP
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
if (ipRegex.test(hostname)) {
|
||||
const parts = hostname.split('.').map(Number)
|
||||
|
||||
// 检查私有IP范围
|
||||
if (
|
||||
parts[0] === 10 || // 10.0.0.0/8
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
|
||||
(parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16
|
||||
) {
|
||||
throw new Error('Webhook URL不能指向私有IP地址')
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
} catch (error) {
|
||||
if (error.message.includes('Webhook URL')) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Webhook URL格式无效')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证显示名称
|
||||
* @param {string} displayName - 显示名称
|
||||
* @returns {string} 验证后的显示名称
|
||||
* @throws {Error} 如果显示名称无效
|
||||
*/
|
||||
validateDisplayName(displayName) {
|
||||
if (!displayName || typeof displayName !== 'string') {
|
||||
throw new Error('显示名称必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = displayName.trim()
|
||||
|
||||
// 长度检查
|
||||
if (trimmed.length < 1 || trimmed.length > 100) {
|
||||
throw new Error('显示名称长度必须在1-100个字符之间')
|
||||
}
|
||||
|
||||
// 禁止特殊控制字符(排除常见的换行和制表符)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
|
||||
if (controlCharRegex.test(trimmed)) {
|
||||
throw new Error('显示名称不能包含控制字符')
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理HTML标签(防止XSS)
|
||||
* @param {string} input - 输入字符串
|
||||
* @returns {string} 清理后的字符串
|
||||
*/
|
||||
sanitizeHtml(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证API Key名称
|
||||
* @param {string} name - API Key名称
|
||||
* @returns {string} 验证后的名称
|
||||
* @throws {Error} 如果名称无效
|
||||
*/
|
||||
validateApiKeyName(name) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('API Key名称必须是非空字符串')
|
||||
}
|
||||
|
||||
const trimmed = name.trim()
|
||||
|
||||
// 长度检查
|
||||
if (trimmed.length < 1 || trimmed.length > 100) {
|
||||
throw new Error('API Key名称长度必须在1-100个字符之间')
|
||||
}
|
||||
|
||||
// 禁止特殊控制字符(排除常见的换行和制表符)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
|
||||
if (controlCharRegex.test(trimmed)) {
|
||||
throw new Error('API Key名称不能包含控制字符')
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分页参数
|
||||
* @param {number} page - 页码
|
||||
* @param {number} limit - 每页数量
|
||||
* @returns {{page: number, limit: number}} 验证后的分页参数
|
||||
*/
|
||||
validatePagination(page, limit) {
|
||||
const pageNum = parseInt(page, 10) || 1
|
||||
const limitNum = parseInt(limit, 10) || 20
|
||||
|
||||
if (pageNum < 1) {
|
||||
throw new Error('页码必须大于0')
|
||||
}
|
||||
|
||||
if (limitNum < 1 || limitNum > 100) {
|
||||
throw new Error('每页数量必须在1-100之间')
|
||||
}
|
||||
|
||||
return {
|
||||
page: pageNum,
|
||||
limit: limitNum
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证UUID格式
|
||||
* @param {string} uuid - UUID字符串
|
||||
* @returns {string} 验证后的UUID
|
||||
* @throws {Error} 如果UUID无效
|
||||
*/
|
||||
validateUuid(uuid) {
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
throw new Error('UUID必须是非空字符串')
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(uuid)) {
|
||||
throw new Error('UUID格式无效')
|
||||
}
|
||||
|
||||
return uuid.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new InputValidator()
|
||||
@@ -1,6 +1,7 @@
|
||||
const winston = require('winston')
|
||||
const DailyRotateFile = require('winston-daily-rotate-file')
|
||||
const config = require('../../config/config')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
@@ -95,7 +96,7 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
// 📝 增强的日志格式
|
||||
const createLogFormat = (colorize = false) => {
|
||||
const formats = [
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
|
||||
winston.format.errors({ stack: true })
|
||||
// 移除 winston.format.metadata() 来避免自动包装
|
||||
]
|
||||
@@ -189,7 +190,7 @@ const securityLogger = winston.createLogger({
|
||||
const authDetailLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
|
||||
winston.format.printf(({ level, message, timestamp, data }) => {
|
||||
// 使用更深的深度和格式化的JSON输出
|
||||
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'
|
||||
|
||||
78
src/utils/modelHelper.js
Normal file
78
src/utils/modelHelper.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Model Helper Utility
|
||||
*
|
||||
* Provides utilities for parsing vendor-prefixed model names.
|
||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse vendor-prefixed model string
|
||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||
* @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model
|
||||
*/
|
||||
function parseVendorPrefixedModel(modelStr) {
|
||||
if (!modelStr || typeof modelStr !== 'string') {
|
||||
return { vendor: null, baseModel: modelStr || '' }
|
||||
}
|
||||
|
||||
// Trim whitespace and convert to lowercase for comparison
|
||||
const trimmed = modelStr.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
|
||||
// Check for ccr prefix (case insensitive)
|
||||
if (lowerTrimmed.startsWith('ccr,')) {
|
||||
const parts = trimmed.split(',')
|
||||
if (parts.length >= 2) {
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: 'ccr',
|
||||
baseModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No recognized vendor prefix found
|
||||
return {
|
||||
vendor: null,
|
||||
baseModel: trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model string has a vendor prefix
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @returns {boolean} - True if the model has a vendor prefix
|
||||
*/
|
||||
function hasVendorPrefix(modelStr) {
|
||||
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||
return vendor !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model name for scheduling and processing
|
||||
* This removes vendor prefixes to get the actual model name used for API calls
|
||||
* @param {string} modelStr - Original model string
|
||||
* @returns {string} - Effective model name without vendor prefix
|
||||
*/
|
||||
function getEffectiveModel(modelStr) {
|
||||
const { baseModel } = parseVendorPrefixedModel(modelStr)
|
||||
return baseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vendor type from a model string
|
||||
* @param {string} modelStr - Model string to parse
|
||||
* @returns {string|null} - Vendor type ('ccr') or null if no prefix
|
||||
*/
|
||||
function getVendorType(modelStr) {
|
||||
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||
return vendor
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const logger = require('./logger')
|
||||
class SessionHelper {
|
||||
/**
|
||||
* 生成会话哈希,用于sticky会话保持
|
||||
* 基于Anthropic的prompt caching机制,优先使用cacheable内容
|
||||
* 基于Anthropic的prompt caching机制,优先使用metadata中的session ID
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {string|null} - 32字符的会话哈希,如果无法生成则返回null
|
||||
*/
|
||||
@@ -13,11 +13,24 @@ class SessionHelper {
|
||||
return null
|
||||
}
|
||||
|
||||
// 1. 最高优先级:使用metadata中的session ID(直接使用,无需hash)
|
||||
if (requestBody.metadata && requestBody.metadata.user_id) {
|
||||
// 提取 session_xxx 部分
|
||||
const userIdString = requestBody.metadata.user_id
|
||||
const sessionMatch = userIdString.match(/session_([a-f0-9-]{36})/)
|
||||
if (sessionMatch && sessionMatch[1]) {
|
||||
const sessionId = sessionMatch[1]
|
||||
// 直接返回session ID
|
||||
logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`)
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
|
||||
let cacheableContent = ''
|
||||
const system = requestBody.system || ''
|
||||
const messages = requestBody.messages || []
|
||||
|
||||
// 1. 优先提取带有cache_control: {"type": "ephemeral"}的内容
|
||||
// 2. 提取带有cache_control: {"type": "ephemeral"}的内容
|
||||
// 检查system中的cacheable内容
|
||||
if (Array.isArray(system)) {
|
||||
for (const part of system) {
|
||||
@@ -30,13 +43,13 @@ class SessionHelper {
|
||||
// 检查messages中的cacheable内容
|
||||
for (const msg of messages) {
|
||||
const content = msg.content || ''
|
||||
let hasCacheControl = false
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part && part.cache_control && part.cache_control.type === 'ephemeral') {
|
||||
if (part.type === 'text') {
|
||||
cacheableContent += part.text || ''
|
||||
}
|
||||
// 其他类型(如image)不参与hash计算
|
||||
hasCacheControl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
@@ -44,12 +57,31 @@ class SessionHelper {
|
||||
msg.cache_control &&
|
||||
msg.cache_control.type === 'ephemeral'
|
||||
) {
|
||||
// 罕见情况,但需要检查
|
||||
cacheableContent += content
|
||||
hasCacheControl = true
|
||||
}
|
||||
|
||||
if (hasCacheControl) {
|
||||
for (const message of messages) {
|
||||
let messageText = ''
|
||||
if (typeof message.content === 'string') {
|
||||
messageText = message.content
|
||||
} else if (Array.isArray(message.content)) {
|
||||
messageText = message.content
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text || '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
if (messageText) {
|
||||
cacheableContent += messageText
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果有cacheable内容,直接使用
|
||||
// 3. 如果有cacheable内容,直接使用
|
||||
if (cacheableContent) {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
@@ -60,7 +92,7 @@ class SessionHelper {
|
||||
return hash
|
||||
}
|
||||
|
||||
// 3. Fallback: 使用system内容
|
||||
// 4. Fallback: 使用system内容
|
||||
if (system) {
|
||||
let systemText = ''
|
||||
if (typeof system === 'string') {
|
||||
@@ -76,7 +108,7 @@ class SessionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 最后fallback: 使用第一条消息内容
|
||||
// 5. 最后fallback: 使用第一条消息内容
|
||||
if (messages.length > 0) {
|
||||
const firstMessage = messages[0]
|
||||
let firstMessageText = ''
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const logger = require('./logger')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const { getISOStringWithTimezone } = require('./dateHelper')
|
||||
|
||||
class WebhookNotifier {
|
||||
constructor() {
|
||||
@@ -28,7 +29,7 @@ class WebhookNotifier {
|
||||
errorCode:
|
||||
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
|
||||
reason: notification.reason,
|
||||
timestamp: notification.timestamp || new Date().toISOString()
|
||||
timestamp: notification.timestamp || getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send account anomaly notification:', error)
|
||||
@@ -57,6 +58,24 @@ class WebhookNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送账号事件通知
|
||||
* @param {string} eventType - 事件类型 (account.created, account.updated, account.deleted, account.status_changed)
|
||||
* @param {Object} data - 事件数据
|
||||
*/
|
||||
async sendAccountEvent(eventType, data) {
|
||||
try {
|
||||
// 使用webhookService发送通知
|
||||
await webhookService.sendNotification('accountEvent', {
|
||||
eventType,
|
||||
...data,
|
||||
timestamp: data.timestamp || getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send account event (${eventType}):`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误代码映射
|
||||
* @param {string} platform - 平台类型
|
||||
@@ -67,6 +86,7 @@ class WebhookNotifier {
|
||||
const errorCodes = {
|
||||
'claude-oauth': {
|
||||
unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||||
blocked: 'CLAUDE_OAUTH_BLOCKED',
|
||||
error: 'CLAUDE_OAUTH_ERROR',
|
||||
disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED'
|
||||
},
|
||||
@@ -79,6 +99,12 @@ class WebhookNotifier {
|
||||
error: 'GEMINI_ERROR',
|
||||
unauthorized: 'GEMINI_UNAUTHORIZED',
|
||||
disabled: 'GEMINI_MANUALLY_DISABLED'
|
||||
},
|
||||
openai: {
|
||||
error: 'OPENAI_ERROR',
|
||||
unauthorized: 'OPENAI_UNAUTHORIZED',
|
||||
blocked: 'OPENAI_RATE_LIMITED',
|
||||
disabled: 'OPENAI_MANUALLY_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ VITE_APP_TITLE=Claude Relay Service - 管理后台
|
||||
# 格式:http://proxy-host:port
|
||||
#VITE_HTTP_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# ========== 教程页面配置 ==========
|
||||
|
||||
# API 基础前缀(可选)
|
||||
# 用于教程页面显示的自定义 API 前缀
|
||||
# 如果不配置,则使用当前浏览器访问地址
|
||||
# 示例:https://api.example.com 或 https://relay.mysite.com
|
||||
# VITE_API_BASE_PREFIX=https://api.example.com
|
||||
|
||||
# ========== 使用说明 ==========
|
||||
# 1. 复制此文件为 .env.local 进行本地配置
|
||||
# 2. .env.local 文件不会被提交到版本控制
|
||||
|
||||
202
web/admin-spa/package-lock.json
generated
202
web/admin-spa/package-lock.json
generated
@@ -15,7 +15,9 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
@@ -1366,6 +1368,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -1643,6 +1654,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1710,6 +1734,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1766,6 +1799,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2304,6 +2349,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
|
||||
@@ -2379,6 +2433,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
|
||||
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -2497,6 +2557,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -3804,6 +3873,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/printj": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
|
||||
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"printj": "bin/printj.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -4083,6 +4164,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -5126,6 +5219,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -5244,6 +5355,95 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
|
||||
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.2.0",
|
||||
"cfb": "^1.1.4",
|
||||
"codepage": "~1.14.0",
|
||||
"commander": "~2.17.1",
|
||||
"crc-32": "~1.2.0",
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"fflate": "^0.3.8",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/adler-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
|
||||
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"printj": "~1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"adler32": "bin/adler32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/codepage": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
|
||||
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "~2.14.1",
|
||||
"exit-on-epipe": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"codepage": "bin/codepage.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
|
||||
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/commander": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
|
||||
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
/* Glass效果 */
|
||||
/* Glass效果 - 优化版 */
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
@@ -216,13 +218,13 @@
|
||||
|
||||
/* 表单输入 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s ease;
|
||||
/* 移除模糊效果,使用纯色背景 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -255,18 +257,18 @@
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
/* 移除模糊,使用半透明背景 */
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
--bg-gradient-mid: #764ba2;
|
||||
--bg-gradient-end: #f093fb;
|
||||
--input-bg: rgba(255, 255, 255, 0.9);
|
||||
--input-border: rgba(255, 255, 255, 0.3);
|
||||
--input-border: rgba(209, 213, 219, 0.8);
|
||||
--modal-bg: rgba(0, 0, 0, 0.4);
|
||||
--table-bg: rgba(255, 255, 255, 0.95);
|
||||
--table-hover: rgba(102, 126, 234, 0.05);
|
||||
@@ -108,13 +108,13 @@ body::before {
|
||||
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -129,13 +129,12 @@ body::before {
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-strong-color);
|
||||
backdrop-filter: blur(25px);
|
||||
-webkit-backdrop-filter: blur(25px);
|
||||
/* 降低模糊强度 */
|
||||
/* 移除模糊效果 */
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -269,8 +268,7 @@ body::before {
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -461,8 +459,7 @@ body::before {
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -511,8 +508,7 @@ body::before {
|
||||
}
|
||||
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
/* 移除模糊效果 */
|
||||
background: var(--modal-bg);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
@@ -522,10 +518,9 @@ body::before {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(229, 231, 235, 0.8);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
/* 移除模糊效果 */
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -730,7 +725,12 @@ body::before {
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
/* 移除无限脉冲动画,改为 hover 效果 */
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.animate-pulse:hover {
|
||||
animation: pulse 0.3s ease;
|
||||
}
|
||||
|
||||
/* 用户菜单下拉框优化 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
451
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
451
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-emerald-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-code-branch text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ isEdit ? '编辑 CCR 账户' : '添加 CCR 账户' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>账户名称 *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>描述 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
placeholder="账户用途说明..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API URL *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.apiUrl"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.apiUrl }"
|
||||
placeholder="例如:https://api.example.com/v1/messages"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500">{{ errors.apiUrl }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key {{ isEdit ? '(留空不更新)' : '*' }}</label
|
||||
>
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.apiKey }"
|
||||
:placeholder="isEdit ? '留空表示不更新' : '必填'"
|
||||
:required="!isEdit"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">{{ errors.apiKey }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>优先级</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
max="100"
|
||||
min="1"
|
||||
placeholder="默认50,数字越小优先级越高"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
建议范围:1-100,数字越小优先级越高
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
placeholder="留空则透传客户端 User-Agent"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限流设置 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>限流机制</label
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="enableRateLimit"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>启用限流机制(429 时暂停调度)</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="enableRateLimit">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>限流时间 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.rateLimitDuration"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="1"
|
||||
placeholder="默认60分钟"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
账号被限流后暂停调度的时间(分钟)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额度管理 -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>每日额度限制 ($)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.dailyQuota"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="0 表示不限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
设置每日使用额度,0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>额度重置时间</label
|
||||
>
|
||||
<input
|
||||
v-model="form.quotaResetTime"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
placeholder="00:00"
|
||||
type="time"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型映射表(可选) -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>模型映射表 (可选)</label
|
||||
>
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="原始模型名称"
|
||||
type="text"
|
||||
/>
|
||||
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="映射后的模型名称"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
type="button"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" /> 添加模型映射
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div>
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="submit"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
{{ loading ? (isEdit ? '保存中...' : '创建中...') : isEdit ? '保存' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const show = ref(true)
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
priority: 50,
|
||||
userAgent: '',
|
||||
rateLimitDuration: 60,
|
||||
dailyQuota: 0,
|
||||
quotaResetTime: '00:00',
|
||||
proxy: null,
|
||||
supportedModels: {}
|
||||
})
|
||||
|
||||
const enableRateLimit = ref(true)
|
||||
const errors = ref({})
|
||||
|
||||
const modelMappings = ref([]) // [{from,to}]
|
||||
|
||||
const buildSupportedModels = () => {
|
||||
const map = {}
|
||||
for (const m of modelMappings.value) {
|
||||
const from = (m.from || '').trim()
|
||||
const to = (m.to || '').trim()
|
||||
if (from && to) map[from] = to
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeModelMapping = (index) => {
|
||||
modelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const e = {}
|
||||
if (!form.value.name || form.value.name.trim().length === 0) e.name = '名称不能为空'
|
||||
if (!form.value.apiUrl || form.value.apiUrl.trim().length === 0) e.apiUrl = 'API URL 不能为空'
|
||||
if (!isEdit.value && (!form.value.apiKey || form.value.apiKey.trim().length === 0))
|
||||
e.apiKey = 'API Key 不能为空'
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!validate()) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
// 更新
|
||||
const updates = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
apiUrl: form.value.apiUrl,
|
||||
priority: form.value.priority,
|
||||
userAgent: form.value.userAgent,
|
||||
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||
quotaResetTime: form.value.quotaResetTime || '00:00',
|
||||
proxy: form.value.proxy || null,
|
||||
supportedModels: buildSupportedModels()
|
||||
}
|
||||
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
||||
updates.apiKey = form.value.apiKey
|
||||
}
|
||||
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
} else {
|
||||
showToast(res.message || '保存失败', 'error')
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
apiUrl: form.value.apiUrl,
|
||||
apiKey: form.value.apiKey,
|
||||
priority: Number(form.value.priority || 50),
|
||||
supportedModels: buildSupportedModels(),
|
||||
userAgent: form.value.userAgent,
|
||||
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||
proxy: form.value.proxy,
|
||||
accountType: 'shared',
|
||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||
quotaResetTime: form.value.quotaResetTime || '00:00'
|
||||
}
|
||||
const res = await apiClient.post('/admin/ccr-accounts', payload)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
} else {
|
||||
showToast(res.message || '创建失败', 'error')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || '请求失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const populateFromAccount = () => {
|
||||
if (!props.account) return
|
||||
const a = props.account
|
||||
form.value.name = a.name || ''
|
||||
form.value.description = a.description || ''
|
||||
form.value.apiUrl = a.apiUrl || ''
|
||||
form.value.priority = Number(a.priority || 50)
|
||||
form.value.userAgent = a.userAgent || ''
|
||||
form.value.rateLimitDuration = Number(a.rateLimitDuration || 60)
|
||||
form.value.dailyQuota = Number(a.dailyQuota || 0)
|
||||
form.value.quotaResetTime = a.quotaResetTime || '00:00'
|
||||
form.value.proxy = a.proxy || null
|
||||
enableRateLimit.value = form.value.rateLimitDuration > 0
|
||||
|
||||
// supportedModels 对象转为数组
|
||||
modelMappings.value = []
|
||||
const mapping = a.supportedModels || {}
|
||||
if (mapping && typeof mapping === 'object') {
|
||||
for (const k of Object.keys(mapping)) {
|
||||
modelMappings.value.push({ from: k, to: mapping[k] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) populateFromAccount()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.account,
|
||||
() => {
|
||||
if (isEdit.value) populateFromAccount()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content {
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,13 +30,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速配置输入框 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
快速配置
|
||||
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(粘贴完整代理URL自动填充)
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxyUrl"
|
||||
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="例如: socks5://username:password@host:port 或 http://host:port"
|
||||
type="text"
|
||||
@input="handleInput"
|
||||
@keyup.enter="parseProxyUrl"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<button
|
||||
v-if="proxyUrl"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
type="button"
|
||||
@click="clearProxyUrl"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="parseError" class="mt-1 text-xs text-red-500">
|
||||
<i class="fas fa-exclamation-circle mr-1" />
|
||||
{{ parseError }}
|
||||
</p>
|
||||
<p v-else-if="parseSuccess" class="mt-1 text-xs text-green-500">
|
||||
<i class="fas fa-check-circle mr-1" />
|
||||
代理配置已自动填充
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>代理类型</label
|
||||
>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
@@ -51,7 +90,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
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="例如: 192.168.1.100"
|
||||
type="text"
|
||||
/>
|
||||
@@ -62,7 +101,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
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="例如: 1080"
|
||||
type="number"
|
||||
/>
|
||||
@@ -92,7 +131,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
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="代理用户名"
|
||||
type="text"
|
||||
/>
|
||||
@@ -104,7 +143,7 @@
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
class="form-input w-full pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="代理密码"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
/>
|
||||
@@ -159,6 +198,11 @@ const proxy = ref({ ...props.modelValue })
|
||||
const showAuth = ref(!!(proxy.value.username || proxy.value.password))
|
||||
const showPassword = ref(false)
|
||||
|
||||
// 快速配置相关
|
||||
const proxyUrl = ref('')
|
||||
const parseError = ref('')
|
||||
const parseSuccess = ref(false)
|
||||
|
||||
// 监听modelValue变化,只在真正需要更新时才更新
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -246,6 +290,122 @@ function emitUpdate() {
|
||||
}, 100) // 100ms 延迟
|
||||
}
|
||||
|
||||
// 解析代理URL
|
||||
function parseProxyUrl() {
|
||||
parseError.value = ''
|
||||
parseSuccess.value = false
|
||||
|
||||
if (!proxyUrl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 移除 # 后面的别名部分
|
||||
const urlWithoutAlias = proxyUrl.value.split('#')[0].trim()
|
||||
|
||||
if (!urlWithoutAlias) {
|
||||
return
|
||||
}
|
||||
|
||||
// 正则表达式匹配代理URL格式
|
||||
// 支持格式:protocol://[username:password@]host:port
|
||||
const proxyPattern = /^(socks5|https?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
|
||||
const match = urlWithoutAlias.match(proxyPattern)
|
||||
|
||||
if (!match) {
|
||||
// 尝试简单格式:host:port(默认为socks5)
|
||||
const simplePattern = /^([^:]+):(\d+)$/
|
||||
const simpleMatch = urlWithoutAlias.match(simplePattern)
|
||||
|
||||
if (simpleMatch) {
|
||||
proxy.value.type = 'socks5'
|
||||
proxy.value.host = simpleMatch[1]
|
||||
proxy.value.port = simpleMatch[2]
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
showAuth.value = false
|
||||
parseSuccess.value = true
|
||||
emitUpdate()
|
||||
|
||||
// 3秒后清除成功提示
|
||||
setTimeout(() => {
|
||||
parseSuccess.value = false
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
parseError.value = '无效的代理URL格式,请检查输入'
|
||||
return
|
||||
}
|
||||
|
||||
// 解析匹配结果
|
||||
const [, protocol, username, password, host, port] = match
|
||||
|
||||
// 填充表单
|
||||
proxy.value.type = protocol.toLowerCase()
|
||||
proxy.value.host = host
|
||||
proxy.value.port = port
|
||||
|
||||
// 处理认证信息
|
||||
if (username && password) {
|
||||
proxy.value.username = decodeURIComponent(username)
|
||||
proxy.value.password = decodeURIComponent(password)
|
||||
showAuth.value = true
|
||||
} else {
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
showAuth.value = false
|
||||
}
|
||||
|
||||
parseSuccess.value = true
|
||||
emitUpdate()
|
||||
|
||||
// 3秒后清除成功提示
|
||||
setTimeout(() => {
|
||||
parseSuccess.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
// 解析代理URL失败
|
||||
parseError.value = '解析失败,请检查URL格式'
|
||||
}
|
||||
}
|
||||
|
||||
// 清空快速配置输入
|
||||
function clearProxyUrl() {
|
||||
proxyUrl.value = ''
|
||||
parseError.value = ''
|
||||
parseSuccess.value = false
|
||||
}
|
||||
|
||||
// 处理粘贴事件
|
||||
function handlePaste() {
|
||||
// 延迟一下以确保v-model已经更新
|
||||
setTimeout(() => {
|
||||
parseProxyUrl()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 处理输入事件
|
||||
function handleInput() {
|
||||
// 检测是否输入了代理URL格式
|
||||
const value = proxyUrl.value.trim()
|
||||
|
||||
// 如果输入包含://,说明可能是完整的代理URL
|
||||
if (value.includes('://')) {
|
||||
// 检查是否看起来像完整的URL(有协议、主机和端口)
|
||||
if (
|
||||
/^(socks5|https?):\/\/[^:]+:\d+/i.test(value) ||
|
||||
/^(socks5|https?):\/\/[^:@]+:[^@]+@[^:]+:\d+/i.test(value)
|
||||
) {
|
||||
parseProxyUrl()
|
||||
}
|
||||
}
|
||||
// 如果是简单的 host:port 格式,并且端口号输入完整
|
||||
else if (/^[^:]+:\d{2,5}$/.test(value)) {
|
||||
parseProxyUrl()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (updateTimer) {
|
||||
|
||||
254
web/admin-spa/src/components/admin/ChangeRoleModal.vue
Normal file
254
web/admin-spa/src/components/admin/ChangeRoleModal.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
|
||||
>
|
||||
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="user" class="space-y-4">
|
||||
<!-- User Info -->
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300">
|
||||
<svg
|
||||
class="h-6 w-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ user.displayName || user.username }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">@{{ user.username }}</p>
|
||||
<div class="mt-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
]"
|
||||
>
|
||||
Current: {{ user.role }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Selection -->
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="selectedRole"
|
||||
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
:disabled="loading"
|
||||
type="radio"
|
||||
value="user"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">User</div>
|
||||
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="selectedRole"
|
||||
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
:disabled="loading"
|
||||
type="radio"
|
||||
value="admin"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">Administrator</div>
|
||||
<div class="text-xs text-gray-500">Full access to manage users and system</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning for role changes -->
|
||||
<div
|
||||
v-if="selectedRole !== user.role"
|
||||
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p v-if="selectedRole === 'admin'">
|
||||
Granting admin privileges will give this user full access to the system,
|
||||
including the ability to manage other users and their API keys.
|
||||
</p>
|
||||
<p v-else>
|
||||
Removing admin privileges will restrict this user to only managing their own
|
||||
API keys and viewing their own usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-700">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="loading || selectedRole === user.role"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center">
|
||||
<svg
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
Updating...
|
||||
</span>
|
||||
<span v-else>Update Role</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const selectedRole = ref('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!props.user || selectedRole.value === props.user.role) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
|
||||
role: selectedRole.value
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast(`User role updated to ${selectedRole.value}`, 'success')
|
||||
emit('updated')
|
||||
} else {
|
||||
error.value = response.message || 'Failed to update user role'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update user role error:', err)
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to update user role'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form when modal is shown
|
||||
watch([() => props.show, () => props.user], ([show, user]) => {
|
||||
if (show && user) {
|
||||
selectedRole.value = user.role
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
428
web/admin-spa/src/components/admin/UserUsageStatsModal.vue
Normal file
428
web/admin-spa/src/components/admin/UserUsageStatsModal.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
|
||||
>
|
||||
<div class="relative top-10 mx-auto w-4/5 max-w-4xl rounded-md border bg-white p-5 shadow-lg">
|
||||
<div class="mt-3">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Usage Statistics - {{ user?.displayName || user?.username }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">@{{ user?.username }} • {{ user?.role }}</p>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Period Selector -->
|
||||
<div class="mb-6">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-8 w-8 animate-spin text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Content -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="overflow-hidden rounded-lg bg-blue-50 shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
|
||||
<dd class="text-lg font-medium text-blue-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-green-50 shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-green-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-purple-50 shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-purple-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-yellow-50 shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-yellow-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User API Keys Table -->
|
||||
<div
|
||||
v-if="userDetails?.apiKeys?.length > 0"
|
||||
class="rounded-lg border border-gray-200 bg-white"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Last Used
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="apiKey in userDetails.apiKeys" :key="apiKey.id">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
apiKey.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
|
||||
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Placeholder -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Daily usage trends for {{ selectedPeriod }} period
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-if="usageStats && usageStats.totalRequests === 0" class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
This user hasn't made any API requests in the selected period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const loading = ref(false)
|
||||
const selectedPeriod = ref('week')
|
||||
const usageStats = ref(null)
|
||||
const userDetails = ref(null)
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsageStats = async () => {
|
||||
if (!props.user) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [statsResponse, userResponse] = await Promise.all([
|
||||
apiClient.get(`/users/${props.user.id}/usage-stats`, {
|
||||
params: { period: selectedPeriod.value }
|
||||
}),
|
||||
apiClient.get(`/users/${props.user.id}`)
|
||||
])
|
||||
|
||||
if (statsResponse.success) {
|
||||
usageStats.value = statsResponse.stats
|
||||
}
|
||||
|
||||
if (userResponse.success) {
|
||||
userDetails.value = userResponse.user
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user usage stats:', error)
|
||||
showToast('Failed to load usage statistics', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for when modal is shown and user changes
|
||||
watch([() => props.show, () => props.user], ([show, user]) => {
|
||||
if (show && user) {
|
||||
loadUsageStats()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
@@ -127,7 +127,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
@@ -166,7 +166,7 @@
|
||||
</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
@@ -179,7 +179,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
@@ -188,12 +188,14 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>Token 限制</label
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="不修改"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -208,7 +210,7 @@
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
@@ -216,6 +218,24 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Opus 模型周费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Opus 模型周费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 并发限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -223,7 +243,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
type="number"
|
||||
@@ -310,7 +330,7 @@
|
||||
>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
@@ -345,7 +365,7 @@
|
||||
>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
@@ -376,7 +396,7 @@
|
||||
>
|
||||
<select
|
||||
v-model="form.openaiAccountId"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
@@ -407,7 +427,7 @@
|
||||
>
|
||||
<select
|
||||
v-model="form.bedrockAccountId"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
@@ -496,11 +516,12 @@ const unselectedTags = computed(() => {
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
tokenLimit: '',
|
||||
rateLimitCost: '', // 费用限制替代token限制
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
weeklyOpusCostLimit: '', // 新增Opus周费用限制
|
||||
permissions: '', // 空字符串表示不修改
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
@@ -616,8 +637,8 @@ const batchUpdateApiKeys = async () => {
|
||||
const updates = {}
|
||||
|
||||
// 只有非空值才添加到更新对象中
|
||||
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
|
||||
updates.tokenLimit = parseInt(form.tokenLimit)
|
||||
if (form.rateLimitCost !== '' && form.rateLimitCost !== null) {
|
||||
updates.rateLimitCost = parseFloat(form.rateLimitCost)
|
||||
}
|
||||
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
|
||||
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
|
||||
@@ -631,6 +652,9 @@ const batchUpdateApiKeys = async () => {
|
||||
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
|
||||
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
|
||||
}
|
||||
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
|
||||
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
|
||||
}
|
||||
|
||||
// 权限设置
|
||||
if (form.permissions !== '') {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="form.batchCount"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
max="500"
|
||||
min="2"
|
||||
placeholder="输入数量 (2-500)"
|
||||
@@ -110,19 +110,21 @@
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
||||
>名称 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
@@ -184,7 +186,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
@@ -228,7 +230,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
@@ -242,7 +244,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
@@ -252,17 +254,17 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>Token 限制</label
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
窗口内最大Token
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,12 +277,9 @@
|
||||
<div>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>示例2:</strong> 时间窗口=1,Token=10000 → 每分钟最多10,000个Token
|
||||
</div>
|
||||
<div>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,Token=100000 →
|
||||
每30分钟50次请求且不超10万Token
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,7 +323,7 @@
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
@@ -336,13 +335,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '100'"
|
||||
>
|
||||
$100
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '500'"
|
||||
>
|
||||
$500
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '1000'"
|
||||
>
|
||||
$1000
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
@@ -358,7 +406,7 @@
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input w-full resize-none text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full resize-none border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="描述此 API Key 的用途..."
|
||||
rows="2"
|
||||
/>
|
||||
@@ -366,34 +414,103 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>有效期限</label
|
||||
>过期设置</label
|
||||
>
|
||||
<select
|
||||
v-model="form.expireDuration"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@change="updateExpireAt"
|
||||
<!-- 过期模式选择 -->
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">永不过期</option>
|
||||
<option value="1d">1 天</option>
|
||||
<option value="7d">7 天</option>
|
||||
<option value="30d">30 天</option>
|
||||
<option value="90d">90 天</option>
|
||||
<option value="180d">180 天</option>
|
||||
<option value="365d">365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
</select>
|
||||
<div v-if="form.expireDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:min="minDateTime"
|
||||
type="datetime-local"
|
||||
@change="updateCustomExpireAt"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.expirationMode"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="fixed"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.expirationMode"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="activation"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="form.expirationMode === 'fixed'">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
固定时间模式:Key 创建后立即生效,按设定时间过期
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 固定时间模式 -->
|
||||
<div v-if="form.expirationMode === 'fixed'">
|
||||
<select
|
||||
v-model="form.expireDuration"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@change="updateExpireAt"
|
||||
>
|
||||
<option value="">永不过期</option>
|
||||
<option value="1d">1 天</option>
|
||||
<option value="7d">7 天</option>
|
||||
<option value="30d">30 天</option>
|
||||
<option value="90d">90 天</option>
|
||||
<option value="180d">180 天</option>
|
||||
<option value="365d">365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
</select>
|
||||
<div v-if="form.expireDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:min="minDateTime"
|
||||
type="datetime-local"
|
||||
@change="updateCustomExpireAt"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 激活模式 -->
|
||||
<div v-else>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="form.activationDays"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
max="3650"
|
||||
min="1"
|
||||
placeholder="输入天数"
|
||||
type="number"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">天</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="days in [30, 90, 180, 365]"
|
||||
:key="days"
|
||||
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
@click="form.activationDays = days"
|
||||
>
|
||||
{{ days }}天
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -739,14 +856,17 @@ const form = reactive({
|
||||
batchCount: 10,
|
||||
name: '',
|
||||
description: '',
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
rateLimitCost: '', // 新增:费用限制
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
weeklyOpusCostLimit: '',
|
||||
expireDuration: '',
|
||||
customExpireDate: '',
|
||||
expiresAt: null,
|
||||
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
||||
activationDays: 30, // 激活后有效天数
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
@@ -766,31 +886,61 @@ onMounted(async () => {
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
})
|
||||
|
||||
// 刷新账号列表
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -824,13 +974,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -985,14 +1153,32 @@ const createApiKey = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否设置了时间窗口但费用限制为0
|
||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续创建',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const baseData = {
|
||||
description: form.description || undefined,
|
||||
tokenLimit:
|
||||
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
|
||||
tokenLimit: 0, // 设置为0,清除历史token限制
|
||||
rateLimitWindow:
|
||||
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
|
||||
? parseInt(form.rateLimitWindow)
|
||||
@@ -1001,6 +1187,10 @@ const createApiKey = async () => {
|
||||
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
|
||||
? parseInt(form.rateLimitRequests)
|
||||
: null,
|
||||
rateLimitCost:
|
||||
form.rateLimitCost !== '' && form.rateLimitCost !== null
|
||||
? parseFloat(form.rateLimitCost)
|
||||
: null,
|
||||
concurrencyLimit:
|
||||
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
|
||||
? parseInt(form.concurrencyLimit)
|
||||
@@ -1009,7 +1199,13 @@ const createApiKey = async () => {
|
||||
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
|
||||
? parseFloat(form.dailyCostLimit)
|
||||
: 0,
|
||||
expiresAt: form.expiresAt || undefined,
|
||||
weeklyOpusCostLimit:
|
||||
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
||||
? parseFloat(form.weeklyOpusCostLimit)
|
||||
: 0,
|
||||
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
|
||||
expirationMode: form.expirationMode,
|
||||
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
|
||||
permissions: form.permissions,
|
||||
tags: form.tags.length > 0 ? form.tags : undefined,
|
||||
enableModelRestriction: form.enableModelRestriction,
|
||||
|
||||
@@ -32,13 +32,39 @@
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>名称</label
|
||||
>
|
||||
<input
|
||||
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
disabled
|
||||
type="text"
|
||||
:value="form.name"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">名称不可修改</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
placeholder="请输入API Key名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
用于识别此 API Key 的用途
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 所有者选择 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>所有者</label
|
||||
>
|
||||
<select
|
||||
v-model="form.ownerId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||
{{ user.displayName }} ({{ user.username }})
|
||||
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
分配此 API Key 给指定用户或管理员,管理员分配时不受用户 API Key 数量限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
@@ -98,7 +124,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
@@ -142,7 +168,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
@@ -156,7 +182,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
@@ -166,17 +192,17 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>Token 限制</label
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
窗口内最大Token
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,12 +215,9 @@
|
||||
<div>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>示例2:</strong> 时间窗口=1,Token=10000 → 每分钟最多10,000个Token
|
||||
</div>
|
||||
<div>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,Token=100000 →
|
||||
每30分钟50次请求且不超10万Token
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +261,7 @@
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
@@ -250,13 +273,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '100'"
|
||||
>
|
||||
$100
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '500'"
|
||||
>
|
||||
$500
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = '1000'"
|
||||
>
|
||||
$1000
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
@@ -488,7 +560,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
@@ -620,6 +692,9 @@ const localAccounts = ref({
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
|
||||
// 可用用户列表
|
||||
const availableUsers = ref([])
|
||||
|
||||
// 标签相关
|
||||
const newTag = ref('')
|
||||
const availableTags = ref([])
|
||||
@@ -632,11 +707,13 @@ const unselectedTags = computed(() => {
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
tokenLimit: '', // 保留用于检测历史数据
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
rateLimitCost: '', // 新增:费用限制
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
weeklyOpusCostLimit: '',
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
@@ -648,7 +725,8 @@ const form = reactive({
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
tags: [],
|
||||
isActive: true
|
||||
isActive: true,
|
||||
ownerId: '' // 新增:所有者ID
|
||||
})
|
||||
|
||||
// 添加限制的模型
|
||||
@@ -702,13 +780,32 @@ const removeTag = (index) => {
|
||||
|
||||
// 更新 API Key
|
||||
const updateApiKey = async () => {
|
||||
// 检查是否设置了时间窗口但费用限制为0
|
||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续保存',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
tokenLimit:
|
||||
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
|
||||
name: form.name, // 添加名称字段
|
||||
tokenLimit: 0, // 清除历史token限制
|
||||
rateLimitWindow:
|
||||
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
|
||||
? parseInt(form.rateLimitWindow)
|
||||
@@ -717,6 +814,10 @@ const updateApiKey = async () => {
|
||||
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
|
||||
? parseInt(form.rateLimitRequests)
|
||||
: 0,
|
||||
rateLimitCost:
|
||||
form.rateLimitCost !== '' && form.rateLimitCost !== null
|
||||
? parseFloat(form.rateLimitCost)
|
||||
: 0,
|
||||
concurrencyLimit:
|
||||
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
|
||||
? parseInt(form.concurrencyLimit)
|
||||
@@ -725,6 +826,10 @@ const updateApiKey = async () => {
|
||||
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
|
||||
? parseFloat(form.dailyCostLimit)
|
||||
: 0,
|
||||
weeklyOpusCostLimit:
|
||||
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
||||
? parseFloat(form.weeklyOpusCostLimit)
|
||||
: 0,
|
||||
permissions: form.permissions,
|
||||
tags: form.tags
|
||||
}
|
||||
@@ -782,6 +887,11 @@ const updateApiKey = async () => {
|
||||
// 活跃状态
|
||||
data.isActive = form.isActive
|
||||
|
||||
// 所有者
|
||||
if (form.ownerId !== undefined) {
|
||||
data.ownerId = form.ownerId
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
@@ -801,15 +911,23 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -843,13 +961,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -873,18 +1009,71 @@ const refreshAccounts = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/users')
|
||||
if (response.success) {
|
||||
availableUsers.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('Failed to load users:', error)
|
||||
availableUsers.value = [
|
||||
{
|
||||
id: 'admin',
|
||||
username: 'admin',
|
||||
displayName: 'Admin',
|
||||
email: '',
|
||||
role: 'admin'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化表单数据
|
||||
onMounted(async () => {
|
||||
// 加载支持的客户端和已存在的标签
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
try {
|
||||
// 并行加载所有需要的数据
|
||||
const [clients, tags] = await Promise.all([
|
||||
clientsStore.loadSupportedClients(),
|
||||
apiKeysStore.fetchTags(),
|
||||
loadUsers()
|
||||
])
|
||||
|
||||
supportedClients.value = clients || []
|
||||
availableTags.value = tags || []
|
||||
} catch (error) {
|
||||
// console.error('Error loading initial data:', error)
|
||||
// Fallback to empty arrays if loading fails
|
||||
supportedClients.value = []
|
||||
availableTags.value = []
|
||||
}
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
@@ -892,12 +1081,26 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
|
||||
// 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户
|
||||
form.tokenLimit = props.apiKey.tokenLimit || ''
|
||||
form.rateLimitCost = props.apiKey.rateLimitCost || ''
|
||||
|
||||
// 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置
|
||||
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
|
||||
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
|
||||
// console.log('检测到历史Token限制,请考虑设置费用限制')
|
||||
}
|
||||
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
|
||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||
if (props.apiKey.claudeConsoleAccountId) {
|
||||
@@ -906,7 +1109,10 @@ onMounted(async () => {
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
|
||||
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||
|
||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
@@ -916,6 +1122,9 @@ onMounted(async () => {
|
||||
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
|
||||
// 初始化活跃状态,默认为 true
|
||||
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
|
||||
|
||||
// 初始化所有者
|
||||
form.ownerId = props.apiKey.userId || 'admin'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -39,11 +39,18 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
当前过期时间
|
||||
</p>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p>
|
||||
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
<template v-if="apiKey.expiresAt">
|
||||
<!-- 未激活状态 -->
|
||||
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
|
||||
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
||||
未激活
|
||||
<span class="ml-2 text-xs font-normal text-gray-600">
|
||||
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</span>
|
||||
</template>
|
||||
<!-- 已设置过期时间 -->
|
||||
<template v-else-if="apiKey.expiresAt">
|
||||
{{ formatExpireDate(apiKey.expiresAt) }}
|
||||
<span
|
||||
v-if="getExpiryStatus(apiKey.expiresAt)"
|
||||
@@ -53,6 +60,7 @@
|
||||
({{ getExpiryStatus(apiKey.expiresAt).text }})
|
||||
</span>
|
||||
</template>
|
||||
<!-- 永不过期 -->
|
||||
<template v-else>
|
||||
<i class="fas fa-infinity mr-1 text-gray-500" />
|
||||
永不过期
|
||||
@@ -74,6 +82,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 激活按钮(仅在未激活状态显示) -->
|
||||
<div v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated" class="mb-4">
|
||||
<button
|
||||
class="w-full rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-3 font-semibold text-white transition-all hover:from-blue-600 hover:to-blue-700 hover:shadow-lg"
|
||||
@click="handleActivateNow"
|
||||
>
|
||||
<i class="fas fa-rocket mr-2" />
|
||||
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 快捷选项 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -115,7 +138,7 @@
|
||||
>
|
||||
<input
|
||||
v-model="localForm.customExpireDate"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:min="minDateTime"
|
||||
type="datetime-local"
|
||||
@change="updateCustomExpiryPreview"
|
||||
@@ -370,6 +393,35 @@ const handleSave = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 立即激活
|
||||
const handleActivateNow = async () => {
|
||||
// 使用确认弹窗
|
||||
let confirmed = true
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'激活 API Key',
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
|
||||
'确定激活',
|
||||
'取消'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
|
||||
)
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
emit('save', {
|
||||
keyId: props.apiKey.id,
|
||||
activateNow: true
|
||||
})
|
||||
}
|
||||
|
||||
// 重置保存状态
|
||||
const resetSaving = () => {
|
||||
saving.value = false
|
||||
|
||||
94
web/admin-spa/src/components/apikeys/LimitBadge.vue
Normal file
94
web/admin-spa/src/components/apikeys/LimitBadge.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-1.5 rounded-md px-2 py-1" :class="badgeClass">
|
||||
<div class="flex items-center gap-1">
|
||||
<i :class="['text-xs', iconClass]" />
|
||||
<span class="text-xs font-medium">{{ label }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs font-semibold">${{ current.toFixed(2) }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">/</span>
|
||||
<span class="text-xs">${{ limit.toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 小型进度条 -->
|
||||
<div class="h-1 w-12 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all duration-300"
|
||||
:class="progressClass"
|
||||
:style="{ width: progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['daily', 'opus', 'window'].includes(value)
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!props.limit || props.limit === 0) return 0
|
||||
const percentage = (props.current / props.limit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'bg-gray-50 dark:bg-gray-700/50'
|
||||
case 'opus':
|
||||
return 'bg-indigo-50 dark:bg-indigo-900/20'
|
||||
case 'window':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20'
|
||||
default:
|
||||
return 'bg-gray-50 dark:bg-gray-700/50'
|
||||
}
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'fas fa-calendar-day text-gray-500'
|
||||
case 'opus':
|
||||
return 'fas fa-gem text-indigo-500'
|
||||
case 'window':
|
||||
return 'fas fa-clock text-blue-500'
|
||||
default:
|
||||
return 'fas fa-info-circle text-gray-500'
|
||||
}
|
||||
})
|
||||
|
||||
const progressClass = computed(() => {
|
||||
const p = progress.value
|
||||
if (p >= 100) return 'bg-red-500'
|
||||
if (p >= 80) return 'bg-yellow-500'
|
||||
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'bg-green-500'
|
||||
case 'opus':
|
||||
return 'bg-indigo-500'
|
||||
case 'window':
|
||||
return 'bg-blue-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
258
web/admin-spa/src/components/apikeys/LimitProgressBar.vue
Normal file
258
web/admin-spa/src/components/apikeys/LimitProgressBar.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative h-8 w-full overflow-hidden rounded-lg shadow-sm" :class="containerClass">
|
||||
<!-- 背景层 -->
|
||||
<div class="absolute inset-0" :class="backgroundClass"></div>
|
||||
|
||||
<!-- 进度条层 -->
|
||||
<div
|
||||
class="absolute inset-0 h-full transition-all duration-500 ease-out"
|
||||
:class="progressBarClass"
|
||||
:style="{ width: progress + '%' }"
|
||||
></div>
|
||||
|
||||
<!-- 文字层 - 使用双层文字技术确保可读性 -->
|
||||
<div class="relative z-10 flex h-full items-center justify-between px-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i :class="['text-xs', iconClass]" />
|
||||
<span class="text-xs font-semibold" :class="labelTextClass">{{ label }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-bold tabular-nums" :class="currentValueClass">
|
||||
${{ current.toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-xs font-medium" :class="limitTextClass">
|
||||
/ ${{ limit.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 闪光效果(可选) -->
|
||||
<div
|
||||
v-if="showShine && progress > 0"
|
||||
class="absolute inset-0 opacity-20"
|
||||
:style="{
|
||||
background:
|
||||
'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.5) 50%, transparent 60%)',
|
||||
animation: 'shine 3s infinite'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['daily', 'opus', 'window'].includes(value)
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
showShine: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!props.limit || props.limit === 0) return 0
|
||||
const percentage = (props.current / props.limit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
})
|
||||
|
||||
// 容器样式 - 使用更柔和的边框和阴影
|
||||
const containerClass = computed(() => {
|
||||
return 'border border-gray-200/80 dark:border-gray-600/50 shadow-sm'
|
||||
})
|
||||
|
||||
// 背景样式 - 使用更浅的背景色提升对比度
|
||||
const backgroundClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'bg-gray-100/50 dark:bg-gray-800/30'
|
||||
case 'opus':
|
||||
return 'bg-violet-50/50 dark:bg-violet-950/20'
|
||||
case 'window':
|
||||
return 'bg-sky-50/50 dark:bg-sky-950/20'
|
||||
default:
|
||||
return 'bg-gray-100/50 dark:bg-gray-800/30'
|
||||
}
|
||||
})
|
||||
|
||||
// 进度条样式 - 使用更柔和的颜色配置
|
||||
const progressBarClass = computed(() => {
|
||||
const p = progress.value
|
||||
|
||||
if (props.type === 'daily') {
|
||||
if (p >= 90) {
|
||||
return 'bg-red-400 dark:bg-red-500'
|
||||
} else if (p >= 70) {
|
||||
return 'bg-amber-400 dark:bg-amber-500'
|
||||
} else {
|
||||
return 'bg-emerald-400 dark:bg-emerald-500'
|
||||
}
|
||||
}
|
||||
|
||||
if (props.type === 'opus') {
|
||||
if (p >= 90) {
|
||||
return 'bg-red-400 dark:bg-red-500'
|
||||
} else if (p >= 70) {
|
||||
return 'bg-amber-400 dark:bg-amber-500'
|
||||
} else {
|
||||
return 'bg-violet-400 dark:bg-violet-500'
|
||||
}
|
||||
}
|
||||
|
||||
if (props.type === 'window') {
|
||||
if (p >= 90) {
|
||||
return 'bg-red-400 dark:bg-red-500'
|
||||
} else if (p >= 70) {
|
||||
return 'bg-amber-400 dark:bg-amber-500'
|
||||
} else {
|
||||
return 'bg-sky-400 dark:bg-sky-500'
|
||||
}
|
||||
}
|
||||
|
||||
return 'bg-gray-300 dark:bg-gray-400'
|
||||
})
|
||||
|
||||
// 图标类
|
||||
const iconClass = computed(() => {
|
||||
const p = progress.value
|
||||
|
||||
// 根据进度选择图标颜色
|
||||
let colorClass = ''
|
||||
if (p >= 90) {
|
||||
colorClass = 'text-red-700 dark:text-red-400'
|
||||
} else if (p >= 70) {
|
||||
colorClass = 'text-orange-700 dark:text-orange-400'
|
||||
} else {
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
colorClass = 'text-green-700 dark:text-green-400'
|
||||
break
|
||||
case 'opus':
|
||||
colorClass = 'text-purple-700 dark:text-purple-400'
|
||||
break
|
||||
case 'window':
|
||||
colorClass = 'text-blue-700 dark:text-blue-400'
|
||||
break
|
||||
default:
|
||||
colorClass = 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
let iconName = ''
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
iconName = 'fas fa-calendar-day'
|
||||
break
|
||||
case 'opus':
|
||||
iconName = 'fas fa-gem'
|
||||
break
|
||||
case 'window':
|
||||
iconName = 'fas fa-clock'
|
||||
break
|
||||
default:
|
||||
iconName = 'fas fa-infinity'
|
||||
}
|
||||
|
||||
return `${iconName} ${colorClass}`
|
||||
})
|
||||
|
||||
// 标签文字颜色 - 始终保持高对比度
|
||||
const labelTextClass = computed(() => {
|
||||
const p = progress.value
|
||||
|
||||
// 根据进度条背景色智能选择文字颜色
|
||||
if (p > 40) {
|
||||
// 当进度条覆盖超过40%时,使用白色文字
|
||||
return 'text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
|
||||
} else {
|
||||
// 在浅色背景上使用深色文字
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'text-gray-900 dark:text-gray-100'
|
||||
case 'opus':
|
||||
return 'text-purple-900 dark:text-purple-100'
|
||||
case 'window':
|
||||
return 'text-blue-900 dark:text-blue-100'
|
||||
default:
|
||||
return 'text-gray-900 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 当前值文字颜色 - 最重要的数字,需要最高对比度
|
||||
const currentValueClass = computed(() => {
|
||||
const p = progress.value
|
||||
|
||||
// 判断数值是否在进度条上
|
||||
if (p > 70) {
|
||||
// 在彩色进度条上,使用白色+强阴影
|
||||
return 'text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
||||
} else {
|
||||
// 在浅色背景上,根据进度状态选择颜色
|
||||
if (p >= 90) {
|
||||
return 'text-red-700 dark:text-red-300'
|
||||
} else if (p >= 70) {
|
||||
return 'text-orange-700 dark:text-orange-300'
|
||||
} else {
|
||||
switch (props.type) {
|
||||
case 'daily':
|
||||
return 'text-green-800 dark:text-green-200'
|
||||
case 'opus':
|
||||
return 'text-purple-800 dark:text-purple-200'
|
||||
case 'window':
|
||||
return 'text-blue-800 dark:text-blue-200'
|
||||
default:
|
||||
return 'text-gray-900 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 限制值文字颜色
|
||||
const limitTextClass = computed(() => {
|
||||
const p = progress.value
|
||||
|
||||
// 判断限制值是否在进度条上
|
||||
if (p > 85) {
|
||||
// 在进度条上
|
||||
return 'text-white/90 drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
|
||||
} else {
|
||||
// 在背景上
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shine {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保文字清晰 */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -167,7 +167,7 @@ const copyApiKey = async () => {
|
||||
await navigator.clipboard.writeText(key)
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
// console.error('Failed to copy:', error)
|
||||
// 降级方案:创建一个临时文本区域
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = key
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<!-- 模态框 -->
|
||||
<div
|
||||
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
|
||||
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-5xl flex-col p-4 sm:w-full sm:p-6 md:p-8"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
@@ -196,6 +196,8 @@
|
||||
时间窗口限制
|
||||
</h5>
|
||||
<WindowCountdown
|
||||
:cost-limit="apiKey.rateLimitCost"
|
||||
:current-cost="apiKey.currentWindowCost"
|
||||
:current-requests="apiKey.currentWindowRequests"
|
||||
:current-tokens="apiKey.currentWindowTokens"
|
||||
label="窗口状态"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token限制(向后兼容) -->
|
||||
<div v-if="hasTokenLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">Token</span>
|
||||
@@ -48,6 +49,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用限制(新功能) -->
|
||||
<div v-if="hasCostLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">费用</span>
|
||||
<span class="text-gray-600">
|
||||
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all duration-300"
|
||||
:class="getCostProgressColor()"
|
||||
:style="{ width: getCostProgress() + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外提示信息 -->
|
||||
@@ -102,6 +120,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentCost: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
costLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showProgress: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
@@ -132,6 +158,7 @@ const windowState = computed(() => {
|
||||
|
||||
const hasRequestLimit = computed(() => props.requestLimit > 0)
|
||||
const hasTokenLimit = computed(() => props.tokenLimit > 0)
|
||||
const hasCostLimit = computed(() => props.costLimit > 0)
|
||||
|
||||
// 方法
|
||||
const formatTime = (seconds) => {
|
||||
@@ -196,6 +223,19 @@ const getTokenProgressColor = () => {
|
||||
return 'bg-purple-500'
|
||||
}
|
||||
|
||||
const getCostProgress = () => {
|
||||
if (!props.costLimit || props.costLimit === 0) return 0
|
||||
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
}
|
||||
|
||||
const getCostProgressColor = () => {
|
||||
const progress = getCostProgress()
|
||||
if (progress >= 100) return 'bg-red-500'
|
||||
if (progress >= 80) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// 更新倒计时
|
||||
const updateCountdown = () => {
|
||||
if (props.windowEndTime && remainingSeconds.value > 0) {
|
||||
|
||||
241
web/admin-spa/src/components/apikeys/WindowLimitBar.vue
Normal file
241
web/admin-spa/src/components/apikeys/WindowLimitBar.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="w-full space-y-1">
|
||||
<!-- 时间窗口进度条 -->
|
||||
<div
|
||||
class="relative h-7 w-full overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-blue-50 to-cyan-100 dark:from-blue-950/30 dark:to-cyan-900/30"
|
||||
>
|
||||
<!-- 时间进度条背景 -->
|
||||
<div
|
||||
class="absolute inset-0 h-full bg-gradient-to-r from-blue-500 to-cyan-500 opacity-20 transition-all duration-1000"
|
||||
:style="{ width: timeProgress + '%' }"
|
||||
></div>
|
||||
|
||||
<!-- 文字层 -->
|
||||
<div class="relative z-10 flex h-full items-center justify-between px-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-clock text-xs text-blue-600 dark:text-blue-400" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ rateLimitWindow }}分钟窗口
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-bold"
|
||||
:class="
|
||||
remainingSeconds > 0
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
"
|
||||
>
|
||||
{{ remainingSeconds > 0 ? formatTime(remainingSeconds) : '未激活' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用和请求限制(如果有的话) -->
|
||||
<div v-if="costLimit > 0 || requestLimit > 0" class="flex gap-1">
|
||||
<!-- 费用限制进度条 -->
|
||||
<div
|
||||
v-if="costLimit > 0"
|
||||
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-green-50 to-emerald-100 dark:from-green-950/30 dark:to-emerald-900/30"
|
||||
:class="requestLimit > 0 ? 'w-1/2' : 'w-full'"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="absolute inset-0 h-full transition-all duration-500 ease-out"
|
||||
:class="getCostProgressBarClass()"
|
||||
:style="{ width: costProgress + '%' }"
|
||||
></div>
|
||||
|
||||
<!-- 文字 -->
|
||||
<div class="relative z-10 flex h-full items-center justify-between px-2">
|
||||
<span class="text-[10px] font-medium" :class="getCostTextClass()">费用</span>
|
||||
<span class="text-[10px] font-bold" :class="getCostValueTextClass()">
|
||||
${{ currentCost.toFixed(1) }}/${{ costLimit.toFixed(0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求限制进度条 -->
|
||||
<div
|
||||
v-if="requestLimit > 0"
|
||||
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-purple-50 to-indigo-100 dark:from-purple-950/30 dark:to-indigo-900/30"
|
||||
:class="costLimit > 0 ? 'w-1/2' : 'w-full'"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="absolute inset-0 h-full transition-all duration-500 ease-out"
|
||||
:class="getRequestProgressBarClass()"
|
||||
:style="{ width: requestProgress + '%' }"
|
||||
></div>
|
||||
|
||||
<!-- 文字 -->
|
||||
<div class="relative z-10 flex h-full items-center justify-between px-2">
|
||||
<span class="text-[10px] font-medium" :class="getRequestTextClass()">请求</span>
|
||||
<span class="text-[10px] font-bold" :class="getRequestValueTextClass()">
|
||||
{{ currentRequests }}/{{ requestLimit }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rateLimitWindow: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
remainingSeconds: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentRequests: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
requestLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentCost: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
costLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentTokens: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tokenLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 费用进度
|
||||
const costProgress = computed(() => {
|
||||
if (!props.costLimit || props.costLimit === 0) return 0
|
||||
const percentage = (props.currentCost / props.costLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
})
|
||||
|
||||
// 请求进度
|
||||
const requestProgress = computed(() => {
|
||||
if (!props.requestLimit || props.requestLimit === 0) return 0
|
||||
const percentage = (props.currentRequests / props.requestLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
})
|
||||
|
||||
// 时间进度(倒计时)
|
||||
const timeProgress = computed(() => {
|
||||
if (!props.rateLimitWindow || props.rateLimitWindow === 0) return 0
|
||||
const totalSeconds = props.rateLimitWindow * 60
|
||||
const elapsed = totalSeconds - props.remainingSeconds
|
||||
return Math.max(0, (elapsed / totalSeconds) * 100)
|
||||
})
|
||||
|
||||
// 费用进度条颜色
|
||||
const getCostProgressBarClass = () => {
|
||||
const p = costProgress.value
|
||||
if (p >= 90) {
|
||||
return 'bg-gradient-to-r from-red-500 to-rose-600'
|
||||
} else if (p >= 70) {
|
||||
return 'bg-gradient-to-r from-orange-500 to-amber-500'
|
||||
} else {
|
||||
return 'bg-gradient-to-r from-green-500 to-emerald-500'
|
||||
}
|
||||
}
|
||||
|
||||
// 请求进度条颜色
|
||||
const getRequestProgressBarClass = () => {
|
||||
const p = requestProgress.value
|
||||
if (p >= 90) {
|
||||
return 'bg-gradient-to-r from-red-500 to-pink-600'
|
||||
} else if (p >= 70) {
|
||||
return 'bg-gradient-to-r from-orange-500 to-yellow-500'
|
||||
} else {
|
||||
return 'bg-gradient-to-r from-purple-500 to-indigo-600'
|
||||
}
|
||||
}
|
||||
|
||||
// 费用文字颜色
|
||||
const getCostTextClass = () => {
|
||||
const p = costProgress.value
|
||||
if (p > 50) {
|
||||
return 'text-white drop-shadow-sm'
|
||||
} else {
|
||||
return 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getCostValueTextClass = () => {
|
||||
const p = costProgress.value
|
||||
if (p > 50) {
|
||||
return 'text-white drop-shadow-md'
|
||||
} else {
|
||||
return 'text-gray-800 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
// 请求文字颜色
|
||||
const getRequestTextClass = () => {
|
||||
const p = requestProgress.value
|
||||
if (p > 50) {
|
||||
return 'text-white drop-shadow-sm'
|
||||
} else {
|
||||
return 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getRequestValueTextClass = () => {
|
||||
const p = requestProgress.value
|
||||
if (p > 50) {
|
||||
return 'text-white drop-shadow-md'
|
||||
} else {
|
||||
return 'text-gray-800 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds) => {
|
||||
if (seconds === null || seconds === undefined) return '--:--'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes}m`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化Token数 - 暂时未使用
|
||||
// const formatTokens = (count) => {
|
||||
// if (count >= 1000000) {
|
||||
// return (count / 1000000).toFixed(1) + 'M'
|
||||
// } else if (count >= 1000) {
|
||||
// return (count / 1000).toFixed(1) + 'K'
|
||||
// }
|
||||
// return count.toString()
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border-opacity-20 {
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .border-opacity-20 {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
202
web/admin-spa/src/components/apistats/AggregatedStatsCard.vue
Normal file
202
web/admin-spa/src/components/apistats/AggregatedStatsCard.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="card h-full p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
|
||||
使用占比
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
|
||||
<div v-if="aggregatedStats && individualStats.length > 0" class="space-y-2 md:space-y-3">
|
||||
<!-- 各Key使用占比列表 -->
|
||||
<div v-for="(stat, index) in topKeys" :key="stat.apiId" class="relative">
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="truncate font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ stat.name || `Key ${index + 1}` }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ calculatePercentage(stat) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="getProgressColor(index)"
|
||||
:style="{ width: calculatePercentage(stat) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}次</span>
|
||||
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他Keys汇总 -->
|
||||
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>其他 {{ otherKeysCount }} 个Keys</span>
|
||||
<span>{{ otherPercentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单个Key模式提示 -->
|
||||
<div
|
||||
v-else-if="!multiKeyMode"
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<div class="text-center">
|
||||
<i class="fas fa-chart-pie mb-2 text-2xl" />
|
||||
<p>使用占比仅在多Key查询时显示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-chart-pie mr-2" />
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 获取当前时间段的使用数据
|
||||
const getStatUsage = (stat) => {
|
||||
if (!stat) return null
|
||||
|
||||
if (statsPeriod.value === 'daily') {
|
||||
return stat.dailyUsage || stat.usage
|
||||
} else {
|
||||
return stat.monthlyUsage || stat.usage
|
||||
}
|
||||
}
|
||||
|
||||
// 获取TOP Keys(最多显示5个)
|
||||
const topKeys = computed(() => {
|
||||
if (!individualStats.value || individualStats.value.length === 0) return []
|
||||
|
||||
return [...individualStats.value]
|
||||
.sort((a, b) => {
|
||||
const aUsage = getStatUsage(a)
|
||||
const bUsage = getStatUsage(b)
|
||||
return (bUsage?.cost || 0) - (aUsage?.cost || 0)
|
||||
})
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
// 计算其他Keys数量
|
||||
const otherKeysCount = computed(() => {
|
||||
if (!individualStats.value) return 0
|
||||
return Math.max(0, individualStats.value.length - 5)
|
||||
})
|
||||
|
||||
// 计算其他Keys的占比
|
||||
const otherPercentage = computed(() => {
|
||||
if (!individualStats.value || !aggregatedStats.value) return 0
|
||||
|
||||
const topKeysCost = topKeys.value.reduce((sum, stat) => {
|
||||
const usage = getStatUsage(stat)
|
||||
return sum + (usage?.cost || 0)
|
||||
}, 0)
|
||||
const totalCost =
|
||||
statsPeriod.value === 'daily'
|
||||
? aggregatedStats.value.dailyUsage?.cost || 0
|
||||
: aggregatedStats.value.monthlyUsage?.cost || 0
|
||||
|
||||
if (totalCost === 0) return 0
|
||||
const otherCost = totalCost - topKeysCost
|
||||
return Math.max(0, Math.round((otherCost / totalCost) * 100))
|
||||
})
|
||||
|
||||
// 计算单个Key的百分比
|
||||
const calculatePercentage = (stat) => {
|
||||
if (!aggregatedStats.value) return 0
|
||||
|
||||
const totalCost =
|
||||
statsPeriod.value === 'daily'
|
||||
? aggregatedStats.value.dailyUsage?.cost || 0
|
||||
: aggregatedStats.value.monthlyUsage?.cost || 0
|
||||
|
||||
if (totalCost === 0) return 0
|
||||
const usage = getStatUsage(stat)
|
||||
const percentage = ((usage?.cost || 0) / totalCost) * 100
|
||||
return Math.round(percentage)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
const getProgressColor = (index) => {
|
||||
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-yellow-500', 'bg-pink-500']
|
||||
return colors[index] || 'bg-gray-400'
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 - 使用CSS变量 */
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .card:hover {
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.5),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,64 @@
|
||||
<template>
|
||||
<div class="api-input-wide-card mb-8 rounded-3xl p-6 shadow-xl">
|
||||
<!-- 标题区域 -->
|
||||
<div class="wide-card-title mb-6 text-center">
|
||||
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<div class="wide-card-title mb-6">
|
||||
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200">
|
||||
<i class="fas fa-chart-line mr-3" />
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600 dark:text-gray-300">查询您的 API Key 使用情况和统计数据</p>
|
||||
<p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
|
||||
<!-- 控制栏 -->
|
||||
<div class="control-bar mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- API Key 标签 -->
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-key mr-2" />
|
||||
{{ multiKeyMode ? '输入您的 API Keys(每行一个或用逗号分隔)' : '输入您的 API Key' }}
|
||||
</label>
|
||||
|
||||
<!-- 模式切换和查询按钮组 -->
|
||||
<div class="button-group flex items-center gap-2">
|
||||
<!-- 模式切换 -->
|
||||
<div
|
||||
class="mode-switch-group flex items-center rounded-lg bg-gray-100 p-1 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: !multiKeyMode }"
|
||||
title="单一模式"
|
||||
@click="multiKeyMode = false"
|
||||
>
|
||||
<i class="fas fa-key" />
|
||||
<span class="ml-2 hidden sm:inline">单一</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: multiKeyMode }"
|
||||
title="聚合模式"
|
||||
@click="multiKeyMode = true"
|
||||
>
|
||||
<i class="fas fa-layer-group" />
|
||||
<span class="ml-2 hidden sm:inline">聚合</span>
|
||||
<span
|
||||
v-if="multiKeyMode && parsedApiKeys.length > 0"
|
||||
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold"
|
||||
>
|
||||
{{ parsedApiKeys.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-input-grid grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<!-- API Key 输入 -->
|
||||
<div class="lg:col-span-3">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<i class="fas fa-key mr-2" />
|
||||
输入您的 API Key
|
||||
</label>
|
||||
<!-- 单 Key 模式输入框 -->
|
||||
<input
|
||||
v-if="!multiKeyMode"
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full"
|
||||
:disabled="loading"
|
||||
@@ -26,16 +66,33 @@
|
||||
type="password"
|
||||
@keyup.enter="queryStats"
|
||||
/>
|
||||
|
||||
<!-- 多 Key 模式输入框 -->
|
||||
<div v-else class="relative">
|
||||
<textarea
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full resize-y"
|
||||
:disabled="loading"
|
||||
placeholder="请输入您的 API Keys,支持以下格式: cr_xxx cr_yyy 或 cr_xxx, cr_yyy"
|
||||
rows="4"
|
||||
@keyup.ctrl.enter="queryStats"
|
||||
/>
|
||||
<button
|
||||
v-if="apiKey && !loading"
|
||||
class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
title="清空输入"
|
||||
@click="clearInput"
|
||||
>
|
||||
<i class="fas fa-times-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="mb-2 hidden text-sm font-medium text-gray-700 dark:text-gray-200 lg:block">
|
||||
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
|
||||
:disabled="loading || !apiKey.trim()"
|
||||
:disabled="loading || !hasValidInput"
|
||||
@click="queryStats"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner" />
|
||||
@@ -48,19 +105,56 @@
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2" />
|
||||
您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途
|
||||
{{
|
||||
multiKeyMode
|
||||
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
|
||||
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- 多 Key 模式额外提示 -->
|
||||
<div
|
||||
v-if="multiKeyMode"
|
||||
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-lightbulb mr-2" />
|
||||
<span>提示:最多支持同时查询 30 个 API Keys。使用 Ctrl+Enter 快速查询。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { apiKey, loading } = storeToRefs(apiStatsStore)
|
||||
const { queryStats } = apiStatsStore
|
||||
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
const { queryStats, clearInput } = apiStatsStore
|
||||
|
||||
// 解析输入的 API Keys
|
||||
const parsedApiKeys = computed(() => {
|
||||
if (!multiKeyMode.value || !apiKey.value) return []
|
||||
|
||||
// 支持逗号和换行符分隔
|
||||
const keys = apiKey.value
|
||||
.split(/[,\n]+/)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key.length > 0)
|
||||
|
||||
// 去重并限制最多30个
|
||||
const uniqueKeys = [...new Set(keys)]
|
||||
return uniqueKeys.slice(0, 30)
|
||||
})
|
||||
|
||||
// 判断是否有有效输入
|
||||
const hasValidInput = computed(() => {
|
||||
if (multiKeyMode.value) {
|
||||
return parsedApiKeys.value.length > 0
|
||||
}
|
||||
return apiKey.value && apiKey.value.trim().length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -101,7 +195,6 @@ const { queryStats } = apiStatsStore
|
||||
|
||||
/* 标题样式 */
|
||||
.wide-card-title h2 {
|
||||
color: #1f2937;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -112,12 +205,12 @@ const { queryStats } = apiStatsStore
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
color: #4b5563;
|
||||
color: #6b7280;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .wide-card-title p {
|
||||
color: #d1d5db;
|
||||
color: #9ca3af;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@@ -251,6 +344,93 @@ const { queryStats } = apiStatsStore
|
||||
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* 控制栏 */
|
||||
.control-bar {
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(229, 231, 235, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark) .control-bar {
|
||||
border-bottom-color: rgba(75, 85, 99, 0.3);
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 模式切换组 */
|
||||
.mode-switch-group {
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .mode-switch-group {
|
||||
background: #1f2937;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 模式切换按钮 */
|
||||
.mode-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .mode-switch-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.mode-switch-btn:hover:not(.active) {
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .mode-switch-btn:hover:not(.active) {
|
||||
color: #d1d5db;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mode-switch-btn.active {
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.mode-switch-btn.active:hover {
|
||||
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.mode-switch-btn i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 淡入淡出动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
@@ -267,6 +447,18 @@ const { queryStats } = apiStatsStore
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.control-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1.25rem;
|
||||
@@ -304,6 +496,22 @@ const { queryStats } = apiStatsStore
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mode-toggle-btn {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,14 +1,108 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 限制配置 -->
|
||||
<!-- 限制配置 / 聚合模式提示 -->
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
||||
限制配置
|
||||
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
||||
</h3>
|
||||
<div class="space-y-4 md:space-y-5">
|
||||
|
||||
<!-- 多 Key 模式下的聚合统计信息 -->
|
||||
<div v-if="multiKeyMode && aggregatedStats" class="space-y-4">
|
||||
<!-- API Keys 概况 -->
|
||||
<div
|
||||
class="rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:from-blue-900/20 dark:to-indigo-900/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-layer-group mr-2 text-blue-500" />
|
||||
API Keys 概况
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-800 dark:text-blue-200"
|
||||
>
|
||||
{{ aggregatedStats.activeKeys }}/{{ aggregatedStats.totalKeys }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.totalKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-bold text-green-600">
|
||||
{{ aggregatedStats.activeKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聚合统计数据 -->
|
||||
<div
|
||||
class="rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 p-4 dark:from-purple-900/20 dark:to-pink-900/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-purple-500" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-database mr-1 text-gray-400" />
|
||||
总请求数
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-coins mr-1 text-yellow-500" />
|
||||
总 Tokens
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-dollar-sign mr-1 text-green-500" />
|
||||
总费用
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无效 Keys 提示 -->
|
||||
<div
|
||||
v-if="invalidKeys && invalidKeys.length > 0"
|
||||
class="rounded-lg bg-red-50 p-3 text-sm dark:bg-red-900/20"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
|
||||
<span class="text-red-700 dark:text-red-300">
|
||||
{{ invalidKeys.length }} 个无效的 API Key
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
每个 API Key 有独立的限制设置,聚合模式下不显示单个限制配置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 仅在单 Key 模式下显示限制配置 -->
|
||||
<div v-if="!multiKeyMode" class="space-y-4 md:space-y-5">
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -45,10 +139,14 @@
|
||||
<div
|
||||
v-if="
|
||||
statsData.limits.rateLimitWindow > 0 &&
|
||||
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
|
||||
(statsData.limits.rateLimitRequests > 0 ||
|
||||
statsData.limits.tokenLimit > 0 ||
|
||||
statsData.limits.rateLimitCost > 0)
|
||||
"
|
||||
>
|
||||
<WindowCountdown
|
||||
:cost-limit="statsData.limits.rateLimitCost"
|
||||
:current-cost="statsData.limits.currentWindowCost"
|
||||
:current-requests="statsData.limits.currentWindowRequests"
|
||||
:current-tokens="statsData.limits.currentWindowTokens"
|
||||
label="时间窗口限制"
|
||||
@@ -64,7 +162,13 @@
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
|
||||
<span v-if="statsData.limits.rateLimitCost > 0">
|
||||
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else-if="statsData.limits.tokenLimit > 0">
|
||||
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else> 仅限制请求次数 </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +315,7 @@ import { useApiStatsStore } from '@/stores/apistats'
|
||||
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData } = storeToRefs(apiStatsStore)
|
||||
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 获取每日费用进度
|
||||
const getDailyCostProgress = () => {
|
||||
@@ -229,6 +333,24 @@ const getDailyCostProgressColor = () => {
|
||||
if (progress >= 80) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,14 +1,83 @@
|
||||
<template>
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
||||
<!-- API Key 基本信息 -->
|
||||
<!-- API Key 基本信息 / 批量查询概要 -->
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
|
||||
API Key 信息
|
||||
<i
|
||||
class="mr-2 text-sm md:mr-3 md:text-base"
|
||||
:class="
|
||||
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
|
||||
"
|
||||
/>
|
||||
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
|
||||
</h3>
|
||||
<div class="space-y-2 md:space-y-3">
|
||||
|
||||
<!-- 多 Key 模式下的概要信息 -->
|
||||
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ aggregatedStats.totalKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-green-600 md:text-base">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ aggregatedStats.activeKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-red-600 md:text-base">
|
||||
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
|
||||
{{ invalidKeys.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总 Token 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
|
||||
<span class="text-sm font-medium text-indigo-600 md:text-base">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 各 Key 贡献占比(可选) -->
|
||||
<div
|
||||
v-if="individualStats.length > 1"
|
||||
class="border-t border-gray-200 pt-2 dark:border-gray-700"
|
||||
>
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">各 Key 贡献占比</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="stat in topContributors"
|
||||
:key="stat.apiId"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span class="truncate text-gray-600 dark:text-gray-400">{{ stat.name }}</span>
|
||||
<span class="text-gray-900 dark:text-gray-100"
|
||||
>{{ calculateContribution(stat) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单 Key 模式下的详细信息 -->
|
||||
<div v-else class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
|
||||
<span
|
||||
@@ -46,7 +115,19 @@
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>过期时间</span
|
||||
>
|
||||
<div v-if="statsData.expiresAt" class="text-right">
|
||||
<!-- 未激活状态 -->
|
||||
<div
|
||||
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
||||
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||
未激活
|
||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
||||
>
|
||||
</div>
|
||||
<!-- 已设置过期时间 -->
|
||||
<div v-else-if="statsData.expiresAt" class="text-right">
|
||||
<div
|
||||
v-if="isApiKeyExpired(statsData.expiresAt)"
|
||||
class="text-sm font-medium text-red-600 md:text-base"
|
||||
@@ -68,6 +149,7 @@
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 永不过期 -->
|
||||
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||
永不过期
|
||||
@@ -128,12 +210,38 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
const {
|
||||
statsData,
|
||||
statsPeriod,
|
||||
currentPeriodData,
|
||||
multiKeyMode,
|
||||
aggregatedStats,
|
||||
individualStats,
|
||||
invalidKeys
|
||||
} = storeToRefs(apiStatsStore)
|
||||
|
||||
// 计算前3个贡献最大的 Key
|
||||
const topContributors = computed(() => {
|
||||
if (!individualStats.value || individualStats.value.length === 0) return []
|
||||
|
||||
return [...individualStats.value]
|
||||
.sort((a, b) => (b.usage?.allTokens || 0) - (a.usage?.allTokens || 0))
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
// 计算单个 Key 的贡献占比
|
||||
const calculateContribution = (stat) => {
|
||||
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
|
||||
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
|
||||
return percentage.toFixed(1)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="triggerRef" class="relative">
|
||||
<!-- 选择器主体 -->
|
||||
<div
|
||||
class="form-input flex w-full cursor-pointer items-center justify-between dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
class="form-input flex w-full cursor-pointer items-center justify-between border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
@click="!disabled && toggleDropdown()"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="搜索账号名称..."
|
||||
style="padding-left: 40px; padding-right: 36px"
|
||||
type="text"
|
||||
@@ -99,7 +99,13 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
|
||||
{{
|
||||
platform === 'claude'
|
||||
? 'Claude OAuth 专属账号'
|
||||
: platform === 'openai'
|
||||
? 'OpenAI 专属账号'
|
||||
: 'OAuth 专属账号'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOAuthAccounts"
|
||||
@@ -170,6 +176,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 账号(仅 OpenAI) -->
|
||||
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
OpenAI-Responses 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOpenAIResponsesAccounts"
|
||||
:key="account.id"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
|
||||
}"
|
||||
@click="selectAccount(`responses:${account.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.isActive === 'true' || account.isActive === true
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: account.status === 'rate_limited'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
{{ getAccountStatusText(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="searchQuery && !hasResults"
|
||||
@@ -196,7 +241,7 @@ const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['claude', 'gemini'].includes(value)
|
||||
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
|
||||
},
|
||||
accounts: {
|
||||
type: Array,
|
||||
@@ -251,6 +296,15 @@ const selectedLabel = computed(() => {
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账号
|
||||
if (props.modelValue.startsWith('responses:')) {
|
||||
const accountId = props.modelValue.substring(10)
|
||||
const account = props.accounts.find(
|
||||
(a) => a.id === accountId && a.platform === 'openai-responses'
|
||||
)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OAuth 账号
|
||||
const account = props.accounts.find((a) => a.id === props.modelValue)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
@@ -260,8 +314,11 @@ const selectedLabel = computed(() => {
|
||||
const getAccountStatusText = (account) => {
|
||||
if (!account) return '未知'
|
||||
|
||||
// 处理 OpenAI-Responses 账号(isActive 可能是字符串)
|
||||
const isActive = account.isActive === 'true' || account.isActive === true
|
||||
|
||||
// 优先使用 isActive 判断
|
||||
if (account.isActive === false) {
|
||||
if (!isActive) {
|
||||
// 根据 status 提供更详细的状态信息
|
||||
switch (account.status) {
|
||||
case 'unauthorized':
|
||||
@@ -272,11 +329,18 @@ const getAccountStatusText = (account) => {
|
||||
return '待验证'
|
||||
case 'rate_limited':
|
||||
return '限流中'
|
||||
case 'quota_exceeded':
|
||||
return '额度超限'
|
||||
default:
|
||||
return '异常'
|
||||
}
|
||||
}
|
||||
|
||||
// 对于激活的账号,如果是限流状态也要显示
|
||||
if (account.status === 'rate_limited') {
|
||||
return '限流中'
|
||||
}
|
||||
|
||||
return '正常'
|
||||
}
|
||||
|
||||
@@ -289,22 +353,42 @@ const sortedAccounts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤的分组
|
||||
// 过滤的分组(根据平台类型过滤)
|
||||
const filteredGroups = computed(() => {
|
||||
if (!searchQuery.value) return props.groups
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
// 只显示与当前平台匹配的分组
|
||||
let groups = props.groups.filter((group) => {
|
||||
// 如果分组有platform属性,则必须匹配当前平台
|
||||
// 如果没有platform属性,则认为是旧数据,根据平台判断
|
||||
if (group.platform) {
|
||||
return group.platform === props.platform
|
||||
}
|
||||
// 向后兼容:如果没有platform字段,通过其他方式判断
|
||||
return true
|
||||
})
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter(
|
||||
(a) =>
|
||||
a.accountType === 'dedicated' &&
|
||||
(props.platform === 'claude'
|
||||
? a.platform === 'claude-oauth'
|
||||
: a.platform !== 'claude-console')
|
||||
)
|
||||
let accounts = []
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
|
||||
} else if (props.platform === 'openai') {
|
||||
// 对于 OpenAI,只显示 openai 类型的账号
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
||||
} else {
|
||||
// 其他平台显示所有非特殊类型的账号
|
||||
accounts = sortedAccounts.value.filter(
|
||||
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
|
||||
)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -318,9 +402,21 @@ const filteredOAuthAccounts = computed(() => {
|
||||
const filteredConsoleAccounts = computed(() => {
|
||||
if (props.platform !== 'claude') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter(
|
||||
(a) => a.accountType === 'dedicated' && a.platform === 'claude-console'
|
||||
)
|
||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-console')
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 过滤的 OpenAI-Responses 账号
|
||||
const filteredOpenAIResponsesAccounts = computed(() => {
|
||||
if (props.platform !== 'openai') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -335,7 +431,8 @@ const hasResults = computed(() => {
|
||||
return (
|
||||
filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
filteredConsoleAccounts.value.length > 0 ||
|
||||
filteredOpenAIResponsesAccounts.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -138,57 +138,42 @@ const selectTheme = (mode) => {
|
||||
.theme-toggle-button {
|
||||
@apply flex items-center justify-center;
|
||||
@apply h-9 w-9 rounded-full;
|
||||
@apply bg-white/80 dark:bg-gray-800/80;
|
||||
@apply hover:bg-white/90 dark:hover:bg-gray-700/90;
|
||||
@apply bg-white/90 dark:bg-gray-800/90;
|
||||
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply shadow-md backdrop-blur-sm hover:shadow-lg;
|
||||
/* 移除 backdrop-blur 减少 GPU 负担 */
|
||||
@apply shadow-md hover:shadow-lg;
|
||||
@apply hover:scale-110 active:scale-95;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 添加优雅的光环效果 */
|
||||
/* 简化的 hover 效果 */
|
||||
.theme-toggle-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(147, 51, 234, 0.2),
|
||||
rgba(59, 130, 246, 0.2)
|
||||
);
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.1), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
animation: rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover::before {
|
||||
opacity: 0.6;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 图标样式优化 - 更生动 */
|
||||
/* 图标样式优化 - 简洁高效 */
|
||||
.theme-toggle-button i {
|
||||
@apply text-base;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover i {
|
||||
transform: rotate(180deg) scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 不同主题的图标颜色 */
|
||||
@@ -300,13 +285,13 @@ const selectTheme = (mode) => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 星星装饰(深色模式) */
|
||||
/* 星星装饰(深色模式) - 优化版 */
|
||||
.stars {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch.is-dark .stars {
|
||||
@@ -320,56 +305,42 @@ const selectTheme = (mode) => {
|
||||
height: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px white;
|
||||
animation: twinkle 3s infinite;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stars span:nth-child(1) {
|
||||
top: 25%;
|
||||
left: 20%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.stars span:nth-child(2) {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation-delay: 1s;
|
||||
width: 1.5px;
|
||||
height: 1.5px;
|
||||
}
|
||||
|
||||
.stars span:nth-child(3) {
|
||||
top: 60%;
|
||||
left: 25%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 云朵装饰(浅色模式) */
|
||||
/* 云朵装饰(浅色模式) - 优化版 */
|
||||
.clouds {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch:not(.is-dark):not(.is-auto) .clouds {
|
||||
opacity: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clouds span {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
@@ -378,7 +349,6 @@ const selectTheme = (mode) => {
|
||||
height: 8px;
|
||||
top: 40%;
|
||||
left: 15%;
|
||||
animation: float 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.clouds span:nth-child(2) {
|
||||
@@ -386,18 +356,6 @@ const selectTheme = (mode) => {
|
||||
height: 6px;
|
||||
top: 60%;
|
||||
left: 35%;
|
||||
animation: float 4s infinite ease-in-out;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换滑块 */
|
||||
@@ -428,16 +386,17 @@ const selectTheme = (mode) => {
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 自动模式滑块位置 - 玻璃态设计 */
|
||||
/* 自动模式滑块位置 - 优化后的半透明设计 */
|
||||
.theme-switch.is-auto .switch-handle {
|
||||
transform: translateY(-50%) translateX(19px);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
/* 降低 blur 强度,减少 GPU 负担 */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.2);
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 滑块图标 */
|
||||
@@ -471,28 +430,7 @@ const selectTheme = (mode) => {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 滑块悬停动画 */
|
||||
.theme-switch:hover .switch-handle {
|
||||
animation: bounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-50%) translateX(var(--handle-x, 0));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(calc(-50% - 3px)) translateX(var(--handle-x, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switch.is-dark:hover .switch-handle {
|
||||
--handle-x: 38px;
|
||||
}
|
||||
|
||||
.theme-switch.is-auto:hover .switch-handle {
|
||||
--handle-x: 19px;
|
||||
}
|
||||
/* 移除弹跳动画,保持简洁 */
|
||||
|
||||
/* 分段按钮样式 - 更现代 */
|
||||
.theme-segmented {
|
||||
|
||||
@@ -20,29 +20,43 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 根据路由设置当前激活的标签
|
||||
const activeTab = ref('dashboard')
|
||||
|
||||
const tabRouteMap = {
|
||||
dashboard: '/dashboard',
|
||||
apiKeys: '/api-keys',
|
||||
accounts: '/accounts',
|
||||
tutorial: '/tutorial',
|
||||
settings: '/settings'
|
||||
}
|
||||
// 根据 LDAP 配置动态生成路由映射
|
||||
const tabRouteMap = computed(() => {
|
||||
const baseMap = {
|
||||
dashboard: '/dashboard',
|
||||
apiKeys: '/api-keys',
|
||||
accounts: '/accounts',
|
||||
tutorial: '/tutorial',
|
||||
settings: '/settings'
|
||||
}
|
||||
|
||||
// 只有在 LDAP 启用时才包含用户管理路由
|
||||
if (authStore.oemSettings?.ldapEnabled) {
|
||||
baseMap.userManagement = '/user-management'
|
||||
}
|
||||
|
||||
return baseMap
|
||||
})
|
||||
|
||||
// 初始化当前激活的标签
|
||||
const initActiveTab = () => {
|
||||
const currentPath = route.path
|
||||
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
|
||||
const tabKey = Object.keys(tabRouteMap.value).find(
|
||||
(key) => tabRouteMap.value[key] === currentPath
|
||||
)
|
||||
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
@@ -72,7 +86,7 @@ initActiveTab()
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
|
||||
const tabKey = Object.keys(tabRouteMap.value).find((key) => tabRouteMap.value[key] === newPath)
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
} else {
|
||||
@@ -95,7 +109,7 @@ watch(
|
||||
// 处理标签切换
|
||||
const handleTabChange = async (tabKey) => {
|
||||
// 如果已经在目标路由,不需要做任何事
|
||||
if (tabRouteMap[tabKey] === route.path) {
|
||||
if (tabRouteMap.value[tabKey] === route.path) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,7 +118,7 @@ const handleTabChange = async (tabKey) => {
|
||||
|
||||
// 使用 await 确保路由切换完成
|
||||
try {
|
||||
await router.push(tabRouteMap[tabKey])
|
||||
await router.push(tabRouteMap.value[tabKey])
|
||||
// 等待下一个DOM更新周期,确保组件正确渲染
|
||||
await nextTick()
|
||||
} catch (err) {
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
@@ -46,13 +49,33 @@ defineProps({
|
||||
|
||||
defineEmits(['tab-change'])
|
||||
|
||||
const tabs = [
|
||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
||||
]
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 根据 LDAP 配置动态生成 tabs
|
||||
const tabs = computed(() => {
|
||||
const baseTabs = [
|
||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
||||
]
|
||||
|
||||
// 只有在 LDAP 启用时才显示用户管理
|
||||
if (authStore.oemSettings?.ldapEnabled) {
|
||||
baseTabs.push({
|
||||
key: 'userManagement',
|
||||
name: '用户管理',
|
||||
shortName: '用户',
|
||||
icon: 'fas fa-users'
|
||||
})
|
||||
}
|
||||
|
||||
baseTabs.push(
|
||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
||||
)
|
||||
|
||||
return baseTabs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
265
web/admin-spa/src/components/user/CreateApiKeyModal.vue
Normal file
265
web/admin-spa/src/components/user/CreateApiKeyModal.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
|
||||
>
|
||||
<div
|
||||
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
placeholder="Enter API key name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-700">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="loading || !form.name.trim()"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center">
|
||||
<svg
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
<span v-else>Create API Key</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Success Modal for showing the new API key -->
|
||||
<div v-if="newApiKey" class="mt-6 rounded-md border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-sm text-green-700">
|
||||
<strong>Important:</strong> Copy your API key now. You won't be able to see it
|
||||
again!
|
||||
</p>
|
||||
<div class="rounded-md border border-green-300 bg-white p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<code class="break-all font-mono text-sm text-gray-900">{{
|
||||
newApiKey.key
|
||||
}}</code>
|
||||
<button
|
||||
class="ml-3 inline-flex flex-shrink-0 items-center rounded border border-transparent bg-green-100 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
@click="copyToClipboard(newApiKey.key)"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
@click="handleClose"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const newApiKey = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
error.value = ''
|
||||
newApiKey.value = null
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
error.value = 'API key name is required'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const apiKeyData = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined
|
||||
}
|
||||
|
||||
const result = await userStore.createApiKey(apiKeyData)
|
||||
|
||||
if (result.success) {
|
||||
newApiKey.value = result.apiKey
|
||||
showToast('API key created successfully!', 'success')
|
||||
} else {
|
||||
error.value = result.message || 'Failed to create API key'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create API key error:', err)
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast('API key copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm()
|
||||
emit('created')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Reset form when modal is shown
|
||||
watch(
|
||||
() => props.show,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
354
web/admin-spa/src/components/user/UserApiKeysManager.vue
Normal file
354
web/admin-spa/src/components/user/UserApiKeysManager.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">
|
||||
Manage your API keys to access Claude Relay services
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
||||
:disabled="activeApiKeysCount >= maxApiKeys"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys 数量限制提示 -->
|
||||
<div
|
||||
v-if="activeApiKeysCount >= maxApiKeys"
|
||||
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
|
||||
existing key to create a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-8 w-8 animate-spin text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
<div v-else-if="sortedApiKeys.length > 0" class="overflow-hidden bg-white shadow sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200" role="list">
|
||||
<li v-for="apiKey in sortedApiKeys" :key="apiKey.id" class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
:class="[
|
||||
'h-2 w-2 rounded-full',
|
||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||
? 'bg-gray-400'
|
||||
: apiKey.isActive
|
||||
? 'bg-green-400'
|
||||
: 'bg-red-400'
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-gray-900">{{ apiKey.name }}</p>
|
||||
<span
|
||||
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
||||
>
|
||||
Deleted
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!apiKey.isActive"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
Deleted
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
|
||||
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
|
||||
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
|
||||
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
|
||||
>
|
||||
<span v-else-if="apiKey.lastUsedAt"
|
||||
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
|
||||
>
|
||||
<span v-else>Never used</span>
|
||||
<span
|
||||
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
|
||||
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Usage Stats -->
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
|
||||
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
|
||||
title="View API Key"
|
||||
@click="showApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="
|
||||
!(apiKey.isDeleted === 'true' || apiKey.deletedAt) &&
|
||||
apiKey.isActive &&
|
||||
allowUserDeleteApiKeys
|
||||
"
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
||||
title="Delete API Key"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create API Key Modal -->
|
||||
<CreateApiKeyModal
|
||||
:show="showCreateModal"
|
||||
@close="showCreateModal = false"
|
||||
@created="handleApiKeyCreated"
|
||||
/>
|
||||
|
||||
<!-- View API Key Modal -->
|
||||
<ViewApiKeyModal
|
||||
:api-key="selectedApiKey"
|
||||
:show="showViewModal"
|
||||
@close="showViewModal = false"
|
||||
/>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<ConfirmModal
|
||||
confirm-class="bg-red-600 hover:bg-red-700"
|
||||
confirm-text="Delete"
|
||||
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
|
||||
:show="showDeleteModal"
|
||||
title="Delete API Key"
|
||||
@cancel="showDeleteModal = false"
|
||||
@confirm="handleDeleteConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const apiKeys = ref([])
|
||||
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
|
||||
const allowUserDeleteApiKeys = computed(() => userStore.config?.allowUserDeleteApiKeys === true)
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showViewModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const selectedApiKey = ref(null)
|
||||
|
||||
// Computed property to sort API keys by creation time (descending - newest first)
|
||||
const sortedApiKeys = computed(() => {
|
||||
return [...apiKeys.value].sort((a, b) => {
|
||||
const dateA = new Date(a.createdAt)
|
||||
const dateB = new Date(b.createdAt)
|
||||
return dateB - dateA // Descending order
|
||||
})
|
||||
})
|
||||
|
||||
// Computed property to count only active (non-deleted) API keys
|
||||
const activeApiKeysCount = computed(() => {
|
||||
return apiKeys.value.filter((key) => !(key.isDeleted === 'true' || key.deletedAt)).length
|
||||
})
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
showToast('Failed to load API keys', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showApiKey = (apiKey) => {
|
||||
selectedApiKey.value = apiKey
|
||||
showViewModal.value = true
|
||||
}
|
||||
|
||||
const deleteApiKey = (apiKey) => {
|
||||
selectedApiKey.value = apiKey
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
try {
|
||||
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API key deleted successfully', 'success')
|
||||
await loadApiKeys()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error)
|
||||
showToast('Failed to delete API key', 'error')
|
||||
} finally {
|
||||
showDeleteModal.value = false
|
||||
selectedApiKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleApiKeyCreated = async () => {
|
||||
showCreateModal.value = false
|
||||
await loadApiKeys()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
397
web/admin-spa/src/components/user/UserUsageStats.vue
Normal file
397
web/admin-spa/src/components/user/UserUsageStats.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-8 w-8 animate-spin text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div v-else class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-6 w-6 text-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Usage Chart -->
|
||||
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
|
||||
|
||||
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Usage Breakdown -->
|
||||
<div
|
||||
v-if="!loading && usageStats && usageStats.modelStats?.length > 0"
|
||||
class="rounded-lg bg-white shadow"
|
||||
>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="model in usageStats.modelStats"
|
||||
:key="model.name"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{ model.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
|
||||
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Usage Table -->
|
||||
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Input Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Output Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="apiKey in userApiKeys" :key="apiKey.id">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: apiKey.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||
? 'Deleted'
|
||||
: apiKey.isActive
|
||||
? 'Active'
|
||||
: 'Disabled'
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div
|
||||
v-if="!loading && (!usageStats || usageStats.totalRequests === 0)"
|
||||
class="py-12 text-center"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
You haven't made any API requests yet. Create an API key and start using the service to see
|
||||
usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const selectedPeriod = ref('week')
|
||||
const usageStats = ref(null)
|
||||
const userApiKeys = ref([])
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const loadUsageStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [stats, apiKeys] = await Promise.all([
|
||||
userStore.getUserUsageStats({ period: selectedPeriod.value }),
|
||||
userStore.getUserApiKeys(true) // Include deleted keys
|
||||
])
|
||||
|
||||
usageStats.value = stats
|
||||
userApiKeys.value = apiKeys
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
showToast('Failed to load usage statistics', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsageStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
250
web/admin-spa/src/components/user/ViewApiKeyModal.vue
Normal file
250
web/admin-spa/src/components/user/ViewApiKeyModal.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
|
||||
>
|
||||
<div
|
||||
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="apiKey" class="space-y-4">
|
||||
<!-- API Key Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<div class="mt-1 flex items-center space-x-2">
|
||||
<div class="flex-1">
|
||||
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="break-all font-mono text-sm text-gray-900">{{
|
||||
apiKey.key || 'Not available'
|
||||
}}</code>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="font-mono text-sm text-gray-900">{{
|
||||
apiKey.keyPreview || 'cr_****'
|
||||
}}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<button
|
||||
v-if="apiKey.key"
|
||||
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="showFullKey = !showFullKey"
|
||||
>
|
||||
<svg
|
||||
v-if="showFullKey"
|
||||
class="mr-1 h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m-1.122-2.122L12 12m-1.122-2.122l-4.243-4.242m6.879 6.878L15 15"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="mr-1 h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ showFullKey ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showFullKey && apiKey.key"
|
||||
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="copyToClipboard(apiKey.key)"
|
||||
>
|
||||
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
|
||||
Full API key is only shown when first created or regenerated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<div class="mt-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Requests:</span>
|
||||
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Input Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.inputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Output Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.outputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Total Cost:</span>
|
||||
<span class="ml-2 font-medium"
|
||||
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
|
||||
<span class="text-gray-500">Last Used:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.expiresAt" class="flex justify-between">
|
||||
<span class="text-gray-500">Expires:</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-medium',
|
||||
new Date(apiKey.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'
|
||||
]"
|
||||
>
|
||||
{{ formatDate(apiKey.expiresAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="emit('close')"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const showFullKey = ref(false)
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast('Copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
@@ -82,7 +82,16 @@ class ApiClient {
|
||||
|
||||
// 如果响应不成功,抛出错误
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`)
|
||||
// 创建一个包含完整错误信息的错误对象
|
||||
const error = new Error(data.message || `HTTP ${response.status}`)
|
||||
// 保留完整的响应数据,以便错误处理时可以访问详细信息
|
||||
error.response = {
|
||||
status: response.status,
|
||||
data: data
|
||||
}
|
||||
// 为了向后兼容,也保留原始的 message
|
||||
error.message = data.message || error.message
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
@@ -98,9 +107,18 @@ class ApiClient {
|
||||
|
||||
// GET 请求
|
||||
async get(url, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
// 处理查询参数
|
||||
let fullUrl = createApiUrl(url)
|
||||
if (options.params) {
|
||||
const params = new URLSearchParams(options.params)
|
||||
fullUrl += '?' + params.toString()
|
||||
}
|
||||
|
||||
// 移除 params 避免传递给 fetch
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { params, ...configOptions } = options
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
...configOptions,
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
@@ -149,6 +167,24 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH 请求
|
||||
async patch(url, data = null, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config)
|
||||
return await this.handleResponse(response)
|
||||
} catch (error) {
|
||||
console.error('API PATCH Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
async delete(url, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
|
||||
@@ -76,6 +76,22 @@ class ApiStatsClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询统计数据
|
||||
async getBatchStats(apiIds) {
|
||||
return this.request('/apiStats/api/batch-stats', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiIds })
|
||||
})
|
||||
}
|
||||
|
||||
// 批量查询模型统计
|
||||
async getBatchModelStats(apiIds, period = 'daily') {
|
||||
return this.request('/apiStats/api/batch-model-stats', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiIds, period })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const apiStatsClient = new ApiStatsClient()
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUserStore } from './stores/user'
|
||||
import './assets/styles/main.css'
|
||||
import './assets/styles/global.css'
|
||||
|
||||
@@ -24,5 +25,9 @@ app.use(ElementPlus, {
|
||||
locale: zhCn
|
||||
})
|
||||
|
||||
// 设置axios拦截器
|
||||
const userStore = useUserStore()
|
||||
userStore.setupAxiosInterceptors()
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { APP_CONFIG } from '@/config/app'
|
||||
|
||||
// 路由懒加载
|
||||
const LoginView = () => import('@/views/LoginView.vue')
|
||||
const UserLoginView = () => import('@/views/UserLoginView.vue')
|
||||
const UserDashboardView = () => import('@/views/UserDashboardView.vue')
|
||||
const UserManagementView = () => import('@/views/UserManagementView.vue')
|
||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||
const DashboardView = () => import('@/views/DashboardView.vue')
|
||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
@@ -35,6 +39,22 @@ const routes = [
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/admin-login',
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/user-login',
|
||||
name: 'UserLogin',
|
||||
component: UserLoginView,
|
||||
meta: { requiresAuth: false, userAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/user-dashboard',
|
||||
name: 'UserDashboard',
|
||||
component: UserDashboardView,
|
||||
meta: { requiresUserAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/api-stats',
|
||||
name: 'ApiStats',
|
||||
@@ -101,6 +121,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user-management',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'UserManagement',
|
||||
component: UserManagementView
|
||||
}
|
||||
]
|
||||
},
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -114,15 +146,18 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
fullPath: to.fullPath,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: authStore.isAuthenticated
|
||||
requiresUserAuth: to.meta.requiresUserAuth,
|
||||
isAuthenticated: authStore.isAuthenticated,
|
||||
isUserAuthenticated: userStore.isAuthenticated
|
||||
})
|
||||
|
||||
// 防止重定向循环:如果已经在目标路径,直接放行
|
||||
@@ -130,9 +165,38 @@ router.beforeEach((to, from, next) => {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 检查用户认证状态
|
||||
if (to.meta.requiresUserAuth) {
|
||||
if (!userStore.isAuthenticated) {
|
||||
// 尝试检查本地存储的认证信息
|
||||
try {
|
||||
const isUserLoggedIn = await userStore.checkAuth()
|
||||
if (!isUserLoggedIn) {
|
||||
return next('/user-login')
|
||||
}
|
||||
} catch (error) {
|
||||
// If the error is about disabled account, redirect to login with error
|
||||
if (error.message && error.message.includes('disabled')) {
|
||||
// Import showToast to display the error
|
||||
const { showToast } = await import('@/utils/toast')
|
||||
showToast(error.message, 'error')
|
||||
}
|
||||
return next('/user-login')
|
||||
}
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// API Stats 页面不需要认证,直接放行
|
||||
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
|
||||
next()
|
||||
} else if (to.path === '/user-login') {
|
||||
// 如果已经是用户登录状态,重定向到用户仪表板
|
||||
if (userStore.isAuthenticated) {
|
||||
next('/user-dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
const geminiAccounts = ref([])
|
||||
const openaiAccounts = ref([])
|
||||
const azureOpenaiAccounts = ref([])
|
||||
const openaiResponsesAccounts = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const sortBy = ref('')
|
||||
@@ -131,6 +132,25 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取OpenAI-Responses账户列表
|
||||
const fetchOpenAIResponsesAccounts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.get('/admin/openai-responses-accounts')
|
||||
if (response.success) {
|
||||
openaiResponsesAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取OpenAI-Responses账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
const fetchAllAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -142,7 +162,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
fetchBedrockAccounts(),
|
||||
fetchGeminiAccounts(),
|
||||
fetchOpenAIAccounts(),
|
||||
fetchAzureOpenAIAccounts()
|
||||
fetchAzureOpenAIAccounts(),
|
||||
fetchOpenAIResponsesAccounts()
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -272,6 +293,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建OpenAI-Responses账户
|
||||
const createOpenAIResponsesAccount = async (data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post('/admin/openai-responses-accounts', data)
|
||||
if (response.success) {
|
||||
await fetchOpenAIResponsesAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '创建OpenAI-Responses账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Claude账户
|
||||
const updateClaudeAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -392,6 +433,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新OpenAI-Responses账户
|
||||
const updateOpenAIResponsesAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/openai-responses-accounts/${id}`, data)
|
||||
if (response.success) {
|
||||
await fetchOpenAIResponsesAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '更新OpenAI-Responses账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换账户状态
|
||||
const toggleAccount = async (platform, id) => {
|
||||
loading.value = true
|
||||
@@ -410,6 +471,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
endpoint = `/admin/openai-accounts/${id}/toggle`
|
||||
} else if (platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${id}/toggle`
|
||||
} else if (platform === 'openai-responses') {
|
||||
endpoint = `/admin/openai-responses-accounts/${id}/toggle`
|
||||
} else {
|
||||
endpoint = `/admin/openai-accounts/${id}/toggle`
|
||||
}
|
||||
@@ -428,6 +491,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchOpenAIAccounts()
|
||||
} else if (platform === 'azure_openai') {
|
||||
await fetchAzureOpenAIAccounts()
|
||||
} else if (platform === 'openai-responses') {
|
||||
await fetchOpenAIResponsesAccounts()
|
||||
} else {
|
||||
await fetchOpenAIAccounts()
|
||||
}
|
||||
@@ -461,6 +526,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
endpoint = `/admin/openai-accounts/${id}`
|
||||
} else if (platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${id}`
|
||||
} else if (platform === 'openai-responses') {
|
||||
endpoint = `/admin/openai-responses-accounts/${id}`
|
||||
} else {
|
||||
endpoint = `/admin/openai-accounts/${id}`
|
||||
}
|
||||
@@ -479,6 +546,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchOpenAIAccounts()
|
||||
} else if (platform === 'azure_openai') {
|
||||
await fetchAzureOpenAIAccounts()
|
||||
} else if (platform === 'openai-responses') {
|
||||
await fetchOpenAIResponsesAccounts()
|
||||
} else {
|
||||
await fetchOpenAIAccounts()
|
||||
}
|
||||
@@ -658,6 +727,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
geminiAccounts.value = []
|
||||
openaiAccounts.value = []
|
||||
azureOpenaiAccounts.value = []
|
||||
openaiResponsesAccounts.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
sortBy.value = ''
|
||||
@@ -672,6 +742,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
geminiAccounts,
|
||||
openaiAccounts,
|
||||
azureOpenaiAccounts,
|
||||
openaiResponsesAccounts,
|
||||
loading,
|
||||
error,
|
||||
sortBy,
|
||||
@@ -684,6 +755,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
fetchGeminiAccounts,
|
||||
fetchOpenAIAccounts,
|
||||
fetchAzureOpenAIAccounts,
|
||||
fetchOpenAIResponsesAccounts,
|
||||
fetchAllAccounts,
|
||||
createClaudeAccount,
|
||||
createClaudeConsoleAccount,
|
||||
@@ -691,12 +763,14 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
createGeminiAccount,
|
||||
createOpenAIAccount,
|
||||
createAzureOpenAIAccount,
|
||||
createOpenAIResponsesAccount,
|
||||
updateClaudeAccount,
|
||||
updateClaudeConsoleAccount,
|
||||
updateBedrockAccount,
|
||||
updateGeminiAccount,
|
||||
updateOpenAIAccount,
|
||||
updateAzureOpenAIAccount,
|
||||
updateOpenAIResponsesAccount,
|
||||
toggleAccount,
|
||||
deleteAccount,
|
||||
refreshClaudeToken,
|
||||
|
||||
@@ -21,6 +21,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
siteIconData: ''
|
||||
})
|
||||
|
||||
// 多 Key 模式相关状态
|
||||
const multiKeyMode = ref(false)
|
||||
const apiKeys = ref([]) // 多个 API Key 数组
|
||||
const apiIds = ref([]) // 对应的 ID 数组
|
||||
const aggregatedStats = ref(null) // 聚合后的统计数据
|
||||
const individualStats = ref([]) // 各个 Key 的独立数据
|
||||
const invalidKeys = ref([]) // 无效的 Keys 列表
|
||||
|
||||
// 计算属性
|
||||
const currentPeriodData = computed(() => {
|
||||
const defaultData = {
|
||||
@@ -34,6 +42,16 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
|
||||
// 聚合模式下使用聚合数据
|
||||
if (multiKeyMode.value && aggregatedStats.value) {
|
||||
if (statsPeriod.value === 'daily') {
|
||||
return aggregatedStats.value.dailyUsage || defaultData
|
||||
} else {
|
||||
return aggregatedStats.value.monthlyUsage || defaultData
|
||||
}
|
||||
}
|
||||
|
||||
// 单个 Key 模式下使用原有逻辑
|
||||
if (statsPeriod.value === 'daily') {
|
||||
return dailyStats.value || defaultData
|
||||
} else {
|
||||
@@ -69,6 +87,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
// 查询统计数据
|
||||
async function queryStats() {
|
||||
// 多 Key 模式处理
|
||||
if (multiKeyMode.value) {
|
||||
return queryBatchStats()
|
||||
}
|
||||
|
||||
if (!apiKey.value.trim()) {
|
||||
error.value = '请输入 API Key'
|
||||
return
|
||||
@@ -204,6 +227,12 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
statsPeriod.value = period
|
||||
|
||||
// 多 Key 模式下加载批量模型统计
|
||||
if (multiKeyMode.value && apiIds.value.length > 0) {
|
||||
await loadBatchModelStats(period)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果对应时间段的数据还没有加载,则加载它
|
||||
if (
|
||||
(period === 'daily' && !dailyStats.value) ||
|
||||
@@ -297,6 +326,127 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询统计数据
|
||||
async function queryBatchStats() {
|
||||
const keys = parseApiKeys()
|
||||
if (keys.length === 0) {
|
||||
error.value = '请输入至少一个有效的 API Key'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
aggregatedStats.value = null
|
||||
individualStats.value = []
|
||||
invalidKeys.value = []
|
||||
modelStats.value = []
|
||||
apiKeys.value = keys
|
||||
apiIds.value = []
|
||||
|
||||
try {
|
||||
// 批量获取 API Key IDs
|
||||
const idResults = await Promise.allSettled(keys.map((key) => apiStatsClient.getKeyId(key)))
|
||||
|
||||
const validIds = []
|
||||
const validKeys = []
|
||||
|
||||
idResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
validIds.push(result.value.data.id)
|
||||
validKeys.push(keys[index])
|
||||
} else {
|
||||
invalidKeys.value.push(keys[index])
|
||||
}
|
||||
})
|
||||
|
||||
if (validIds.length === 0) {
|
||||
throw new Error('所有 API Key 都无效')
|
||||
}
|
||||
|
||||
apiIds.value = validIds
|
||||
apiKeys.value = validKeys
|
||||
|
||||
// 批量查询统计数据
|
||||
const batchResult = await apiStatsClient.getBatchStats(validIds)
|
||||
|
||||
if (batchResult.success) {
|
||||
aggregatedStats.value = batchResult.data.aggregated
|
||||
individualStats.value = batchResult.data.individual
|
||||
statsData.value = batchResult.data.aggregated // 兼容现有组件
|
||||
|
||||
// 设置聚合模式下的日期统计数据,以保证现有组件的兼容性
|
||||
dailyStats.value = batchResult.data.aggregated.dailyUsage || null
|
||||
monthlyStats.value = batchResult.data.aggregated.monthlyUsage || null
|
||||
|
||||
// 加载聚合的模型统计
|
||||
await loadBatchModelStats(statsPeriod.value)
|
||||
|
||||
// 更新 URL
|
||||
updateBatchURL()
|
||||
} else {
|
||||
throw new Error(batchResult.message || '批量查询失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch query error:', err)
|
||||
error.value = err.message || '批量查询统计数据失败'
|
||||
aggregatedStats.value = null
|
||||
individualStats.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载批量模型统计
|
||||
async function loadBatchModelStats(period = 'daily') {
|
||||
if (apiIds.value.length === 0) return
|
||||
|
||||
modelStatsLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await apiStatsClient.getBatchModelStats(apiIds.value, period)
|
||||
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
} else {
|
||||
throw new Error(result.message || '加载批量模型统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load batch model stats error:', err)
|
||||
modelStats.value = []
|
||||
} finally {
|
||||
modelStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 API Keys
|
||||
function parseApiKeys() {
|
||||
if (!apiKey.value) return []
|
||||
|
||||
const keys = apiKey.value
|
||||
.split(/[,\n]+/)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key.length > 0)
|
||||
|
||||
// 去重并限制最多30个
|
||||
const uniqueKeys = [...new Set(keys)]
|
||||
return uniqueKeys.slice(0, 30)
|
||||
}
|
||||
|
||||
// 更新批量查询 URL
|
||||
function updateBatchURL() {
|
||||
if (apiIds.value.length > 0) {
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('apiIds', apiIds.value.join(','))
|
||||
url.searchParams.set('batch', 'true')
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空输入
|
||||
function clearInput() {
|
||||
apiKey.value = ''
|
||||
}
|
||||
|
||||
// 清除数据
|
||||
function clearData() {
|
||||
statsData.value = null
|
||||
@@ -306,11 +456,18 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
error.value = ''
|
||||
statsPeriod.value = 'daily'
|
||||
apiId.value = null
|
||||
// 清除多 Key 模式数据
|
||||
apiKeys.value = []
|
||||
apiIds.value = []
|
||||
aggregatedStats.value = null
|
||||
individualStats.value = []
|
||||
invalidKeys.value = []
|
||||
}
|
||||
|
||||
// 重置
|
||||
function reset() {
|
||||
apiKey.value = ''
|
||||
multiKeyMode.value = false
|
||||
clearData()
|
||||
}
|
||||
|
||||
@@ -328,6 +485,13 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
dailyStats,
|
||||
monthlyStats,
|
||||
oemSettings,
|
||||
// 多 Key 模式状态
|
||||
multiKeyMode,
|
||||
apiKeys,
|
||||
apiIds,
|
||||
aggregatedStats,
|
||||
individualStats,
|
||||
invalidKeys,
|
||||
|
||||
// Computed
|
||||
currentPeriodData,
|
||||
@@ -335,13 +499,16 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
// Actions
|
||||
queryStats,
|
||||
queryBatchStats,
|
||||
loadAllPeriodStats,
|
||||
loadPeriodStats,
|
||||
loadModelStats,
|
||||
loadBatchModelStats,
|
||||
switchPeriod,
|
||||
loadStatsWithApiId,
|
||||
loadOemSettings,
|
||||
clearData,
|
||||
clearInput,
|
||||
reset
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
showAdminButton: true, // 控制管理后台按钮的显示
|
||||
updatedAt: null
|
||||
})
|
||||
|
||||
@@ -64,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
showAdminButton: true,
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
|
||||
217
web/admin-spa/src/stores/user.js
Normal file
217
web/admin-spa/src/stores/user.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const API_BASE = '/users'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
sessionToken: null,
|
||||
loading: false,
|
||||
config: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoggedIn: (state) => state.isAuthenticated && state.user,
|
||||
userName: (state) => state.user?.displayName || state.user?.username,
|
||||
userRole: (state) => state.user?.role
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 🔐 用户登录
|
||||
async login(credentials) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE}/login`, credentials)
|
||||
|
||||
if (response.data.success) {
|
||||
this.user = response.data.user
|
||||
this.sessionToken = response.data.sessionToken
|
||||
this.isAuthenticated = true
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('userToken', this.sessionToken)
|
||||
localStorage.setItem('userData', JSON.stringify(this.user))
|
||||
|
||||
// 设置 axios 默认头部
|
||||
this.setAuthHeader()
|
||||
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.data.message || 'Login failed')
|
||||
}
|
||||
} catch (error) {
|
||||
this.clearAuth()
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 🚪 用户登出
|
||||
async logout() {
|
||||
try {
|
||||
if (this.sessionToken) {
|
||||
await axios.post(
|
||||
`${API_BASE}/logout`,
|
||||
{},
|
||||
{
|
||||
headers: { 'x-user-token': this.sessionToken }
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout request failed:', error)
|
||||
} finally {
|
||||
this.clearAuth()
|
||||
}
|
||||
},
|
||||
|
||||
// 🔄 检查认证状态
|
||||
async checkAuth() {
|
||||
const token = localStorage.getItem('userToken')
|
||||
const userData = localStorage.getItem('userData')
|
||||
const userConfig = localStorage.getItem('userConfig')
|
||||
|
||||
if (!token || !userData) {
|
||||
this.clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.sessionToken = token
|
||||
this.user = JSON.parse(userData)
|
||||
this.config = userConfig ? JSON.parse(userConfig) : null
|
||||
this.isAuthenticated = true
|
||||
this.setAuthHeader()
|
||||
|
||||
// 验证 token 是否仍然有效
|
||||
await this.getUserProfile()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
this.clearAuth()
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 👤 获取用户资料
|
||||
async getUserProfile() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/profile`)
|
||||
|
||||
if (response.data.success) {
|
||||
this.user = response.data.user
|
||||
this.config = response.data.config
|
||||
localStorage.setItem('userData', JSON.stringify(this.user))
|
||||
localStorage.setItem('userConfig', JSON.stringify(this.config))
|
||||
return response.data.user
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// 401: Invalid/expired session, 403: Account disabled
|
||||
this.clearAuth()
|
||||
// If it's a disabled account error, throw a specific error
|
||||
if (error.response?.status === 403) {
|
||||
throw new Error(error.response.data?.message || 'Your account has been disabled')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 🔑 获取用户API Keys
|
||||
async getUserApiKeys(includeDeleted = false) {
|
||||
try {
|
||||
const params = {}
|
||||
if (includeDeleted) {
|
||||
params.includeDeleted = 'true'
|
||||
}
|
||||
const response = await axios.get(`${API_BASE}/api-keys`, { params })
|
||||
return response.data.success ? response.data.apiKeys : []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch API keys:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 🔑 创建API Key
|
||||
async createApiKey(keyData) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE}/api-keys`, keyData)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to create API key:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 🗑️ 删除API Key
|
||||
async deleteApiKey(keyId) {
|
||||
try {
|
||||
const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 获取使用统计
|
||||
async getUserUsageStats(params = {}) {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/usage-stats`, { params })
|
||||
return response.data.success ? response.data.stats : null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage stats:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 🧹 清除认证信息
|
||||
clearAuth() {
|
||||
this.user = null
|
||||
this.sessionToken = null
|
||||
this.isAuthenticated = false
|
||||
this.config = null
|
||||
|
||||
localStorage.removeItem('userToken')
|
||||
localStorage.removeItem('userData')
|
||||
localStorage.removeItem('userConfig')
|
||||
|
||||
// 清除 axios 默认头部
|
||||
delete axios.defaults.headers.common['x-user-token']
|
||||
},
|
||||
|
||||
// 🔧 设置认证头部
|
||||
setAuthHeader() {
|
||||
if (this.sessionToken) {
|
||||
axios.defaults.headers.common['x-user-token'] = this.sessionToken
|
||||
}
|
||||
},
|
||||
|
||||
// 🔧 设置axios拦截器
|
||||
setupAxiosInterceptors() {
|
||||
// Response interceptor to handle disabled user responses globally
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 403) {
|
||||
const message = error.response.data?.message
|
||||
if (message && (message.includes('disabled') || message.includes('Account disabled'))) {
|
||||
this.clearAuth()
|
||||
showToast(message, 'error')
|
||||
// Redirect to login page
|
||||
if (window.location.pathname !== '/user-login') {
|
||||
window.location.href = '/user-login'
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -31,6 +31,9 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
info: 'fas fa-info-circle'
|
||||
}
|
||||
|
||||
// 处理消息中的换行符,转换为 HTML 换行
|
||||
const formattedMessage = message.replace(/\n/g, '<br>')
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
@@ -38,7 +41,7 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
|
||||
<p class="text-sm opacity-90 leading-relaxed">${message}</p>
|
||||
<p class="text-sm opacity-90 leading-relaxed">${formattedMessage}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user