mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:40:25 +00:00
Merge remote-tracking branch 'f3n9/main' into user-management-new
This commit is contained in:
@@ -94,4 +94,10 @@ LDAP_USER_ATTR_LAST_NAME=sn
|
||||
USER_MANAGEMENT_ENABLED=false
|
||||
DEFAULT_USER_ROLE=user
|
||||
USER_SESSION_TIMEOUT=86400000
|
||||
MAX_API_KEYS_PER_USER=5
|
||||
MAX_API_KEYS_PER_USER=5
|
||||
|
||||
# 📢 Webhook 通知配置
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
WEBHOOK_RETRIES=3
|
||||
|
||||
153
README.md
153
README.md
@@ -9,7 +9,7 @@
|
||||
[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml)
|
||||
[](https://hub.docker.com/r/weishaw/claude-relay-service)
|
||||
|
||||
**🔐 自行搭建Claude API中转服务,支持多账户管理**
|
||||
**🔐 自行搭建Claude API中转服务,支持多账户管理**
|
||||
|
||||
[English](#english) • [中文文档](#中文文档) • [📸 界面预览](docs/preview.md) • [📢 公告频道](https://t.me/claude_relay_service)
|
||||
|
||||
@@ -35,11 +35,11 @@
|
||||
---
|
||||
|
||||
> 💡 **感谢 [@vista8](https://x.com/vista8) 的推荐!**
|
||||
>
|
||||
>
|
||||
> 如果你对Vibe coding感兴趣,推荐关注:
|
||||
>
|
||||
>
|
||||
> - 🐦 **X**: [@vista8](https://x.com/vista8) - 分享前沿技术动态
|
||||
> - 📱 **公众号**: 向阳乔木推荐看
|
||||
> - 📱 **公众号**: 向阳乔木推荐看
|
||||
|
||||
---
|
||||
|
||||
@@ -62,14 +62,14 @@
|
||||
✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容
|
||||
✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护
|
||||
✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站
|
||||
✅ **地区受限**: 无法直接访问Claude官方服务
|
||||
✅ **地区受限**: 无法直接访问Claude官方服务
|
||||
|
||||
### 不适合的场景
|
||||
|
||||
❌ **纯小白**: 完全不懂技术,连服务器都不会买
|
||||
❌ **偶尔使用**: 一个月用不了几次,没必要折腾
|
||||
❌ **注册问题**: 无法自行注册Claude账号
|
||||
❌ **支付问题**: 没有支付渠道订阅Claude Code
|
||||
❌ **支付问题**: 没有支付渠道订阅Claude Code
|
||||
|
||||
**如果你只是普通用户,对隐私要求不高,随便玩玩、想快速体验 Claude,那选个你熟知的镜像站会更合适。**
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
|
||||
## 💭 为什么要自己搭?
|
||||
|
||||
|
||||
### 现有镜像站可能的问题
|
||||
|
||||
- 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了
|
||||
@@ -98,11 +97,13 @@
|
||||
> 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图
|
||||
|
||||
### 基础功能
|
||||
|
||||
- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换
|
||||
- ✅ **自定义API Key**: 给每个人分配独立的Key
|
||||
- ✅ **使用统计**: 详细记录每个人用了多少token
|
||||
|
||||
### 高级功能
|
||||
|
||||
- 🔄 **智能切换**: 账户出问题自动换下一个
|
||||
- 🚀 **性能优化**: 连接池、缓存,减少延迟
|
||||
- 📊 **监控面板**: Web界面查看所有数据
|
||||
@@ -114,6 +115,7 @@
|
||||
## 📋 部署要求
|
||||
|
||||
### 硬件要求(最低配置)
|
||||
|
||||
- **CPU**: 1核心就够了
|
||||
- **内存**: 512MB(建议1GB)
|
||||
- **硬盘**: 30GB可用空间
|
||||
@@ -122,11 +124,13 @@
|
||||
- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api
|
||||
|
||||
### 软件要求
|
||||
|
||||
- **Node.js** 18或更高版本
|
||||
- **Redis** 6或更高版本
|
||||
- **操作系统**: 建议Linux
|
||||
|
||||
### 费用估算
|
||||
|
||||
- **服务器**: 轻量云服务器,一个月30-60块
|
||||
- **Claude订阅**: 看你怎么分摊了
|
||||
- **其他**: 域名(可选)
|
||||
@@ -174,11 +178,11 @@ crs uninstall # 卸载服务
|
||||
$ crs install
|
||||
|
||||
# 会依次询问:
|
||||
安装目录 (默认: ~/claude-relay-service):
|
||||
安装目录 (默认: ~/claude-relay-service):
|
||||
服务端口 (默认: 3000): 8080
|
||||
Redis 地址 (默认: localhost):
|
||||
Redis 端口 (默认: 6379):
|
||||
Redis 密码 (默认: 无密码):
|
||||
Redis 地址 (默认: localhost):
|
||||
Redis 端口 (默认: 6379):
|
||||
Redis 密码 (默认: 无密码):
|
||||
|
||||
# 安装完成后自动启动并显示:
|
||||
服务已成功安装并启动!
|
||||
@@ -203,6 +207,7 @@ Redis 密码 (默认: 无密码):
|
||||
### 第一步:环境准备
|
||||
|
||||
**Ubuntu/Debian用户:**
|
||||
|
||||
```bash
|
||||
# 安装Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
@@ -215,6 +220,7 @@ sudo systemctl start redis-server
|
||||
```
|
||||
|
||||
**CentOS/RHEL用户:**
|
||||
|
||||
```bash
|
||||
# 安装Node.js
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
@@ -243,6 +249,7 @@ cp .env.example .env
|
||||
### 第三步:配置文件设置
|
||||
|
||||
**编辑 `.env` 文件:**
|
||||
|
||||
```bash
|
||||
# 这两个密钥随便生成,但要记住
|
||||
JWT_SECRET=你的超级秘密密钥
|
||||
@@ -252,19 +259,26 @@ ENCRYPTION_KEY=32位的加密密钥随便写
|
||||
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` 文件:**
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
server: {
|
||||
port: 3000, // 服务端口,可以改
|
||||
host: '0.0.0.0' // 不用改
|
||||
port: 3000, // 服务端口,可以改
|
||||
host: '0.0.0.0' // 不用改
|
||||
},
|
||||
redis: {
|
||||
host: '127.0.0.1', // Redis地址
|
||||
port: 6379 // Redis端口
|
||||
},
|
||||
host: '127.0.0.1', // Redis地址
|
||||
port: 6379 // Redis端口
|
||||
}
|
||||
// 其他配置保持默认就行
|
||||
}
|
||||
```
|
||||
@@ -372,6 +386,7 @@ docker-compose up -d
|
||||
### Docker Compose 配置
|
||||
|
||||
docker-compose.yml 已包含:
|
||||
|
||||
- ✅ 自动初始化管理员账号
|
||||
- ✅ 数据持久化(logs和data目录自动挂载)
|
||||
- ✅ Redis数据库
|
||||
@@ -382,10 +397,12 @@ docker-compose.yml 已包含:
|
||||
### 环境变量说明
|
||||
|
||||
#### 必填项
|
||||
|
||||
- `JWT_SECRET`: JWT密钥,至少32个字符
|
||||
- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符
|
||||
|
||||
#### 可选项
|
||||
|
||||
- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成)
|
||||
- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成)
|
||||
- `LOG_LEVEL`: 日志级别(默认:info)
|
||||
@@ -394,11 +411,13 @@ docker-compose.yml 已包含:
|
||||
### 管理员凭据获取方式
|
||||
|
||||
1. **查看容器日志**
|
||||
|
||||
```bash
|
||||
docker logs claude-relay-service
|
||||
```
|
||||
|
||||
2. **查看挂载的文件**
|
||||
|
||||
```bash
|
||||
cat ./data/init.json
|
||||
```
|
||||
@@ -419,6 +438,7 @@ docker-compose.yml 已包含:
|
||||
浏览器访问:`http://你的服务器IP:3000/web`
|
||||
|
||||
管理员账号:
|
||||
|
||||
- 自动生成:查看 data/init.json
|
||||
- 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置
|
||||
- Docker 部署:查看容器日志 `docker logs claude-relay-service`
|
||||
@@ -456,12 +476,14 @@ docker-compose.yml 已包含:
|
||||
现在你可以用自己的服务替换官方API了:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
**Gemini CLI 设置环境变量:**
|
||||
|
||||
```bash
|
||||
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||
export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可
|
||||
@@ -469,43 +491,49 @@ export GOOGLE_GENAI_USE_GCA="true"
|
||||
```
|
||||
|
||||
**使用 Claude Code:**
|
||||
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
|
||||
**使用 Gemini CLI:**
|
||||
|
||||
```bash
|
||||
gemini # 或其他 Gemini CLI 命令
|
||||
```
|
||||
|
||||
**Codex 设置环境变量:**
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
|
||||
export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
|
||||
```
|
||||
|
||||
|
||||
### 5. 第三方工具API接入
|
||||
|
||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
|
||||
|
||||
**Claude标准格式:**
|
||||
|
||||
```
|
||||
# 如果工具支持Claude标准格式,请使用该接口
|
||||
http://你的服务器:3000/claude/
|
||||
http://你的服务器:3000/claude/
|
||||
```
|
||||
|
||||
**OpenAI兼容格式:**
|
||||
|
||||
```
|
||||
# 适用于需要OpenAI格式的第三方工具
|
||||
http://你的服务器:3000/openai/claude/v1/
|
||||
```
|
||||
|
||||
**接入示例:**
|
||||
|
||||
- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
|
||||
- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
|
||||
|
||||
**重要说明:**
|
||||
|
||||
- 所有格式都支持相同的功能,仅是路径不同
|
||||
- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
|
||||
- 选择适合你使用工具的格式即可
|
||||
@@ -513,6 +541,67 @@ http://你的服务器:3000/openai/claude/v1/
|
||||
|
||||
---
|
||||
|
||||
## 📢 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. 发送测试通知确认配置正确
|
||||
|
||||
---
|
||||
|
||||
## 🔧 日常维护
|
||||
|
||||
### 服务管理
|
||||
@@ -567,6 +656,7 @@ npm run service:status
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
|
||||
- 升级前建议备份重要配置文件(.env, config/config.js)
|
||||
- 查看更新日志了解是否有破坏性变更
|
||||
- 如果有数据库结构变更,会自动迁移
|
||||
@@ -615,12 +705,14 @@ clientRestrictions: {
|
||||
### 日志示例
|
||||
|
||||
认证成功时的日志:
|
||||
|
||||
```
|
||||
🔓 Authenticated request from key: 测试Key (key-id) in 5ms
|
||||
User-Agent: "claude-cli/1.0.58 (external, cli)"
|
||||
```
|
||||
|
||||
客户端限制检查日志:
|
||||
|
||||
```
|
||||
🔍 Checking client restriction for key: key-id (测试Key)
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||
@@ -631,6 +723,7 @@ clientRestrictions: {
|
||||
### 常见问题处理
|
||||
|
||||
**Redis连不上?**
|
||||
|
||||
```bash
|
||||
# 检查Redis是否启动
|
||||
redis-cli ping
|
||||
@@ -639,11 +732,13 @@ redis-cli ping
|
||||
```
|
||||
|
||||
**OAuth授权失败?**
|
||||
|
||||
- 检查代理设置是否正确
|
||||
- 确保能正常访问 claude.ai
|
||||
- 清除浏览器缓存重试
|
||||
|
||||
**API请求失败?**
|
||||
|
||||
- 检查API Key是否正确
|
||||
- 查看日志文件找错误信息
|
||||
- 确认Claude账户状态正常
|
||||
@@ -652,7 +747,6 @@ redis-cli ping
|
||||
|
||||
## 🛠️ 进阶
|
||||
|
||||
|
||||
### 生产环境部署建议(重要!)
|
||||
|
||||
**强烈建议使用Caddy反向代理(自动HTTPS)**
|
||||
@@ -660,6 +754,7 @@ redis-cli ping
|
||||
建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
|
||||
|
||||
**1. 安装Caddy**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
@@ -677,18 +772,19 @@ sudo yum install caddy
|
||||
**2. Caddy配置(超简单!)**
|
||||
|
||||
编辑 `/etc/caddy/Caddyfile`:
|
||||
|
||||
```
|
||||
your-domain.com {
|
||||
# 反向代理到本地服务
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
# 支持流式响应(SSE)
|
||||
flush_interval -1
|
||||
|
||||
|
||||
# 传递真实IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
|
||||
# 超时设置(适合长连接)
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
@@ -696,7 +792,7 @@ your-domain.com {
|
||||
dial_timeout 30s
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 安全头部
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
@@ -708,6 +804,7 @@ your-domain.com {
|
||||
```
|
||||
|
||||
**3. 启动Caddy**
|
||||
|
||||
```bash
|
||||
# 测试配置
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
@@ -723,34 +820,37 @@ sudo systemctl status caddy
|
||||
**4. 更新服务配置**
|
||||
|
||||
修改你的服务配置,让它只监听本地:
|
||||
|
||||
```javascript
|
||||
// config/config.js
|
||||
module.exports = {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '127.0.0.1' // 只监听本地,通过nginx代理
|
||||
host: '127.0.0.1' // 只监听本地,通过nginx代理
|
||||
}
|
||||
// ... 其他配置
|
||||
}
|
||||
```
|
||||
|
||||
**Caddy优势:**
|
||||
|
||||
- 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置
|
||||
- 🛡️ **安全默认**: 默认启用现代安全协议和加密套件
|
||||
- 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输
|
||||
- 📊 **简单配置**: 配置文件极其简洁,易于维护
|
||||
- ⚡ **HTTP/2**: 默认启用HTTP/2,提升传输性能
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用建议
|
||||
|
||||
### 账户管理
|
||||
|
||||
- **定期检查**: 每周看看账户状态,及时处理异常
|
||||
- **合理分配**: 可以给不同的人分配不同的apikey,可以根据不同的apikey来分析用量
|
||||
|
||||
### 安全建议
|
||||
|
||||
- **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全
|
||||
- **定期备份**: 重要配置和数据要备份
|
||||
- **监控日志**: 定期查看异常日志
|
||||
@@ -762,12 +862,14 @@ module.exports = {
|
||||
## 🆘 遇到问题怎么办?
|
||||
|
||||
### 自助排查
|
||||
|
||||
1. **查看日志**: `logs/` 目录下的日志文件
|
||||
2. **检查配置**: 确认配置文件设置正确
|
||||
3. **测试连通性**: 用 curl 测试API是否正常
|
||||
4. **重启服务**: 有时候重启一下就好了
|
||||
|
||||
### 寻求帮助
|
||||
|
||||
- **GitHub Issues**: 提交详细的错误信息
|
||||
- **查看文档**: 仔细阅读错误信息和文档
|
||||
- **社区讨论**: 看看其他人是否遇到类似问题
|
||||
@@ -775,6 +877,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [MIT许可证](LICENSE)。
|
||||
|
||||
---
|
||||
@@ -785,4 +888,4 @@ module.exports = {
|
||||
|
||||
**🤝 有问题欢迎提Issue,有改进建议欢迎PR**
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
const path = require('path')
|
||||
require('dotenv').config()
|
||||
|
||||
const config = {
|
||||
// 🌐 服务器配置
|
||||
@@ -29,14 +29,16 @@ const config = {
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
enableTLS: process.env.REDIS_ENABLE_TLS === 'true',
|
||||
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
|
||||
},
|
||||
|
||||
// 🎯 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'
|
||||
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'
|
||||
},
|
||||
|
||||
// ☁️ Bedrock API配置
|
||||
@@ -45,7 +47,8 @@ const config = {
|
||||
defaultRegion: process.env.AWS_REGION || 'us-east-1',
|
||||
smallFastModelRegion: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION,
|
||||
defaultModel: process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
smallFastModel: process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||
smallFastModel:
|
||||
process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||
maxOutputTokens: parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096,
|
||||
maxThinkingTokens: parseInt(process.env.MAX_THINKING_TOKENS) || 1024,
|
||||
enablePromptCaching: process.env.DISABLE_PROMPT_CACHING !== '1'
|
||||
@@ -82,7 +85,9 @@ const config = {
|
||||
// 🎨 Web界面配置
|
||||
web: {
|
||||
title: process.env.WEB_TITLE || 'Claude Relay Service',
|
||||
description: process.env.WEB_DESCRIPTION || 'Multi-account Claude API relay service with beautiful management interface',
|
||||
description:
|
||||
process.env.WEB_DESCRIPTION ||
|
||||
'Multi-account Claude API relay service with beautiful management interface',
|
||||
logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png',
|
||||
enableCors: process.env.ENABLE_CORS === 'true',
|
||||
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
|
||||
@@ -98,7 +103,7 @@ const config = {
|
||||
description: 'Official Claude Code CLI',
|
||||
// 匹配 Claude CLI 的 User-Agent
|
||||
// 示例: claude-cli/1.0.58 (external, cli)
|
||||
userAgentPattern: /^claude-cli\/[\d\.]+\s+\(/i
|
||||
userAgentPattern: /^claude-cli\/[\d.]+\s+\(/i
|
||||
},
|
||||
{
|
||||
id: 'gemini_cli',
|
||||
@@ -106,7 +111,7 @@ const config = {
|
||||
description: 'Gemini Command Line Interface',
|
||||
// 匹配 GeminiCLI 的 User-Agent
|
||||
// 示例: GeminiCLI/v18.20.8 (darwin; arm64)
|
||||
userAgentPattern: /^GeminiCLI\/v?[\d\.]+\s+\(/i
|
||||
userAgentPattern: /^GeminiCLI\/v?[\d.]+\s+\(/i
|
||||
}
|
||||
// 添加自定义客户端示例:
|
||||
// {
|
||||
@@ -140,7 +145,7 @@ const config = {
|
||||
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
|
||||
@@ -163,11 +168,21 @@ const config = {
|
||||
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 5
|
||||
},
|
||||
|
||||
// 📢 Webhook通知配置
|
||||
webhook: {
|
||||
enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用
|
||||
urls: process.env.WEBHOOK_URLS
|
||||
? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim())
|
||||
: [],
|
||||
timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时
|
||||
retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次
|
||||
},
|
||||
|
||||
// 🛠️ 开发配置
|
||||
development: {
|
||||
debug: process.env.DEBUG === 'true',
|
||||
hotReload: process.env.HOT_RELOAD === 'true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
module.exports = config
|
||||
|
||||
40
src/app.js
40
src/app.js
@@ -10,6 +10,7 @@ const config = require('../config/config')
|
||||
const logger = require('./utils/logger')
|
||||
const redis = require('./models/redis')
|
||||
const pricingService = require('./services/pricingService')
|
||||
const cacheMonitor = require('./utils/cacheMonitor')
|
||||
|
||||
// Import routes
|
||||
const apiRoutes = require('./routes/api')
|
||||
@@ -21,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||
const openaiRoutes = require('./routes/openaiRoutes')
|
||||
const userRoutes = require('./routes/userRoutes')
|
||||
const webhookRoutes = require('./routes/webhook')
|
||||
|
||||
// Import middleware
|
||||
const {
|
||||
@@ -49,6 +51,9 @@ class Application {
|
||||
logger.info('🔄 Initializing pricing service...')
|
||||
await pricingService.initialize()
|
||||
|
||||
// 📊 初始化缓存监控
|
||||
await this.initializeCacheMonitoring()
|
||||
|
||||
// 🔧 初始化管理员凭据
|
||||
logger.info('🔄 Initializing admin credentials...')
|
||||
await this.initializeAdmin()
|
||||
@@ -238,6 +243,7 @@ class Application {
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||
this.app.use('/openai', openaiRoutes)
|
||||
this.app.use('/admin/webhook', webhookRoutes)
|
||||
|
||||
// 🏠 根路径重定向到新版管理界面
|
||||
this.app.get('/', (req, res) => {
|
||||
@@ -456,6 +462,40 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 初始化缓存监控
|
||||
async initializeCacheMonitoring() {
|
||||
try {
|
||||
logger.info('🔄 Initializing cache monitoring...')
|
||||
|
||||
// 注册各个服务的缓存实例
|
||||
const services = [
|
||||
{ name: 'claudeAccount', service: require('./services/claudeAccountService') },
|
||||
{ name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') },
|
||||
{ name: 'bedrockAccount', service: require('./services/bedrockAccountService') }
|
||||
]
|
||||
|
||||
// 注册已加载的服务缓存
|
||||
for (const { name, service } of services) {
|
||||
if (service && (service._decryptCache || service.decryptCache)) {
|
||||
const cache = service._decryptCache || service.decryptCache
|
||||
cacheMonitor.registerCache(`${name}_decrypt`, cache)
|
||||
logger.info(`✅ Registered ${name} decrypt cache for monitoring`)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时打印一次统计
|
||||
setTimeout(() => {
|
||||
const stats = cacheMonitor.getGlobalStats()
|
||||
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
||||
}, 5000)
|
||||
|
||||
logger.success('✅ Cache monitoring initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize cache monitoring:', error)
|
||||
// 不阻止应用启动
|
||||
}
|
||||
}
|
||||
|
||||
startCleanupTasks() {
|
||||
// 🧹 每小时清理一次过期数据
|
||||
setInterval(async () => {
|
||||
|
||||
@@ -304,7 +304,10 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
name: validation.keyData.name,
|
||||
tokenLimit: validation.keyData.tokenLimit,
|
||||
claudeAccountId: validation.keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, // 添加 Claude Console 账号ID
|
||||
geminiAccountId: validation.keyData.geminiAccountId,
|
||||
openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID
|
||||
bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
permissions: validation.keyData.permissions,
|
||||
concurrencyLimit: validation.keyData.concurrencyLimit,
|
||||
rateLimitWindow: validation.keyData.rateLimitWindow,
|
||||
|
||||
@@ -191,7 +191,9 @@ class RedisClient {
|
||||
outputTokens = 0,
|
||||
cacheCreateTokens = 0,
|
||||
cacheReadTokens = 0,
|
||||
model = 'unknown'
|
||||
model = 'unknown',
|
||||
ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens
|
||||
ephemeral1hTokens = 0 // 新增:1小时缓存 tokens
|
||||
) {
|
||||
const key = `usage:${keyId}`
|
||||
const now = new Date()
|
||||
@@ -245,6 +247,9 @@ class RedisClient {
|
||||
pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens)
|
||||
pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(key, 'totalAllTokens', totalTokens) // 包含所有类型的总token
|
||||
// 详细缓存类型统计(新增)
|
||||
pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens)
|
||||
// 请求计数
|
||||
pipeline.hincrby(key, 'totalRequests', 1)
|
||||
|
||||
@@ -256,6 +261,9 @@ class RedisClient {
|
||||
pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(daily, 'allTokens', totalTokens)
|
||||
pipeline.hincrby(daily, 'requests', 1)
|
||||
// 详细缓存类型统计
|
||||
pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
|
||||
// 每月统计
|
||||
pipeline.hincrby(monthly, 'tokens', coreTokens)
|
||||
@@ -265,6 +273,9 @@ class RedisClient {
|
||||
pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(monthly, 'allTokens', totalTokens)
|
||||
pipeline.hincrby(monthly, 'requests', 1)
|
||||
// 详细缓存类型统计
|
||||
pipeline.hincrby(monthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(monthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
|
||||
// 按模型统计 - 每日
|
||||
pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens)
|
||||
@@ -289,6 +300,9 @@ class RedisClient {
|
||||
pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens)
|
||||
pipeline.hincrby(keyModelDaily, 'requests', 1)
|
||||
// 详细缓存类型统计
|
||||
pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
|
||||
// API Key级别的模型统计 - 每月
|
||||
pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens)
|
||||
@@ -297,6 +311,9 @@ class RedisClient {
|
||||
pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens)
|
||||
pipeline.hincrby(keyModelMonthly, 'requests', 1)
|
||||
// 详细缓存类型统计
|
||||
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
|
||||
// 小时级别统计
|
||||
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
||||
|
||||
@@ -391,6 +391,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
bedrockAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -487,6 +488,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
bedrockAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -633,6 +635,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
bedrockAccountId,
|
||||
permissions,
|
||||
enableModelRestriction,
|
||||
restrictedModels,
|
||||
@@ -696,6 +699,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.openaiAccountId = openaiAccountId || ''
|
||||
}
|
||||
|
||||
if (bedrockAccountId !== undefined) {
|
||||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||||
updates.bedrockAccountId = bedrockAccountId || ''
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
|
||||
@@ -1402,6 +1410,46 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新单个Claude账户的Profile信息
|
||||
router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
||||
|
||||
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Account profile updated successfully',
|
||||
data: profileInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update account profile:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to update account profile', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 批量更新所有Claude账户的Profile信息
|
||||
router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await claudeAccountService.updateAllAccountProfiles()
|
||||
|
||||
logger.success('✅ Batch profile update completed')
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Batch profile update completed',
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update all account profiles:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to update all account profiles', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新Claude账户token
|
||||
router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -96,22 +96,42 @@ async function handleMessagesRequest(req, res) {
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
const cacheCreateTokens = usageData.cache_creation_input_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 { accountId: usageAccountId } = usageData
|
||||
|
||||
// 构建 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
|
||||
.recordUsage(
|
||||
req.apiKey.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
usageAccountId
|
||||
)
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
@@ -161,22 +181,42 @@ async function handleMessagesRequest(req, res) {
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
const cacheCreateTokens = usageData.cache_creation_input_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
|
||||
.recordUsage(
|
||||
req.apiKey.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
usageAccountId
|
||||
)
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
@@ -318,19 +318,38 @@ async function handleLoadCodeAssist(req, res) {
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
const { metadata, cloudaicompanionProject } = req.body
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`LoadCodeAssist request (${version})`, {
|
||||
metadata: metadata || {},
|
||||
cloudaicompanionProject: cloudaicompanionProject || null,
|
||||
requestedProject: cloudaicompanionProject || null,
|
||||
accountProject: projectId || null,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
const response = await geminiAccountService.loadCodeAssist(client, cloudaicompanionProject)
|
||||
|
||||
// 根据账户配置决定项目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')
|
||||
}
|
||||
|
||||
const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
@@ -345,6 +364,7 @@ async function handleLoadCodeAssist(req, res) {
|
||||
// 共用的 onboardUser 处理函数
|
||||
async function handleOnboardUser(req, res) {
|
||||
try {
|
||||
// 提取请求参数
|
||||
const { tierId, cloudaicompanionProject, metadata } = req.body
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
@@ -355,34 +375,53 @@ async function handleOnboardUser(req, res) {
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`OnboardUser request (${version})`, {
|
||||
tierId: tierId || 'not provided',
|
||||
cloudaicompanionProject: cloudaicompanionProject || null,
|
||||
requestedProject: cloudaicompanionProject || null,
|
||||
accountProject: projectId || null,
|
||||
metadata: metadata || {},
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 如果提供了完整参数,直接调用onboardUser
|
||||
if (tierId && metadata) {
|
||||
// 根据账户配置决定项目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')
|
||||
}
|
||||
|
||||
// 如果提供了 tierId,直接调用 onboardUser
|
||||
if (tierId) {
|
||||
const response = await geminiAccountService.onboardUser(
|
||||
client,
|
||||
tierId,
|
||||
cloudaicompanionProject,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
} else {
|
||||
// 否则执行完整的setupUser流程
|
||||
// 否则执行完整的 setupUser 流程
|
||||
const response = await geminiAccountService.setupUser(
|
||||
client,
|
||||
cloudaicompanionProject,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -506,7 +545,7 @@ async function handleGenerateContent(req, res) {
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
project || account.projectId,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
||||
)
|
||||
|
||||
@@ -533,7 +572,6 @@ async function handleGenerateContent(req, res) {
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
console.log(321, error.response)
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in generateContent endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
@@ -620,7 +658,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
project || account.projectId,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal // 传递中止信号
|
||||
)
|
||||
|
||||
@@ -250,19 +250,13 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
(usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const outputTokens = usage.output_tokens || 0
|
||||
const cacheCreateTokens = usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
const model = usage.model || claudeRequest.model
|
||||
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsage(
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
@@ -328,13 +322,11 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 记录使用统计
|
||||
if (claudeData.usage) {
|
||||
const { usage } = claudeData
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsage(
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage.input_tokens || 0,
|
||||
usage.output_tokens || 0,
|
||||
usage.cache_creation_input_tokens || 0,
|
||||
usage.cache_read_input_tokens || 0,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
)
|
||||
|
||||
@@ -128,7 +128,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
'max_output_tokens',
|
||||
'user',
|
||||
'text_formatting',
|
||||
'truncation'
|
||||
'truncation',
|
||||
'service_tier'
|
||||
]
|
||||
fieldsToRemove.forEach((field) => {
|
||||
delete req.body[field]
|
||||
|
||||
120
src/routes/webhook.js
Normal file
120
src/routes/webhook.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
|
||||
// 测试Webhook连通性
|
||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: 'Please provide a webhook URL to test'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: 'Please provide a valid webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 Testing webhook URL: ${url}`)
|
||||
|
||||
const result = await webhookNotifier.testWebhook(url)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`✅ Webhook test successful for: ${url}`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook test successful',
|
||||
url
|
||||
})
|
||||
} else {
|
||||
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook test failed',
|
||||
url,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Webhook test error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to test webhook'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发账号异常通知(用于测试)
|
||||
router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
accountId = 'test-account-id',
|
||||
accountName = 'Test Account',
|
||||
platform = 'claude-oauth',
|
||||
status = 'error',
|
||||
errorCode = 'TEST_ERROR',
|
||||
reason = 'Manual test notification'
|
||||
} = req.body
|
||||
|
||||
logger.info(`🧪 Sending test notification for account: ${accountName}`)
|
||||
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
status,
|
||||
errorCode,
|
||||
reason
|
||||
})
|
||||
|
||||
logger.info(`✅ Test notification sent successfully`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test notification sent successfully',
|
||||
data: {
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
status,
|
||||
errorCode,
|
||||
reason
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to send test notification:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to send test notification'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取Webhook配置信息
|
||||
router.get('/config', authenticateAdmin, (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: config.webhook?.enabled !== false,
|
||||
urls: config.webhook?.urls || [],
|
||||
timeout: config.webhook?.timeout || 10000,
|
||||
retries: config.webhook?.retries || 3,
|
||||
urlCount: (config.webhook?.urls || []).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -20,6 +20,7 @@ class ApiKeyService {
|
||||
claudeConsoleAccountId = null,
|
||||
geminiAccountId = null,
|
||||
openaiAccountId = null,
|
||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
@@ -52,6 +53,7 @@ class ApiKeyService {
|
||||
claudeConsoleAccountId: claudeConsoleAccountId || '',
|
||||
geminiAccountId: geminiAccountId || '',
|
||||
openaiAccountId: openaiAccountId || '',
|
||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||
permissions: permissions || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
@@ -86,6 +88,7 @@ class ApiKeyService {
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
permissions: keyData.permissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
@@ -187,6 +190,7 @@ class ApiKeyService {
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
@@ -333,6 +337,7 @@ class ApiKeyService {
|
||||
'claudeConsoleAccountId',
|
||||
'geminiAccountId',
|
||||
'openaiAccountId',
|
||||
'bedrockAccountId', // 添加 Bedrock 账号ID
|
||||
'permissions',
|
||||
'expiresAt',
|
||||
'enableModelRestriction',
|
||||
@@ -495,6 +500,126 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||
async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
|
||||
try {
|
||||
// 提取 token 数量
|
||||
const inputTokens = usageObject.input_tokens || 0
|
||||
const outputTokens = usageObject.output_tokens || 0
|
||||
const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = usageObject.cache_read_input_tokens || 0
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 计算费用(支持详细的缓存类型)- 添加错误处理
|
||||
let costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 }
|
||||
try {
|
||||
const pricingService = require('./pricingService')
|
||||
// 确保 pricingService 已初始化
|
||||
if (!pricingService.pricingData) {
|
||||
logger.warn('⚠️ PricingService not initialized, initializing now...')
|
||||
await pricingService.initialize()
|
||||
}
|
||||
costInfo = pricingService.calculateCost(usageObject, model)
|
||||
} catch (pricingError) {
|
||||
logger.error('❌ Failed to calculate cost:', pricingError)
|
||||
// 继续执行,不要因为费用计算失败而跳过统计记录
|
||||
}
|
||||
|
||||
// 提取详细的缓存创建数据
|
||||
let ephemeral5mTokens = 0
|
||||
let ephemeral1hTokens = 0
|
||||
|
||||
if (usageObject.cache_creation && typeof usageObject.cache_creation === 'object') {
|
||||
ephemeral5mTokens = usageObject.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
|
||||
// 记录API Key级别的使用统计 - 这个必须执行
|
||||
await redis.incrementTokenUsage(
|
||||
keyId,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
ephemeral5mTokens, // 传递5分钟缓存 tokens
|
||||
ephemeral1hTokens // 传递1小时缓存 tokens
|
||||
)
|
||||
|
||||
// 记录费用统计
|
||||
if (costInfo.totalCost > 0) {
|
||||
await redis.incrementDailyCost(keyId, costInfo.totalCost)
|
||||
logger.database(
|
||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
|
||||
// 记录详细的缓存费用(如果有)
|
||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||
logger.database(
|
||||
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString()
|
||||
await redis.setApiKey(keyId, keyData)
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(
|
||||
accountId,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
)
|
||||
logger.database(
|
||||
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
'⚠️ No accountId provided for usage recording, skipping account-level statistics'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
|
||||
if (cacheCreateTokens > 0) {
|
||||
logParts.push(`Cache Create: ${cacheCreateTokens}`)
|
||||
|
||||
// 如果有详细的缓存创建数据,也记录它们
|
||||
if (usageObject.cache_creation) {
|
||||
const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } =
|
||||
usageObject.cache_creation
|
||||
if (ephemeral_5m_input_tokens > 0) {
|
||||
logParts.push(`5m: ${ephemeral_5m_input_tokens}`)
|
||||
}
|
||||
if (ephemeral_1h_input_tokens > 0) {
|
||||
logParts.push(`1h: ${ephemeral_1h_input_tokens}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cacheReadTokens > 0) {
|
||||
logParts.push(`Cache Read: ${cacheReadTokens}`)
|
||||
}
|
||||
logParts.push(`Total: ${totalTokens} tokens`)
|
||||
|
||||
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 生成密钥
|
||||
_generateSecretKey() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
|
||||
@@ -4,12 +4,28 @@ const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const bedrockRelayService = require('./bedrockRelayService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class BedrockAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'salt'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Bedrock decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建Bedrock账户
|
||||
@@ -336,10 +352,22 @@ class BedrockAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥(缓存优化)
|
||||
_generateEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto
|
||||
.createHash('sha256')
|
||||
.update(config.security.encryptionKey)
|
||||
.digest()
|
||||
logger.info('🔑 Bedrock encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🔐 加密AWS凭证
|
||||
_encryptAwsCredentials(credentials) {
|
||||
try {
|
||||
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest()
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
@@ -368,15 +396,35 @@ class BedrockAccountService {
|
||||
|
||||
// 检查是否为加密格式 (有 encrypted 和 iv 字段)
|
||||
if (encryptedData.encrypted && encryptedData.iv) {
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(encryptedData))
|
||||
.digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 加密数据 - 进行解密
|
||||
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest()
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(encryptedData.iv, 'hex')
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
return JSON.parse(decrypted)
|
||||
const result = JSON.parse(decrypted)
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
|
||||
this._decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} else if (encryptedData.accessKeyId) {
|
||||
// 纯文本数据 - 直接返回 (向后兼容)
|
||||
logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密')
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
@@ -24,6 +25,22 @@ class ClaudeAccountService {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'salt'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Claude decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建Claude账户
|
||||
@@ -39,7 +56,8 @@ class ClaudeAccountService {
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
schedulable = true // 是否可被调度
|
||||
schedulable = true, // 是否可被调度
|
||||
subscriptionInfo = null // 手动设置的订阅信息
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -68,7 +86,13 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'active', // 有OAuth数据的账户直接设为active
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
// 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空
|
||||
subscriptionInfo: subscriptionInfo
|
||||
? JSON.stringify(subscriptionInfo)
|
||||
: claudeAiOauth.subscriptionInfo
|
||||
? JSON.stringify(claudeAiOauth.subscriptionInfo)
|
||||
: ''
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -91,7 +115,9 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'created', // created, active, expired, error
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
// 手动设置的订阅信息
|
||||
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +125,24 @@ class ClaudeAccountService {
|
||||
|
||||
logger.success(`🏢 Created Claude account: ${name} (${accountId})`)
|
||||
|
||||
// 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息
|
||||
if (claudeAiOauth && claudeAiOauth.accessToken) {
|
||||
// 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有)
|
||||
const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile')
|
||||
|
||||
if (hasProfileScope) {
|
||||
try {
|
||||
const agent = this._createProxyAgent(proxy)
|
||||
await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent)
|
||||
logger.info(`📊 Successfully fetched profile info for new account: ${name}`)
|
||||
} catch (profileError) {
|
||||
logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name,
|
||||
@@ -188,8 +232,39 @@ class ClaudeAccountService {
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
// 记录完整的响应数据到专门的认证详细日志
|
||||
logger.authDetail('Token refresh response', response.data)
|
||||
|
||||
// 记录简化版本到主日志
|
||||
logger.info('📊 Token refresh response (analyzing for subscription info):', {
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
const { access_token, refresh_token, expires_in } = response.data
|
||||
|
||||
// 检查是否有套餐信息
|
||||
if (
|
||||
response.data.subscription ||
|
||||
response.data.plan ||
|
||||
response.data.tier ||
|
||||
response.data.account_type
|
||||
) {
|
||||
const subscriptionInfo = {
|
||||
subscription: response.data.subscription,
|
||||
plan: response.data.plan,
|
||||
tier: response.data.tier,
|
||||
accountType: response.data.account_type,
|
||||
features: response.data.features,
|
||||
limits: response.data.limits
|
||||
}
|
||||
logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo)
|
||||
|
||||
// 将套餐信息存储在账户数据中
|
||||
accountData.subscriptionInfo = JSON.stringify(subscriptionInfo)
|
||||
}
|
||||
|
||||
// 更新账户数据
|
||||
accountData.accessToken = this._encryptSensitiveData(access_token)
|
||||
accountData.refreshToken = this._encryptSensitiveData(refresh_token)
|
||||
@@ -200,6 +275,22 @@ class ClaudeAccountService {
|
||||
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
// 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息
|
||||
// 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有)
|
||||
const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile')
|
||||
|
||||
if (hasProfileScope) {
|
||||
try {
|
||||
await this.fetchAndUpdateAccountProfile(accountId, access_token, agent)
|
||||
} catch (profileError) {
|
||||
logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`)
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)`
|
||||
)
|
||||
}
|
||||
|
||||
// 记录刷新成功
|
||||
logRefreshSuccess(accountId, accountData.name, 'claude', {
|
||||
accessToken: access_token,
|
||||
@@ -228,6 +319,21 @@ class ClaudeAccountService {
|
||||
accountData.status = 'error'
|
||||
accountData.errorMessage = error.message
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
platform: 'claude-oauth',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_OAUTH_ERROR',
|
||||
reason: `Token refresh failed: ${error.message}`
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error)
|
||||
@@ -343,6 +449,15 @@ class ClaudeAccountService {
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
lastRefreshAt: account.lastRefreshAt,
|
||||
expiresAt: account.expiresAt,
|
||||
// 添加 scopes 字段用于判断认证方式
|
||||
// 处理空字符串的情况,避免返回 ['']
|
||||
scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [],
|
||||
// 添加 refreshToken 是否存在的标记(不返回实际值)
|
||||
hasRefreshToken: !!account.refreshToken,
|
||||
// 添加套餐信息(如果存在)
|
||||
subscriptionInfo: account.subscriptionInfo
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: null,
|
||||
// 添加限流状态信息
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
@@ -393,7 +508,8 @@ class ClaudeAccountService {
|
||||
'claudeAiOauth',
|
||||
'accountType',
|
||||
'priority',
|
||||
'schedulable'
|
||||
'schedulable',
|
||||
'subscriptionInfo'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
|
||||
@@ -408,6 +524,9 @@ class ClaudeAccountService {
|
||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||
} else if (field === 'priority') {
|
||||
updatedData[field] = value.toString()
|
||||
} else if (field === 'subscriptionInfo') {
|
||||
// 处理订阅信息更新
|
||||
updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
} else if (field === 'claudeAiOauth') {
|
||||
// 更新 Claude AI OAuth 数据
|
||||
if (value) {
|
||||
@@ -453,6 +572,26 @@ class ClaudeAccountService {
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString()
|
||||
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && accountData.isActive === 'true') {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updatedData.name || 'Unknown Account',
|
||||
platform: 'claude-oauth',
|
||||
status: 'disabled',
|
||||
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
|
||||
reason: 'Account manually disabled by administrator'
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error(
|
||||
'Failed to send webhook notification for manual account disable:',
|
||||
webhookError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await redis.setClaudeAccount(accountId, updatedData)
|
||||
|
||||
logger.success(`📝 Updated Claude account: ${accountId}`)
|
||||
@@ -482,15 +621,43 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 智能选择可用账户(支持sticky会话)
|
||||
async selectAvailableAccount(sessionHash = null) {
|
||||
// 🎯 智能选择可用账户(支持sticky会话和模型过滤)
|
||||
async selectAvailableAccount(sessionHash = null, modelName = null) {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
|
||||
const activeAccounts = accounts.filter(
|
||||
let activeAccounts = accounts.filter(
|
||||
(account) => account.isActive === 'true' && account.status !== 'error'
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
activeAccounts = activeAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
return true
|
||||
})
|
||||
|
||||
if (activeAccounts.length === 0) {
|
||||
throw new Error('No Claude accounts available that support Opus model')
|
||||
}
|
||||
}
|
||||
|
||||
if (activeAccounts.length === 0) {
|
||||
throw new Error('No active Claude accounts available')
|
||||
}
|
||||
@@ -541,8 +708,8 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 基于API Key选择账户(支持专属绑定和共享池)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null) {
|
||||
// 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) {
|
||||
try {
|
||||
// 如果API Key绑定了专属账户,优先使用
|
||||
if (apiKeyData.claudeAccountId) {
|
||||
@@ -562,13 +729,41 @@ class ClaudeAccountService {
|
||||
// 如果没有绑定账户或绑定账户不可用,从共享池选择
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
|
||||
const sharedAccounts = accounts.filter(
|
||||
let sharedAccounts = accounts.filter(
|
||||
(account) =>
|
||||
account.isActive === 'true' &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) // 兼容旧数据
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
sharedAccounts = sharedAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
return true
|
||||
})
|
||||
|
||||
if (sharedAccounts.length === 0) {
|
||||
throw new Error('No shared Claude accounts available that support Opus model')
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedAccounts.length === 0) {
|
||||
throw new Error('No active shared Claude accounts available')
|
||||
}
|
||||
@@ -715,7 +910,16 @@ class ClaudeAccountService {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
let decrypted = ''
|
||||
|
||||
// 检查是否是新格式(包含IV)
|
||||
if (encryptedData.includes(':')) {
|
||||
// 新格式:iv:encryptedData
|
||||
@@ -726,8 +930,17 @@ class ClaudeAccountService {
|
||||
const encrypted = parts[1]
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
|
||||
this._decryptCache.printStats()
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
}
|
||||
@@ -736,8 +949,12 @@ class ClaudeAccountService {
|
||||
// 注意:在新版本Node.js中这将失败,但我们会捕获错误
|
||||
try {
|
||||
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey)
|
||||
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
||||
decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 旧格式也存入缓存
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} catch (oldError) {
|
||||
// 如果旧方式也失败,返回原数据
|
||||
@@ -752,7 +969,20 @@ class ClaudeAccountService {
|
||||
|
||||
// 🔑 生成加密密钥(辅助方法)
|
||||
_generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32)
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
// scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解)
|
||||
// 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用
|
||||
if (!this._encryptionKeyCache) {
|
||||
// 只在第一次调用时计算,后续使用缓存
|
||||
// 由于输入参数固定,派生结果永远相同,不影响数据兼容性
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
logger.info('🔑 Encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🎭 掩码邮箱地址
|
||||
@@ -1117,6 +1347,199 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取账号 Profile 信息并更新账号类型
|
||||
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 检查账户是否有 user:profile 权限
|
||||
const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile')
|
||||
if (!hasProfileScope) {
|
||||
logger.warn(
|
||||
`⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile`
|
||||
)
|
||||
throw new Error('Account does not have user:profile permission')
|
||||
}
|
||||
|
||||
// 如果没有提供 accessToken,使用账号存储的 token
|
||||
if (!accessToken) {
|
||||
accessToken = this._decryptSensitiveData(accountData.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token available')
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有提供 agent,创建代理
|
||||
if (!agent) {
|
||||
agent = this._createProxyAgent(accountData.proxy)
|
||||
}
|
||||
|
||||
logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
// 请求 profile 接口
|
||||
const response = await axios.get('https://api.anthropic.com/api/oauth/profile', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
},
|
||||
httpsAgent: agent,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const profileData = response.data
|
||||
|
||||
logger.info('✅ Successfully fetched profile data:', {
|
||||
email: profileData.account?.email,
|
||||
hasClaudeMax: profileData.account?.has_claude_max,
|
||||
hasClaudePro: profileData.account?.has_claude_pro,
|
||||
organizationType: profileData.organization?.organization_type
|
||||
})
|
||||
|
||||
// 构建订阅信息
|
||||
const subscriptionInfo = {
|
||||
// 账号信息
|
||||
email: profileData.account?.email,
|
||||
fullName: profileData.account?.full_name,
|
||||
displayName: profileData.account?.display_name,
|
||||
hasClaudeMax: profileData.account?.has_claude_max || false,
|
||||
hasClaudePro: profileData.account?.has_claude_pro || false,
|
||||
accountUuid: profileData.account?.uuid,
|
||||
|
||||
// 组织信息
|
||||
organizationName: profileData.organization?.name,
|
||||
organizationUuid: profileData.organization?.uuid,
|
||||
billingType: profileData.organization?.billing_type,
|
||||
rateLimitTier: profileData.organization?.rate_limit_tier,
|
||||
organizationType: profileData.organization?.organization_type,
|
||||
|
||||
// 账号类型(基于 has_claude_max 和 has_claude_pro 判断)
|
||||
accountType:
|
||||
profileData.account?.has_claude_max === true
|
||||
? 'claude_max'
|
||||
: profileData.account?.has_claude_pro === true
|
||||
? 'claude_pro'
|
||||
: 'free',
|
||||
|
||||
// 更新时间
|
||||
profileFetchedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 更新账户数据
|
||||
accountData.subscriptionInfo = JSON.stringify(subscriptionInfo)
|
||||
accountData.profileUpdatedAt = new Date().toISOString()
|
||||
|
||||
// 如果提供了邮箱,更新邮箱字段
|
||||
if (profileData.account?.email) {
|
||||
accountData.email = this._encryptSensitiveData(profileData.account.email)
|
||||
}
|
||||
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
logger.success(
|
||||
`✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}`
|
||||
)
|
||||
|
||||
return subscriptionInfo
|
||||
} else {
|
||||
throw new Error(`Failed to fetch profile with status: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`)
|
||||
} else if (error.response?.status === 403) {
|
||||
logger.warn(
|
||||
`⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions`
|
||||
)
|
||||
} else {
|
||||
logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 手动更新所有账号的 Profile 信息
|
||||
async updateAllAccountProfiles() {
|
||||
try {
|
||||
logger.info('🔄 Starting batch profile update for all accounts...')
|
||||
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
const results = []
|
||||
|
||||
for (const account of accounts) {
|
||||
// 跳过未激活或错误状态的账号
|
||||
if (account.isActive !== 'true' || account.status === 'error') {
|
||||
logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过没有 user:profile 权限的账号(Setup Token 账号)
|
||||
const hasProfileScope = account.scopes && account.scopes.includes('user:profile')
|
||||
if (!hasProfileScope) {
|
||||
logger.info(
|
||||
`⏩ Skipping account without user:profile scope: ${account.name} (${account.id})`
|
||||
)
|
||||
results.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
success: false,
|
||||
error: 'No user:profile permission (Setup Token account)'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取有效的 access token
|
||||
const accessToken = await this.getValidAccessToken(account.id)
|
||||
if (accessToken) {
|
||||
const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken)
|
||||
successCount++
|
||||
results.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
success: true,
|
||||
accountType: profileInfo.accountType
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount++
|
||||
results.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
logger.warn(
|
||||
`⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 添加延迟以避免触发限流
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
results
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update account profiles:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 初始化所有账户的会话窗口(从历史数据恢复)
|
||||
async initializeSessionWindows(forceRecalculate = false) {
|
||||
try {
|
||||
@@ -1223,6 +1646,21 @@ class ClaudeAccountService {
|
||||
`⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling`
|
||||
)
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
platform: 'claude-oauth',
|
||||
status: 'unauthorized',
|
||||
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||||
reason: 'Account unauthorized (401 errors detected)'
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error)
|
||||
|
||||
@@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class ClaudeConsoleAccountService {
|
||||
constructor() {
|
||||
@@ -15,6 +16,25 @@ class ClaudeConsoleAccountService {
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'claude_console_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info(
|
||||
'🧹 Claude Console decrypt cache cleanup completed',
|
||||
this._decryptCache.getStats()
|
||||
)
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建Claude Console账户
|
||||
@@ -261,6 +281,26 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString()
|
||||
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === false && existingAccount.isActive === true) {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updatedData.name || existingAccount.name || 'Unknown Account',
|
||||
platform: 'claude-console',
|
||||
status: 'disabled',
|
||||
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
|
||||
reason: 'Account manually disabled by administrator'
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error(
|
||||
'Failed to send webhook notification for manual account disable:',
|
||||
webhookError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`)
|
||||
logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
@@ -403,6 +443,9 @@ class ClaudeConsoleAccountService {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取账户信息用于webhook通知
|
||||
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
const updates = {
|
||||
status: 'blocked',
|
||||
errorMessage: reason,
|
||||
@@ -412,6 +455,24 @@ class ClaudeConsoleAccountService {
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`)
|
||||
|
||||
// 发送Webhook通知
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Unknown Account',
|
||||
platform: 'claude-console',
|
||||
status: 'blocked',
|
||||
errorCode: 'CLAUDE_CONSOLE_BLOCKED',
|
||||
reason
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error)
|
||||
@@ -471,6 +532,13 @@ class ClaudeConsoleAccountService {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
if (encryptedData.includes(':')) {
|
||||
const parts = encryptedData.split(':')
|
||||
@@ -482,6 +550,15 @@ class ClaudeConsoleAccountService {
|
||||
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)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
|
||||
this._decryptCache.printStats()
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
}
|
||||
@@ -495,7 +572,20 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32)
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
// scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解)
|
||||
// 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用
|
||||
if (!this._encryptionKeyCache) {
|
||||
// 只在第一次调用时计算,后续使用缓存
|
||||
// 由于输入参数固定,派生结果永远相同,不影响数据兼容性
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
logger.info('🔑 Console encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🎭 掩码API URL
|
||||
|
||||
@@ -451,6 +451,23 @@ class ClaudeConsoleRelayService {
|
||||
collectedUsageData.cache_read_input_tokens =
|
||||
data.message.usage.cache_read_input_tokens || 0
|
||||
collectedUsageData.model = data.message.model
|
||||
|
||||
// 检查是否有详细的 cache_creation 对象
|
||||
if (
|
||||
data.message.usage.cache_creation &&
|
||||
typeof data.message.usage.cache_creation === 'object'
|
||||
) {
|
||||
collectedUsageData.cache_creation = {
|
||||
ephemeral_5m_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
|
||||
ephemeral_1h_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
logger.info(
|
||||
'📊 Collected detailed cache creation data:',
|
||||
JSON.stringify(collectedUsageData.cache_creation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -269,17 +269,33 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 记录成功的API调用
|
||||
const inputTokens = requestBody.messages
|
||||
? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4
|
||||
: 0 // 粗略估算
|
||||
const outputTokens = response.content
|
||||
? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4
|
||||
: 0
|
||||
// 记录成功的API调用并打印详细的usage数据
|
||||
let responseBody = null
|
||||
try {
|
||||
responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse response body for usage logging')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`
|
||||
)
|
||||
if (responseBody && responseBody.usage) {
|
||||
const { usage } = responseBody
|
||||
// 打印原始usage数据为JSON字符串
|
||||
logger.info(
|
||||
`📊 === Non-Stream Request Usage Summary === Model: ${requestBody.model}, Usage: ${JSON.stringify(usage)}`
|
||||
)
|
||||
} else {
|
||||
// 如果没有usage数据,使用估算值
|
||||
const inputTokens = requestBody.messages
|
||||
? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4
|
||||
: 0
|
||||
const outputTokens = response.content
|
||||
? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4
|
||||
: 0
|
||||
|
||||
logger.info(
|
||||
`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens (estimated), Output: ~${Math.round(outputTokens)} tokens (estimated)`
|
||||
)
|
||||
}
|
||||
|
||||
// 在响应中添加accountId,以便调用方记录账户级别统计
|
||||
response.accountId = accountId
|
||||
@@ -893,8 +909,8 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
let finalUsageReported = false // 防止重复统计的标志
|
||||
const collectedUsageData = {} // 收集来自不同事件的usage数据
|
||||
const allUsageData = [] // 收集所有的usage事件
|
||||
let currentUsageData = {} // 当前正在收集的usage数据
|
||||
let rateLimitDetected = false // 限流检测标志
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
@@ -931,17 +947,43 @@ class ClaudeRelayService {
|
||||
|
||||
// 收集来自不同事件的usage数据
|
||||
if (data.type === 'message_start' && data.message && data.message.usage) {
|
||||
// message_start包含input tokens、cache tokens和模型信息
|
||||
collectedUsageData.input_tokens = data.message.usage.input_tokens || 0
|
||||
collectedUsageData.cache_creation_input_tokens =
|
||||
data.message.usage.cache_creation_input_tokens || 0
|
||||
collectedUsageData.cache_read_input_tokens =
|
||||
data.message.usage.cache_read_input_tokens || 0
|
||||
collectedUsageData.model = data.message.model
|
||||
// 新的消息开始,如果之前有数据,先保存
|
||||
if (
|
||||
currentUsageData.input_tokens !== undefined &&
|
||||
currentUsageData.output_tokens !== undefined
|
||||
) {
|
||||
allUsageData.push({ ...currentUsageData })
|
||||
currentUsageData = {}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
// message_start包含input tokens、cache tokens和模型信息
|
||||
currentUsageData.input_tokens = data.message.usage.input_tokens || 0
|
||||
currentUsageData.cache_creation_input_tokens =
|
||||
data.message.usage.cache_creation_input_tokens || 0
|
||||
currentUsageData.cache_read_input_tokens =
|
||||
data.message.usage.cache_read_input_tokens || 0
|
||||
currentUsageData.model = data.message.model
|
||||
|
||||
// 检查是否有详细的 cache_creation 对象
|
||||
if (
|
||||
data.message.usage.cache_creation &&
|
||||
typeof data.message.usage.cache_creation === 'object'
|
||||
) {
|
||||
currentUsageData.cache_creation = {
|
||||
ephemeral_5m_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
|
||||
ephemeral_1h_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
logger.debug(
|
||||
'📊 Collected detailed cache creation data:',
|
||||
JSON.stringify(currentUsageData.cache_creation)
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'📊 Collected input/cache data from message_start:',
|
||||
JSON.stringify(collectedUsageData)
|
||||
JSON.stringify(currentUsageData)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -951,18 +993,27 @@ class ClaudeRelayService {
|
||||
data.usage &&
|
||||
data.usage.output_tokens !== undefined
|
||||
) {
|
||||
collectedUsageData.output_tokens = data.usage.output_tokens || 0
|
||||
currentUsageData.output_tokens = data.usage.output_tokens || 0
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
'📊 Collected output data from message_delta:',
|
||||
JSON.stringify(collectedUsageData)
|
||||
JSON.stringify(currentUsageData)
|
||||
)
|
||||
|
||||
// 如果已经收集到了input数据,现在有了output数据,可以统计了
|
||||
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
|
||||
logger.info('🎯 Complete usage data collected, triggering callback')
|
||||
usageCallback(collectedUsageData)
|
||||
finalUsageReported = true
|
||||
// 如果已经收集到了input数据和output数据,这是一个完整的usage
|
||||
if (currentUsageData.input_tokens !== undefined) {
|
||||
logger.debug(
|
||||
'🎯 Complete usage data collected for model:',
|
||||
currentUsageData.model,
|
||||
'- Input:',
|
||||
currentUsageData.input_tokens,
|
||||
'Output:',
|
||||
currentUsageData.output_tokens
|
||||
)
|
||||
// 保存到列表中,但不立即触发回调
|
||||
allUsageData.push({ ...currentUsageData })
|
||||
// 重置当前数据,准备接收下一个
|
||||
currentUsageData = {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,11 +1071,73 @@ class ClaudeRelayService {
|
||||
logger.error('❌ Error processing stream end:', error)
|
||||
}
|
||||
|
||||
// 如果还有未完成的usage数据,尝试保存
|
||||
if (currentUsageData.input_tokens !== undefined) {
|
||||
if (currentUsageData.output_tokens === undefined) {
|
||||
currentUsageData.output_tokens = 0 // 如果没有output,设为0
|
||||
}
|
||||
allUsageData.push(currentUsageData)
|
||||
}
|
||||
|
||||
// 检查是否捕获到usage数据
|
||||
if (!finalUsageReported) {
|
||||
if (allUsageData.length === 0) {
|
||||
logger.warn(
|
||||
'⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.'
|
||||
)
|
||||
} else {
|
||||
// 打印此次请求的所有usage数据汇总
|
||||
const totalUsage = allUsageData.reduce(
|
||||
(acc, usage) => ({
|
||||
input_tokens: (acc.input_tokens || 0) + (usage.input_tokens || 0),
|
||||
output_tokens: (acc.output_tokens || 0) + (usage.output_tokens || 0),
|
||||
cache_creation_input_tokens:
|
||||
(acc.cache_creation_input_tokens || 0) + (usage.cache_creation_input_tokens || 0),
|
||||
cache_read_input_tokens:
|
||||
(acc.cache_read_input_tokens || 0) + (usage.cache_read_input_tokens || 0),
|
||||
models: [...(acc.models || []), usage.model].filter(Boolean)
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
||||
logger.info(
|
||||
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||
)
|
||||
|
||||
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
||||
// 计算总的usage
|
||||
const finalUsage = {
|
||||
input_tokens: totalUsage.input_tokens,
|
||||
output_tokens: totalUsage.output_tokens,
|
||||
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
||||
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
|
||||
}
|
||||
|
||||
// 如果有详细的cache_creation数据,合并它们
|
||||
let totalEphemeral5m = 0
|
||||
let totalEphemeral1h = 0
|
||||
allUsageData.forEach((usage) => {
|
||||
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
|
||||
totalEphemeral5m += usage.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
totalEphemeral1h += usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有详细的缓存数据,添加到finalUsage
|
||||
if (totalEphemeral5m > 0 || totalEphemeral1h > 0) {
|
||||
finalUsage.cache_creation = {
|
||||
ephemeral_5m_input_tokens: totalEphemeral5m,
|
||||
ephemeral_1h_input_tokens: totalEphemeral1h
|
||||
}
|
||||
logger.info(
|
||||
'📊 Detailed cache creation breakdown:',
|
||||
JSON.stringify(finalUsage.cache_creation)
|
||||
)
|
||||
}
|
||||
|
||||
// 调用一次usageCallback记录合并后的数据
|
||||
usageCallback(finalUsage)
|
||||
}
|
||||
|
||||
// 处理限流状态
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
||||
@@ -24,9 +25,20 @@ const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
|
||||
// Gemini 账户键前缀
|
||||
@@ -52,6 +64,14 @@ function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
@@ -63,13 +83,32 @@ function decrypt(text) {
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
return decrypted.toString()
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
// 创建 OAuth2 客户端
|
||||
function createOAuth2Client(redirectUri = null) {
|
||||
// 如果没有提供 redirectUri,使用默认值
|
||||
@@ -291,7 +330,8 @@ async function createAccount(accountData) {
|
||||
accessToken: accessToken ? encrypt(accessToken) : '',
|
||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||
expiresAt,
|
||||
scopes: accountData.scopes || OAUTH_SCOPES.join(' '),
|
||||
// 只有OAuth方式才有scopes,手动添加的没有
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
||||
|
||||
// 代理设置
|
||||
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
|
||||
@@ -455,6 +495,23 @@ async function updateAccount(accountId, updates) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && existingAccount.isActive !== 'false') {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updates.name || existingAccount.name || 'Unknown Account',
|
||||
platform: 'gemini',
|
||||
status: 'disabled',
|
||||
errorCode: 'GEMINI_MANUALLY_DISABLED',
|
||||
reason: 'Account manually disabled by administrator'
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification for manual account disable:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
logger.info(`Updated Gemini account: ${accountId}`)
|
||||
@@ -534,6 +591,12 @@ async function getAllAccounts() {
|
||||
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
// 添加 scopes 字段用于判断认证方式
|
||||
// 处理空字符串和默认值的情况
|
||||
scopes:
|
||||
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
|
||||
// 添加 hasRefreshToken 标记
|
||||
hasRefreshToken: !!accountData.refreshToken,
|
||||
// 添加限流状态信息(统一格式)
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
@@ -764,6 +827,21 @@ async function refreshAccountToken(accountId) {
|
||||
status: 'error',
|
||||
errorMessage: error.message
|
||||
})
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
platform: 'gemini',
|
||||
status: 'error',
|
||||
errorCode: 'GEMINI_ERROR',
|
||||
reason: `Token refresh failed: ${error.message}`
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
} catch (updateError) {
|
||||
logger.error('Failed to update account status after refresh error:', updateError)
|
||||
}
|
||||
@@ -947,7 +1025,12 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
|
||||
metadata: clientMetadata
|
||||
}
|
||||
|
||||
logger.info('📋 开始onboardUser API调用', { tierId, projectId })
|
||||
logger.info('📋 开始onboardUser API调用', {
|
||||
tierId,
|
||||
projectId,
|
||||
hasProjectId: !!projectId,
|
||||
isFreeTier: tierId === 'free-tier' || tierId === 'FREE'
|
||||
})
|
||||
|
||||
// 轮询onboardUser直到长运行操作完成
|
||||
let lroRes = await axios({
|
||||
@@ -1209,6 +1292,10 @@ module.exports = {
|
||||
getOnboardTier,
|
||||
onboardUser,
|
||||
setupUser,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
countTokens,
|
||||
generateContent,
|
||||
generateContentStream,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent')
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
// const { maskToken } = require('../utils/tokenMask')
|
||||
@@ -11,6 +14,7 @@ const {
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
// const tokenRefreshService = require('./tokenRefreshService')
|
||||
|
||||
// 加密相关常量
|
||||
@@ -18,9 +22,20 @@ const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
|
||||
// OpenAI 账户键前缀
|
||||
@@ -46,6 +61,14 @@ function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
@@ -57,23 +80,112 @@ function decrypt(text) {
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
return decrypted.toString()
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
// 刷新访问令牌
|
||||
async function refreshAccessToken(_refreshToken) {
|
||||
async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
try {
|
||||
// OpenAI OAuth token 刷新实现
|
||||
// TODO: 实现具体的 OpenAI OAuth token 刷新逻辑
|
||||
logger.warn('OpenAI token refresh not yet implemented')
|
||||
return null
|
||||
// Codex CLI 的官方 CLIENT_ID
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
scope: 'openid profile email'
|
||||
}).toString()
|
||||
|
||||
// 配置请求选项
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
url: 'https://auth.openai.com/oauth/token',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': requestData.length
|
||||
},
|
||||
data: requestData,
|
||||
timeout: 30000 // 30秒超时
|
||||
}
|
||||
|
||||
// 配置代理(如果有)
|
||||
if (proxy && proxy.host && proxy.port) {
|
||||
if (proxy.type === 'socks5') {
|
||||
const proxyAuth =
|
||||
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}`
|
||||
requestOptions.httpsAgent = new SocksProxyAgent(socksProxy)
|
||||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||
const proxyAuth =
|
||||
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}`
|
||||
requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await axios(requestOptions)
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const result = response.data
|
||||
|
||||
logger.info('✅ Successfully refreshed OpenAI token')
|
||||
|
||||
// 返回新的 token 信息
|
||||
return {
|
||||
access_token: result.access_token,
|
||||
id_token: result.id_token,
|
||||
refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的
|
||||
expires_in: result.expires_in || 3600,
|
||||
expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error refreshing OpenAI access token:', error)
|
||||
throw error
|
||||
if (error.response) {
|
||||
// 服务器响应了错误状态码
|
||||
logger.error('OpenAI token refresh failed:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
headers: error.response.headers
|
||||
})
|
||||
throw new Error(
|
||||
`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
|
||||
)
|
||||
} 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}`)
|
||||
} else {
|
||||
// 设置请求时发生错误
|
||||
logger.error('OpenAI token refresh error:', error.message)
|
||||
throw new Error(`Token refresh failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,17 +214,41 @@ async function refreshAccountToken(accountId) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshToken)
|
||||
const newTokens = await refreshAccessToken(refreshToken, proxy)
|
||||
if (!newTokens) {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
await updateAccount(accountId, {
|
||||
// 准备更新数据
|
||||
const updates = {
|
||||
accessToken: encrypt(newTokens.access_token),
|
||||
expiresAt: new Date(newTokens.expiry_date).toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 如果有新的 ID token,也更新它
|
||||
if (newTokens.id_token) {
|
||||
updates.idToken = encrypt(newTokens.id_token)
|
||||
}
|
||||
|
||||
// 如果返回了新的 refresh token,更新它
|
||||
if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) {
|
||||
updates.refreshToken = encrypt(newTokens.refresh_token)
|
||||
logger.info(`Updated refresh token for account ${accountId}`)
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
await updateAccount(accountId, updates)
|
||||
|
||||
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
|
||||
return newTokens
|
||||
@@ -374,6 +510,12 @@ async function getAllAccounts() {
|
||||
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
// 添加 scopes 字段用于判断认证方式
|
||||
// 处理空字符串的情况
|
||||
scopes:
|
||||
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
|
||||
// 添加 hasRefreshToken 标记
|
||||
hasRefreshToken: !!accountData.refreshToken,
|
||||
// 添加限流状态信息(统一格式)
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
@@ -590,5 +732,7 @@ module.exports = {
|
||||
updateAccountUsage,
|
||||
recordUsage, // 别名,指向updateAccountUsage
|
||||
encrypt,
|
||||
decrypt
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache // 暴露缓存对象以便测试和监控
|
||||
}
|
||||
|
||||
@@ -20,6 +20,41 @@ class PricingService {
|
||||
this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
|
||||
this.fileWatcher = null // 文件监听器
|
||||
this.reloadDebounceTimer = null // 防抖定时器
|
||||
|
||||
// 硬编码的 1 小时缓存价格(美元/百万 token)
|
||||
// ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
|
||||
// ephemeral_1h 的价格需要硬编码
|
||||
this.ephemeral1hPricing = {
|
||||
// Opus 系列: $30/MTok
|
||||
'claude-opus-4-1': 0.00003,
|
||||
'claude-opus-4-1-20250805': 0.00003,
|
||||
'claude-opus-4': 0.00003,
|
||||
'claude-opus-4-20250514': 0.00003,
|
||||
'claude-3-opus': 0.00003,
|
||||
'claude-3-opus-latest': 0.00003,
|
||||
'claude-3-opus-20240229': 0.00003,
|
||||
|
||||
// Sonnet 系列: $6/MTok
|
||||
'claude-3-5-sonnet': 0.000006,
|
||||
'claude-3-5-sonnet-latest': 0.000006,
|
||||
'claude-3-5-sonnet-20241022': 0.000006,
|
||||
'claude-3-5-sonnet-20240620': 0.000006,
|
||||
'claude-3-sonnet': 0.000006,
|
||||
'claude-3-sonnet-20240307': 0.000006,
|
||||
'claude-sonnet-3': 0.000006,
|
||||
'claude-sonnet-3-5': 0.000006,
|
||||
'claude-sonnet-3-7': 0.000006,
|
||||
'claude-sonnet-4': 0.000006,
|
||||
|
||||
// Haiku 系列: $1.6/MTok
|
||||
'claude-3-5-haiku': 0.0000016,
|
||||
'claude-3-5-haiku-latest': 0.0000016,
|
||||
'claude-3-5-haiku-20241022': 0.0000016,
|
||||
'claude-3-haiku': 0.0000016,
|
||||
'claude-3-haiku-20240307': 0.0000016,
|
||||
'claude-haiku-3': 0.0000016,
|
||||
'claude-haiku-3-5': 0.0000016
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化价格服务
|
||||
@@ -258,6 +293,40 @@ class PricingService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取 1 小时缓存价格
|
||||
getEphemeral1hPricing(modelName) {
|
||||
if (!modelName) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 尝试直接匹配
|
||||
if (this.ephemeral1hPricing[modelName]) {
|
||||
return this.ephemeral1hPricing[modelName]
|
||||
}
|
||||
|
||||
// 处理各种模型名称变体
|
||||
const modelLower = modelName.toLowerCase()
|
||||
|
||||
// 检查是否是 Opus 系列
|
||||
if (modelLower.includes('opus')) {
|
||||
return 0.00003 // $30/MTok
|
||||
}
|
||||
|
||||
// 检查是否是 Sonnet 系列
|
||||
if (modelLower.includes('sonnet')) {
|
||||
return 0.000006 // $6/MTok
|
||||
}
|
||||
|
||||
// 检查是否是 Haiku 系列
|
||||
if (modelLower.includes('haiku')) {
|
||||
return 0.0000016 // $1.6/MTok
|
||||
}
|
||||
|
||||
// 默认返回 0(未知模型)
|
||||
logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算使用费用
|
||||
calculateCost(usage, modelName) {
|
||||
const pricing = this.getModelPricing(modelName)
|
||||
@@ -268,6 +337,8 @@ class PricingService {
|
||||
outputCost: 0,
|
||||
cacheCreateCost: 0,
|
||||
cacheReadCost: 0,
|
||||
ephemeral5mCost: 0,
|
||||
ephemeral1hCost: 0,
|
||||
totalCost: 0,
|
||||
hasPricing: false
|
||||
}
|
||||
@@ -275,23 +346,52 @@ class PricingService {
|
||||
|
||||
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
|
||||
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
|
||||
const cacheCreateCost =
|
||||
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
|
||||
const cacheReadCost =
|
||||
(usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
|
||||
|
||||
// 处理缓存创建费用:
|
||||
// 1. 如果有详细的 cache_creation 对象,使用它
|
||||
// 2. 否则使用总的 cache_creation_input_tokens(向后兼容)
|
||||
let ephemeral5mCost = 0
|
||||
let ephemeral1hCost = 0
|
||||
let cacheCreateCost = 0
|
||||
|
||||
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
|
||||
// 有详细的缓存创建数据
|
||||
const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
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)
|
||||
|
||||
// 1小时缓存使用硬编码的价格
|
||||
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
|
||||
ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice
|
||||
|
||||
// 总的缓存创建费用
|
||||
cacheCreateCost = ephemeral5mCost + ephemeral1hCost
|
||||
} else if (usage.cache_creation_input_tokens) {
|
||||
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
|
||||
cacheCreateCost =
|
||||
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
|
||||
ephemeral5mCost = cacheCreateCost
|
||||
}
|
||||
|
||||
return {
|
||||
inputCost,
|
||||
outputCost,
|
||||
cacheCreateCost,
|
||||
cacheReadCost,
|
||||
ephemeral5mCost,
|
||||
ephemeral1hCost,
|
||||
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
|
||||
hasPricing: true,
|
||||
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
|
||||
cacheRead: pricing.cache_read_input_token_cost || 0,
|
||||
ephemeral1h: this.getEphemeral1hPricing(modelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,35 @@ 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`)
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||
if (!isRateLimited) {
|
||||
|
||||
@@ -35,6 +35,28 @@ class UnifiedOpenAIScheduler {
|
||||
// 普通专属账户
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
if (boundAccount && 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)
|
||||
}
|
||||
|
||||
// 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查)
|
||||
if (
|
||||
requestedModel &&
|
||||
boundAccount.supportedModels &&
|
||||
boundAccount.supportedModels.length > 0
|
||||
) {
|
||||
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
|
||||
if (!modelSupported) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -45,9 +67,12 @@ class UnifiedOpenAIScheduler {
|
||||
accountType: 'openai'
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available, falling back to pool`
|
||||
)
|
||||
// 专属账户不可用时直接报错,不降级到共享池
|
||||
const errorMsg = boundAccount
|
||||
? `Dedicated account ${boundAccount.name} is not available (inactive or error status)`
|
||||
: `Dedicated account ${apiKeyData.openaiAccountId} not found`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +115,12 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
||||
})
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -109,7 +138,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
|
||||
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
|
||||
// 更新账户的最后使用时间
|
||||
@@ -125,49 +154,12 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户
|
||||
// 📋 获取所有可用账户(仅共享池)
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
if (apiKeyData.openaiAccountId) {
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持(仅在明确设置了supportedModels且不为空时才检查)
|
||||
// 如果没有设置supportedModels或为空数组,则支持所有模型
|
||||
if (
|
||||
requestedModel &&
|
||||
boundAccount.supportedModels &&
|
||||
boundAccount.supportedModels.length > 0
|
||||
) {
|
||||
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
|
||||
if (!modelSupported) {
|
||||
logger.warn(
|
||||
`⚠️ Bound OpenAI account ${boundAccount.name} does not support model ${requestedModel}`
|
||||
)
|
||||
return availableAccounts
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId})`
|
||||
)
|
||||
return [
|
||||
{
|
||||
...boundAccount,
|
||||
accountId: boundAccount.id,
|
||||
accountType: 'openai',
|
||||
priority: parseInt(boundAccount.priority) || 50,
|
||||
lastUsedAt: boundAccount.lastUsedAt || '0'
|
||||
}
|
||||
]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available`)
|
||||
}
|
||||
}
|
||||
// 注意:专属账户的处理已经在 selectAccountForApiKey 中完成
|
||||
// 这里只处理共享池账户
|
||||
|
||||
// 获取所有OpenAI账户(共享池)
|
||||
const openaiAccounts = await openaiAccountService.getAllAccounts()
|
||||
@@ -221,20 +213,20 @@ class UnifiedOpenAIScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
// 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序)
|
||||
// _sortAccountsByPriority(accounts) {
|
||||
// return accounts.sort((a, b) => {
|
||||
// // 首先按优先级排序(数字越小优先级越高)
|
||||
// if (a.priority !== b.priority) {
|
||||
// return a.priority - b.priority
|
||||
// }
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
// // 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
// const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
// const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
// return aLastUsed - bLastUsed
|
||||
// })
|
||||
// }
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
@@ -449,8 +441,12 @@ class UnifiedOpenAIScheduler {
|
||||
throw new Error(`No available accounts in group ${group.name}`)
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
||||
})
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -468,7 +464,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority}`
|
||||
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})`
|
||||
)
|
||||
|
||||
// 更新账户的最后使用时间
|
||||
|
||||
294
src/utils/cacheMonitor.js
Normal file
294
src/utils/cacheMonitor.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 缓存监控和管理工具
|
||||
* 提供统一的缓存监控、统计和安全清理功能
|
||||
*/
|
||||
|
||||
const logger = require('./logger')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class CacheMonitor {
|
||||
constructor() {
|
||||
this.monitors = new Map() // 存储所有被监控的缓存实例
|
||||
this.startTime = Date.now()
|
||||
this.totalHits = 0
|
||||
this.totalMisses = 0
|
||||
this.totalEvictions = 0
|
||||
|
||||
// 🔒 安全配置
|
||||
this.securityConfig = {
|
||||
maxCacheAge: 15 * 60 * 1000, // 最大缓存年龄 15 分钟
|
||||
forceCleanupInterval: 30 * 60 * 1000, // 强制清理间隔 30 分钟
|
||||
memoryThreshold: 100 * 1024 * 1024, // 内存阈值 100MB
|
||||
sensitiveDataPatterns: [/password/i, /token/i, /secret/i, /key/i, /credential/i]
|
||||
}
|
||||
|
||||
// 🧹 定期执行安全清理
|
||||
this.setupSecurityCleanup()
|
||||
|
||||
// 📊 定期报告统计信息
|
||||
this.setupPeriodicReporting()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册缓存实例进行监控
|
||||
* @param {string} name - 缓存名称
|
||||
* @param {LRUCache} cache - 缓存实例
|
||||
*/
|
||||
registerCache(name, cache) {
|
||||
if (this.monitors.has(name)) {
|
||||
logger.warn(`⚠️ Cache ${name} is already registered, updating reference`)
|
||||
}
|
||||
|
||||
this.monitors.set(name, {
|
||||
cache,
|
||||
registeredAt: Date.now(),
|
||||
lastCleanup: Date.now(),
|
||||
totalCleanups: 0
|
||||
})
|
||||
|
||||
logger.info(`📦 Registered cache for monitoring: ${name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有缓存的综合统计
|
||||
*/
|
||||
getGlobalStats() {
|
||||
const stats = {
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000), // 秒
|
||||
cacheCount: this.monitors.size,
|
||||
totalSize: 0,
|
||||
totalHits: 0,
|
||||
totalMisses: 0,
|
||||
totalEvictions: 0,
|
||||
averageHitRate: 0,
|
||||
caches: {}
|
||||
}
|
||||
|
||||
for (const [name, monitor] of this.monitors) {
|
||||
const cacheStats = monitor.cache.getStats()
|
||||
stats.totalSize += cacheStats.size
|
||||
stats.totalHits += cacheStats.hits
|
||||
stats.totalMisses += cacheStats.misses
|
||||
stats.totalEvictions += cacheStats.evictions
|
||||
|
||||
stats.caches[name] = {
|
||||
...cacheStats,
|
||||
lastCleanup: new Date(monitor.lastCleanup).toISOString(),
|
||||
totalCleanups: monitor.totalCleanups,
|
||||
age: Math.floor((Date.now() - monitor.registeredAt) / 1000) // 秒
|
||||
}
|
||||
}
|
||||
|
||||
const totalRequests = stats.totalHits + stats.totalMisses
|
||||
stats.averageHitRate =
|
||||
totalRequests > 0 ? `${((stats.totalHits / totalRequests) * 100).toFixed(2)}%` : '0%'
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 执行安全清理
|
||||
* 清理过期数据和潜在的敏感信息
|
||||
*/
|
||||
performSecurityCleanup() {
|
||||
logger.info('🔒 Starting security cleanup for all caches')
|
||||
|
||||
for (const [name, monitor] of this.monitors) {
|
||||
try {
|
||||
const { cache } = monitor
|
||||
const beforeSize = cache.cache.size
|
||||
|
||||
// 执行常规清理
|
||||
cache.cleanup()
|
||||
|
||||
// 检查缓存年龄,如果太老则完全清空
|
||||
const cacheAge = Date.now() - monitor.registeredAt
|
||||
if (cacheAge > this.securityConfig.maxCacheAge * 2) {
|
||||
logger.warn(
|
||||
`⚠️ Cache ${name} is too old (${Math.floor(cacheAge / 60000)}min), performing full clear`
|
||||
)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
monitor.lastCleanup = Date.now()
|
||||
monitor.totalCleanups++
|
||||
|
||||
const afterSize = cache.cache.size
|
||||
if (beforeSize !== afterSize) {
|
||||
logger.info(`🧹 Cache ${name}: Cleaned ${beforeSize - afterSize} items`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error cleaning cache ${name}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 生成详细报告
|
||||
*/
|
||||
generateReport() {
|
||||
const stats = this.getGlobalStats()
|
||||
|
||||
logger.info('═══════════════════════════════════════════')
|
||||
logger.info('📊 Cache System Performance Report')
|
||||
logger.info('═══════════════════════════════════════════')
|
||||
logger.info(`⏱️ Uptime: ${this.formatUptime(stats.uptime)}`)
|
||||
logger.info(`📦 Active Caches: ${stats.cacheCount}`)
|
||||
logger.info(`📈 Total Cache Size: ${stats.totalSize} items`)
|
||||
logger.info(`🎯 Global Hit Rate: ${stats.averageHitRate}`)
|
||||
logger.info(`✅ Total Hits: ${stats.totalHits.toLocaleString()}`)
|
||||
logger.info(`❌ Total Misses: ${stats.totalMisses.toLocaleString()}`)
|
||||
logger.info(`🗑️ Total Evictions: ${stats.totalEvictions.toLocaleString()}`)
|
||||
logger.info('───────────────────────────────────────────')
|
||||
|
||||
// 详细的每个缓存统计
|
||||
for (const [name, cacheStats] of Object.entries(stats.caches)) {
|
||||
logger.info(`\n📦 ${name}:`)
|
||||
logger.info(
|
||||
` Size: ${cacheStats.size}/${cacheStats.maxSize} | Hit Rate: ${cacheStats.hitRate}`
|
||||
)
|
||||
logger.info(
|
||||
` Hits: ${cacheStats.hits} | Misses: ${cacheStats.misses} | Evictions: ${cacheStats.evictions}`
|
||||
)
|
||||
logger.info(
|
||||
` Age: ${this.formatUptime(cacheStats.age)} | Cleanups: ${cacheStats.totalCleanups}`
|
||||
)
|
||||
}
|
||||
logger.info('═══════════════════════════════════════════')
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 设置定期安全清理
|
||||
*/
|
||||
setupSecurityCleanup() {
|
||||
// 每 10 分钟执行一次安全清理
|
||||
setInterval(
|
||||
() => {
|
||||
this.performSecurityCleanup()
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
// 每 30 分钟强制完整清理
|
||||
setInterval(() => {
|
||||
logger.warn('⚠️ Performing forced complete cleanup for security')
|
||||
for (const [name, monitor] of this.monitors) {
|
||||
monitor.cache.clear()
|
||||
logger.info(`🗑️ Force cleared cache: ${name}`)
|
||||
}
|
||||
}, this.securityConfig.forceCleanupInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 设置定期报告
|
||||
*/
|
||||
setupPeriodicReporting() {
|
||||
// 每 5 分钟生成一次简单统计
|
||||
setInterval(
|
||||
() => {
|
||||
const stats = this.getGlobalStats()
|
||||
logger.info(
|
||||
`📊 Quick Stats - Caches: ${stats.cacheCount}, Size: ${stats.totalSize}, Hit Rate: ${stats.averageHitRate}`
|
||||
)
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
|
||||
// 每 30 分钟生成一次详细报告
|
||||
setInterval(
|
||||
() => {
|
||||
this.generateReport()
|
||||
},
|
||||
30 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
*/
|
||||
formatUptime(seconds) {
|
||||
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 ${secs}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 生成安全的缓存键
|
||||
* 使用 SHA-256 哈希避免暴露原始数据
|
||||
*/
|
||||
static generateSecureCacheKey(data) {
|
||||
return crypto.createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 🛡️ 验证缓存数据安全性
|
||||
* 检查是否包含敏感信息
|
||||
*/
|
||||
validateCacheSecurity(data) {
|
||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
|
||||
for (const pattern of this.securityConfig.sensitiveDataPatterns) {
|
||||
if (pattern.test(dataStr)) {
|
||||
logger.warn('⚠️ Potential sensitive data detected in cache')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 💾 获取内存使用估算
|
||||
*/
|
||||
estimateMemoryUsage() {
|
||||
let totalBytes = 0
|
||||
|
||||
for (const [, monitor] of this.monitors) {
|
||||
const { cache } = monitor.cache
|
||||
for (const [key, item] of cache) {
|
||||
// 粗略估算:key 长度 + value 序列化长度
|
||||
totalBytes += key.length * 2 // UTF-16
|
||||
totalBytes += JSON.stringify(item).length * 2
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: totalBytes,
|
||||
mb: (totalBytes / (1024 * 1024)).toFixed(2),
|
||||
warning: totalBytes > this.securityConfig.memoryThreshold
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 紧急清理
|
||||
* 在内存压力大时使用
|
||||
*/
|
||||
emergencyCleanup() {
|
||||
logger.error('🚨 EMERGENCY CLEANUP INITIATED')
|
||||
|
||||
for (const [name, monitor] of this.monitors) {
|
||||
const { cache } = monitor
|
||||
const beforeSize = cache.cache.size
|
||||
|
||||
// 清理一半的缓存项(LRU 会保留最近使用的)
|
||||
const targetSize = Math.floor(cache.maxSize / 2)
|
||||
while (cache.cache.size > targetSize) {
|
||||
const firstKey = cache.cache.keys().next().value
|
||||
cache.cache.delete(firstKey)
|
||||
}
|
||||
|
||||
logger.warn(`🚨 Emergency cleaned ${name}: ${beforeSize} -> ${cache.cache.size} items`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new CacheMonitor()
|
||||
@@ -69,6 +69,12 @@ 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)
|
||||
}
|
||||
|
||||
// 否则使用旧的逻辑(向后兼容)
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const outputTokens = usage.output_tokens || 0
|
||||
const cacheCreateTokens = usage.cache_creation_input_tokens || 0
|
||||
|
||||
@@ -6,11 +6,13 @@ const fs = require('fs')
|
||||
const os = require('os')
|
||||
|
||||
// 安全的 JSON 序列化函数,处理循环引用
|
||||
const safeStringify = (obj, maxDepth = 3) => {
|
||||
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
const seen = new WeakSet()
|
||||
// 如果是fullDepth模式,增加深度限制
|
||||
const actualMaxDepth = fullDepth ? 10 : maxDepth
|
||||
|
||||
const replacer = (key, value, depth = 0) => {
|
||||
if (depth > maxDepth) {
|
||||
if (depth > actualMaxDepth) {
|
||||
return '[Max Depth Reached]'
|
||||
}
|
||||
|
||||
@@ -152,6 +154,21 @@ const securityLogger = winston.createLogger({
|
||||
silent: false
|
||||
})
|
||||
|
||||
// 🔐 创建专门的认证详细日志记录器(记录完整的认证响应)
|
||||
const authDetailLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ level, message, timestamp, data }) => {
|
||||
// 使用更深的深度和格式化的JSON输出
|
||||
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'
|
||||
return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}`
|
||||
})
|
||||
),
|
||||
transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')],
|
||||
silent: false
|
||||
})
|
||||
|
||||
// 🌟 增强的 Winston logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
@@ -327,6 +344,28 @@ logger.healthCheck = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 记录认证详细信息的方法
|
||||
logger.authDetail = (message, data = {}) => {
|
||||
try {
|
||||
// 记录到主日志(简化版)
|
||||
logger.info(`🔐 ${message}`, {
|
||||
type: 'auth-detail',
|
||||
summary: {
|
||||
hasAccessToken: !!data.access_token,
|
||||
hasRefreshToken: !!data.refresh_token,
|
||||
scopes: data.scope || data.scopes,
|
||||
organization: data.organization?.name,
|
||||
account: data.account?.email_address
|
||||
}
|
||||
})
|
||||
|
||||
// 记录到专门的认证详细日志文件(完整数据)
|
||||
authDetailLogger.info(message, { data })
|
||||
} catch (error) {
|
||||
logger.error('Failed to log auth detail:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🎬 启动日志记录系统
|
||||
logger.start('Logger initialized', {
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
|
||||
134
src/utils/lruCache.js
Normal file
134
src/utils/lruCache.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* LRU (Least Recently Used) 缓存实现
|
||||
* 用于缓存解密结果,提高性能同时控制内存使用
|
||||
*/
|
||||
class LRUCache {
|
||||
constructor(maxSize = 500) {
|
||||
this.maxSize = maxSize
|
||||
this.cache = new Map()
|
||||
this.hits = 0
|
||||
this.misses = 0
|
||||
this.evictions = 0
|
||||
this.lastCleanup = Date.now()
|
||||
this.cleanupInterval = 5 * 60 * 1000 // 5分钟清理一次过期项
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存值
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {*} 缓存的值,如果不存在则返回 undefined
|
||||
*/
|
||||
get(key) {
|
||||
// 定期清理
|
||||
if (Date.now() - this.lastCleanup > this.cleanupInterval) {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
const item = this.cache.get(key)
|
||||
if (!item) {
|
||||
this.misses++
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (item.expiry && Date.now() > item.expiry) {
|
||||
this.cache.delete(key)
|
||||
this.misses++
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 更新访问时间,将元素移到最后(最近使用)
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, {
|
||||
...item,
|
||||
lastAccessed: Date.now()
|
||||
})
|
||||
|
||||
this.hits++
|
||||
return item.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存值
|
||||
* @param {string} key - 缓存键
|
||||
* @param {*} value - 要缓存的值
|
||||
* @param {number} ttl - 生存时间(毫秒),默认5分钟
|
||||
*/
|
||||
set(key, value, ttl = 5 * 60 * 1000) {
|
||||
// 如果缓存已满,删除最少使用的项
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
const firstKey = this.cache.keys().next().value
|
||||
this.cache.delete(firstKey)
|
||||
this.evictions++
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
createdAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
expiry: ttl ? Date.now() + ttl : null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期项
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now()
|
||||
let cleanedCount = 0
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (item.expiry && now > item.expiry) {
|
||||
this.cache.delete(key)
|
||||
cleanedCount++
|
||||
}
|
||||
}
|
||||
|
||||
this.lastCleanup = now
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`🧹 LRU Cache: Cleaned ${cleanedCount} expired items`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存
|
||||
*/
|
||||
clear() {
|
||||
const { size } = this.cache
|
||||
this.cache.clear()
|
||||
this.hits = 0
|
||||
this.misses = 0
|
||||
this.evictions = 0
|
||||
console.log(`🗑️ LRU Cache: Cleared ${size} items`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const total = this.hits + this.misses
|
||||
const hitRate = total > 0 ? ((this.hits / total) * 100).toFixed(2) : 0
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
evictions: this.evictions,
|
||||
hitRate: `${hitRate}%`,
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印缓存统计信息
|
||||
*/
|
||||
printStats() {
|
||||
const stats = this.getStats()
|
||||
console.log(
|
||||
`📊 LRU Cache Stats: Size: ${stats.size}/${stats.maxSize}, Hit Rate: ${stats.hitRate}, Hits: ${stats.hits}, Misses: ${stats.misses}, Evictions: ${stats.evictions}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LRUCache
|
||||
@@ -16,7 +16,7 @@ const OAUTH_CONFIG = {
|
||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
||||
SCOPES: 'org:create_api_key user:profile user:inference',
|
||||
SCOPES_SETUP: 'user:inference'
|
||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,23 +203,55 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// 记录完整的响应数据到专门的认证详细日志
|
||||
logger.authDetail('OAuth token exchange response', response.data)
|
||||
|
||||
// 记录简化版本到主日志
|
||||
logger.info('📊 OAuth token exchange response (analyzing for subscription info):', {
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ OAuth token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
hasRefreshToken: !!response.data?.refresh_token,
|
||||
scopes: response.data?.scope
|
||||
scopes: response.data?.scope,
|
||||
// 尝试提取可能的套餐信息字段
|
||||
subscription: response.data?.subscription,
|
||||
plan: response.data?.plan,
|
||||
tier: response.data?.tier,
|
||||
accountType: response.data?.account_type,
|
||||
features: response.data?.features,
|
||||
limits: response.data?.limits
|
||||
})
|
||||
|
||||
const { data } = response
|
||||
|
||||
// 返回Claude格式的token数据
|
||||
return {
|
||||
// 返回Claude格式的token数据,包含可能的套餐信息
|
||||
const result = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000,
|
||||
scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'],
|
||||
isMax: true
|
||||
}
|
||||
|
||||
// 如果响应中包含套餐信息,添加到返回结果中
|
||||
if (data.subscription || data.plan || data.tier || data.account_type) {
|
||||
result.subscriptionInfo = {
|
||||
subscription: data.subscription,
|
||||
plan: data.plan,
|
||||
tier: data.tier,
|
||||
accountType: data.account_type,
|
||||
features: data.features,
|
||||
limits: data.limits
|
||||
}
|
||||
logger.info('🎯 Found subscription info in OAuth response:', result.subscriptionInfo)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// 处理axios错误响应
|
||||
if (error.response) {
|
||||
@@ -340,7 +372,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
|
||||
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
||||
code_verifier: codeVerifier,
|
||||
state,
|
||||
expires_in: 31536000
|
||||
expires_in: 31536000 // Setup Token 可以设置较长的过期时间
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
@@ -368,16 +400,54 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// 记录完整的响应数据到专门的认证详细日志
|
||||
logger.authDetail('Setup Token exchange response', response.data)
|
||||
|
||||
// 记录简化版本到主日志
|
||||
logger.info('📊 Setup Token exchange response (analyzing for subscription info):', {
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ Setup Token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
scopes: response.data?.scope,
|
||||
// 尝试提取可能的套餐信息字段
|
||||
subscription: response.data?.subscription,
|
||||
plan: response.data?.plan,
|
||||
tier: response.data?.tier,
|
||||
accountType: response.data?.account_type,
|
||||
features: response.data?.features,
|
||||
limits: response.data?.limits
|
||||
})
|
||||
|
||||
const { data } = response
|
||||
|
||||
// 返回Claude格式的token数据
|
||||
return {
|
||||
// 返回Claude格式的token数据,包含可能的套餐信息
|
||||
const result = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: '',
|
||||
expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000,
|
||||
scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'],
|
||||
isMax: true
|
||||
}
|
||||
|
||||
// 如果响应中包含套餐信息,添加到返回结果中
|
||||
if (data.subscription || data.plan || data.tier || data.account_type) {
|
||||
result.subscriptionInfo = {
|
||||
subscription: data.subscription,
|
||||
plan: data.plan,
|
||||
tier: data.tier,
|
||||
accountType: data.account_type,
|
||||
features: data.features,
|
||||
limits: data.limits
|
||||
}
|
||||
logger.info('🎯 Found subscription info in Setup Token response:', result.subscriptionInfo)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// 使用与标准OAuth相同的错误处理逻辑
|
||||
if (error.response) {
|
||||
|
||||
147
src/utils/webhookNotifier.js
Normal file
147
src/utils/webhookNotifier.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const axios = require('axios')
|
||||
const logger = require('./logger')
|
||||
const config = require('../../config/config')
|
||||
|
||||
class WebhookNotifier {
|
||||
constructor() {
|
||||
this.webhookUrls = config.webhook?.urls || []
|
||||
this.timeout = config.webhook?.timeout || 10000
|
||||
this.retries = config.webhook?.retries || 3
|
||||
this.enabled = config.webhook?.enabled !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送账号异常通知
|
||||
* @param {Object} notification - 通知内容
|
||||
* @param {string} notification.accountId - 账号ID
|
||||
* @param {string} notification.accountName - 账号名称
|
||||
* @param {string} notification.platform - 平台类型 (claude-oauth, claude-console, gemini)
|
||||
* @param {string} notification.status - 异常状态 (unauthorized, blocked, error)
|
||||
* @param {string} notification.errorCode - 异常代码
|
||||
* @param {string} notification.reason - 异常原因
|
||||
* @param {string} notification.timestamp - 时间戳
|
||||
*/
|
||||
async sendAccountAnomalyNotification(notification) {
|
||||
if (!this.enabled || this.webhookUrls.length === 0) {
|
||||
logger.debug('Webhook notification disabled or no URLs configured')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'account_anomaly',
|
||||
data: {
|
||||
accountId: notification.accountId,
|
||||
accountName: notification.accountName,
|
||||
platform: notification.platform,
|
||||
status: notification.status,
|
||||
errorCode: notification.errorCode,
|
||||
reason: notification.reason,
|
||||
timestamp: notification.timestamp || new Date().toISOString(),
|
||||
service: 'claude-relay-service'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📢 Sending account anomaly webhook notification: ${notification.accountName} (${notification.accountId}) - ${notification.status}`
|
||||
)
|
||||
|
||||
const promises = this.webhookUrls.map((url) => this._sendWebhook(url, payload))
|
||||
|
||||
try {
|
||||
await Promise.allSettled(promises)
|
||||
} catch (error) {
|
||||
logger.error('Failed to send webhook notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送Webhook请求
|
||||
* @param {string} url - Webhook URL
|
||||
* @param {Object} payload - 请求载荷
|
||||
*/
|
||||
async _sendWebhook(url, payload, attempt = 1) {
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
timeout: this.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'claude-relay-service/webhook-notifier'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
logger.info(`✅ Webhook sent successfully to ${url}`)
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to send webhook to ${url} (attempt ${attempt}/${this.retries}):`,
|
||||
error.message
|
||||
)
|
||||
|
||||
// 重试机制
|
||||
if (attempt < this.retries) {
|
||||
const delay = Math.pow(2, attempt - 1) * 1000 // 指数退避
|
||||
logger.info(`🔄 Retrying webhook to ${url} in ${delay}ms...`)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
return this._sendWebhook(url, payload, attempt + 1)
|
||||
}
|
||||
|
||||
logger.error(`💥 All ${this.retries} webhook attempts failed for ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试Webhook连通性
|
||||
* @param {string} url - Webhook URL
|
||||
*/
|
||||
async testWebhook(url) {
|
||||
const testPayload = {
|
||||
type: 'test',
|
||||
data: {
|
||||
message: 'Claude Relay Service webhook test',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'claude-relay-service'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this._sendWebhook(url, testPayload)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误代码映射
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {string} status - 状态
|
||||
* @param {string} _reason - 原因 (未使用)
|
||||
*/
|
||||
_getErrorCode(platform, status, _reason) {
|
||||
const errorCodes = {
|
||||
'claude-oauth': {
|
||||
unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||||
error: 'CLAUDE_OAUTH_ERROR',
|
||||
disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED'
|
||||
},
|
||||
'claude-console': {
|
||||
blocked: 'CLAUDE_CONSOLE_BLOCKED',
|
||||
error: 'CLAUDE_CONSOLE_ERROR',
|
||||
disabled: 'CLAUDE_CONSOLE_MANUALLY_DISABLED'
|
||||
},
|
||||
gemini: {
|
||||
error: 'GEMINI_ERROR',
|
||||
unauthorized: 'GEMINI_UNAUTHORIZED',
|
||||
disabled: 'GEMINI_MANUALLY_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
return errorCodes[platform]?.[status] || 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WebhookNotifier()
|
||||
@@ -555,14 +555,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude、Claude Console和Bedrock的优先级设置 -->
|
||||
<div
|
||||
v-if="
|
||||
form.platform === 'claude' ||
|
||||
form.platform === 'claude-console' ||
|
||||
form.platform === 'bedrock'
|
||||
"
|
||||
>
|
||||
<!-- Claude 订阅类型选择 -->
|
||||
<div v-if="form.platform === 'claude'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">订阅类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.subscriptionType"
|
||||
class="mr-2"
|
||||
type="radio"
|
||||
value="claude_max"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Claude Max</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.subscriptionType"
|
||||
class="mr-2"
|
||||
type="radio"
|
||||
value="claude_pro"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Claude Pro</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
Pro 账号不支持 Claude Opus 4 模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 所有平台的优先级设置 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700"
|
||||
>调度优先级 (1-100)</label
|
||||
>
|
||||
@@ -961,14 +984,37 @@
|
||||
<p class="mt-2 text-xs text-gray-500">Google Cloud/Workspace 账号可能需要提供项目 ID</p>
|
||||
</div>
|
||||
|
||||
<!-- Claude、Claude Console和Bedrock的优先级设置(编辑模式) -->
|
||||
<div
|
||||
v-if="
|
||||
form.platform === 'claude' ||
|
||||
form.platform === 'claude-console' ||
|
||||
form.platform === 'bedrock'
|
||||
"
|
||||
>
|
||||
<!-- Claude 订阅类型选择(编辑模式) -->
|
||||
<div v-if="form.platform === 'claude'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">订阅类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.subscriptionType"
|
||||
class="mr-2"
|
||||
type="radio"
|
||||
value="claude_max"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Claude Max</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.subscriptionType"
|
||||
class="mr-2"
|
||||
type="radio"
|
||||
value="claude_pro"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Claude Pro</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
Pro 账号不支持 Claude Opus 4 模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 所有平台的优先级设置(编辑模式) -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">调度优先级 (1-100)</label>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
@@ -1419,6 +1465,7 @@ const form = ref({
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
||||
groupId: '',
|
||||
projectId: props.account?.projectId || '',
|
||||
idToken: '',
|
||||
@@ -1678,12 +1725,21 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
// Claude使用claudeAiOauth字段
|
||||
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
|
||||
data.priority = form.value.priority || 50
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true // 标记为手动设置
|
||||
}
|
||||
} else if (form.value.platform === 'gemini') {
|
||||
// Gemini使用geminiOauth字段
|
||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||
if (form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
// 添加 Gemini 优先级
|
||||
data.priority = form.value.priority || 50
|
||||
} else if (form.value.platform === 'openai') {
|
||||
data.openaiOauth = tokenInfo.tokens || tokenInfo
|
||||
data.accountInfo = tokenInfo.accountInfo
|
||||
@@ -1803,9 +1859,16 @@ const createAccount = async () => {
|
||||
accessToken: form.value.accessToken,
|
||||
refreshToken: form.value.refreshToken || '',
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
scopes: ['user:inference']
|
||||
scopes: [] // 手动添加没有 scopes
|
||||
}
|
||||
data.priority = form.value.priority || 50
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true // 标记为手动设置
|
||||
}
|
||||
} else if (form.value.platform === 'gemini') {
|
||||
// Gemini手动模式需要构建geminiOauth对象
|
||||
const expiresInMs = form.value.refreshToken
|
||||
@@ -1823,6 +1886,9 @@ const createAccount = async () => {
|
||||
if (form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
// 添加 Gemini 优先级
|
||||
data.priority = form.value.priority || 50
|
||||
} else if (form.value.platform === 'openai') {
|
||||
// OpenAI手动模式需要构建openaiOauth对象
|
||||
const expiresInMs = form.value.refreshToken
|
||||
@@ -1985,7 +2051,7 @@ const updateAccount = async () => {
|
||||
accessToken: form.value.accessToken || '',
|
||||
refreshToken: form.value.refreshToken || '',
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
scopes: ['user:inference']
|
||||
scopes: props.account.scopes || [] // 保持原有的 scopes,如果没有则为空数组
|
||||
}
|
||||
} else if (props.account.platform === 'gemini') {
|
||||
// Gemini需要构建geminiOauth对象
|
||||
@@ -2019,9 +2085,16 @@ const updateAccount = async () => {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
// Claude 官方账号优先级更新
|
||||
// Claude 官方账号优先级和订阅类型更新
|
||||
if (props.account.platform === 'claude') {
|
||||
data.priority = form.value.priority || 50
|
||||
// 更新订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true // 标记为手动设置
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI 账号优先级更新
|
||||
@@ -2029,6 +2102,11 @@ const updateAccount = async () => {
|
||||
data.priority = form.value.priority || 50
|
||||
}
|
||||
|
||||
// Gemini 账号优先级更新
|
||||
if (props.account.platform === 'gemini') {
|
||||
data.priority = form.value.priority || 50
|
||||
}
|
||||
|
||||
// Claude Console 特定更新
|
||||
if (props.account.platform === 'claude-console') {
|
||||
data.apiUrl = form.value.apiUrl
|
||||
@@ -2319,12 +2397,32 @@ watch(
|
||||
groupId = newAccount.groupId || (newAccount.groupInfo && newAccount.groupInfo.id) || ''
|
||||
}
|
||||
|
||||
// 初始化订阅类型(从 subscriptionInfo 中提取,兼容旧数据默认为 claude_max)
|
||||
let subscriptionType = 'claude_max'
|
||||
if (newAccount.subscriptionInfo) {
|
||||
const info =
|
||||
typeof newAccount.subscriptionInfo === 'string'
|
||||
? JSON.parse(newAccount.subscriptionInfo)
|
||||
: newAccount.subscriptionInfo
|
||||
|
||||
if (info.accountType) {
|
||||
subscriptionType = info.accountType
|
||||
} else if (info.hasClaudeMax) {
|
||||
subscriptionType = 'claude_max'
|
||||
} else if (info.hasClaudePro) {
|
||||
subscriptionType = 'claude_pro'
|
||||
} else {
|
||||
subscriptionType = 'claude_free'
|
||||
}
|
||||
}
|
||||
|
||||
form.value = {
|
||||
platform: newAccount.platform,
|
||||
addType: 'oauth',
|
||||
name: newAccount.name,
|
||||
description: newAccount.description || '',
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
subscriptionType: subscriptionType,
|
||||
groupId: groupId,
|
||||
projectId: newAccount.projectId || '',
|
||||
accessToken: '',
|
||||
|
||||
@@ -436,6 +436,18 @@
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Bedrock 专属账号</label>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池
|
||||
@@ -618,6 +630,7 @@ const localAccounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: [],
|
||||
bedrock: [], // 添加 Bedrock 账号列表
|
||||
claudeGroups: [],
|
||||
geminiGroups: [],
|
||||
openaiGroups: []
|
||||
@@ -658,6 +671,7 @@ const form = reactive({
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
openaiAccountId: '',
|
||||
bedrockAccountId: '', // 添加 Bedrock 账号ID
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
@@ -676,6 +690,7 @@ onMounted(async () => {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
@@ -687,13 +702,15 @@ onMounted(async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, 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/account-groups')
|
||||
])
|
||||
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')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -734,6 +751,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
@@ -939,6 +963,11 @@ const createApiKey = async () => {
|
||||
baseData.openaiAccountId = form.openaiAccountId
|
||||
}
|
||||
|
||||
// Bedrock账户绑定
|
||||
if (form.bedrockAccountId) {
|
||||
baseData.bedrockAccountId = form.bedrockAccountId
|
||||
}
|
||||
|
||||
if (form.createType === 'single') {
|
||||
// 单个创建
|
||||
const data = {
|
||||
|
||||
@@ -339,6 +339,18 @@
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Bedrock 专属账号</label>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">修改绑定账号将影响此API Key的请求路由</p>
|
||||
</div>
|
||||
@@ -522,6 +534,7 @@ const localAccounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: [],
|
||||
bedrock: [], // 添加 Bedrock 账号列表
|
||||
claudeGroups: [],
|
||||
geminiGroups: [],
|
||||
openaiGroups: []
|
||||
@@ -551,6 +564,7 @@ const form = reactive({
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
openaiAccountId: '',
|
||||
bedrockAccountId: '', // 添加 Bedrock 账号ID
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
@@ -673,6 +687,13 @@ const updateApiKey = async () => {
|
||||
data.openaiAccountId = null
|
||||
}
|
||||
|
||||
// Bedrock账户绑定
|
||||
if (form.bedrockAccountId) {
|
||||
data.bedrockAccountId = form.bedrockAccountId
|
||||
} else {
|
||||
data.bedrockAccountId = null
|
||||
}
|
||||
|
||||
// 模型限制 - 始终提交这些字段
|
||||
data.enableModelRestriction = form.enableModelRestriction
|
||||
data.restrictedModels = form.restrictedModels
|
||||
@@ -703,13 +724,15 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, 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/account-groups')
|
||||
])
|
||||
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')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -750,6 +773,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
@@ -778,6 +808,7 @@ onMounted(async () => {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
@@ -799,6 +830,7 @@ onMounted(async () => {
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
form.tags = props.apiKey.tags || []
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
||||
<span class="mx-1 h-4 w-px bg-yellow-300" />
|
||||
<span class="text-xs font-medium text-yellow-700">
|
||||
{{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }}
|
||||
{{ getGeminiAuthType() }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -289,19 +289,28 @@
|
||||
<div class="fa-openai" />
|
||||
<span class="text-xs font-semibold text-gray-950">OpenAi</span>
|
||||
<span class="mx-1 h-4 w-px bg-gray-400" />
|
||||
<span class="text-xs font-medium text-gray-950">Oauth</span>
|
||||
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
|
||||
>
|
||||
<i class="fas fa-brain text-xs text-indigo-700" />
|
||||
<span class="text-xs font-semibold text-indigo-800">Claude</span>
|
||||
<span class="text-xs font-semibold text-indigo-800">{{
|
||||
getClaudeAccountType(account)
|
||||
}}</span>
|
||||
<span class="mx-1 h-4 w-px bg-indigo-300" />
|
||||
<span class="text-xs font-medium text-indigo-700">
|
||||
{{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }}
|
||||
{{ getClaudeAuthType(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
|
||||
>
|
||||
<i class="fas fa-question text-xs text-gray-700" />
|
||||
<span class="text-xs font-semibold text-gray-800">未知</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
@@ -382,7 +391,9 @@
|
||||
v-if="
|
||||
account.platform === 'claude' ||
|
||||
account.platform === 'claude-console' ||
|
||||
account.platform === 'bedrock'
|
||||
account.platform === 'bedrock' ||
|
||||
account.platform === 'gemini' ||
|
||||
account.platform === 'openai'
|
||||
"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
@@ -482,21 +493,6 @@
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-if="account.platform === 'claude' && account.scopes"
|
||||
:class="[
|
||||
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
account.isRefreshing
|
||||
? 'cursor-not-allowed bg-gray-100 text-gray-400'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
]"
|
||||
:disabled="account.isRefreshing"
|
||||
:title="account.isRefreshing ? '刷新中...' : '刷新Token'"
|
||||
@click="refreshToken(account)"
|
||||
>
|
||||
<i :class="['fas fa-sync-alt', account.isRefreshing ? 'animate-spin' : '']" />
|
||||
<span class="ml-1">刷新</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
account.platform === 'claude' &&
|
||||
@@ -700,23 +696,13 @@
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500">优先级</span>
|
||||
<span class="font-medium text-gray-700">
|
||||
{{ account.priority || 0 }}
|
||||
{{ account.priority || 50 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3">
|
||||
<button
|
||||
v-if="account.platform === 'claude' && account.type === 'oauth'"
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-600 transition-colors hover:bg-blue-100"
|
||||
:disabled="refreshingTokens[account.id]"
|
||||
@click="refreshAccountToken(account)"
|
||||
>
|
||||
<i :class="['fas fa-sync-alt', { 'animate-spin': refreshingTokens[account.id] }]" />
|
||||
{{ refreshingTokens[account.id] ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs transition-colors"
|
||||
:class="
|
||||
@@ -797,7 +783,6 @@ const accountSortBy = ref('name')
|
||||
const accountsSortBy = ref('')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([])
|
||||
const refreshingTokens = ref({})
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const platformFilter = ref('all')
|
||||
@@ -821,7 +806,7 @@ const platformOptions = ref([
|
||||
{ value: 'all', label: '所有平台', icon: 'fa-globe' },
|
||||
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
|
||||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-robot' },
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
|
||||
])
|
||||
@@ -1266,27 +1251,6 @@ const deleteAccount = async (account) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新Token
|
||||
const refreshToken = async (account) => {
|
||||
if (account.isRefreshing) return
|
||||
|
||||
try {
|
||||
account.isRefreshing = true
|
||||
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/refresh`)
|
||||
|
||||
if (data.success) {
|
||||
showToast('Token刷新成功', 'success')
|
||||
loadAccounts()
|
||||
} else {
|
||||
showToast(data.message || 'Token刷新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Token刷新失败', 'error')
|
||||
} finally {
|
||||
account.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置账户状态
|
||||
const resetAccountStatus = async (account) => {
|
||||
if (account.isResetting) return
|
||||
@@ -1378,6 +1342,66 @@ const handleEditSuccess = () => {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// 获取 Claude 账号的添加方式
|
||||
const getClaudeAuthType = (account) => {
|
||||
// 基于 lastRefreshAt 判断:如果为空说明是 Setup Token(不能刷新),否则是 OAuth
|
||||
if (!account.lastRefreshAt || account.lastRefreshAt === '') {
|
||||
return 'Setup' // 缩短显示文本
|
||||
}
|
||||
return 'OAuth'
|
||||
}
|
||||
|
||||
// 获取 Gemini 账号的添加方式
|
||||
const getGeminiAuthType = () => {
|
||||
// Gemini 统一显示 OAuth
|
||||
return 'OAuth'
|
||||
}
|
||||
|
||||
// 获取 OpenAI 账号的添加方式
|
||||
const getOpenAIAuthType = () => {
|
||||
// OpenAI 统一显示 OAuth
|
||||
return 'OAuth'
|
||||
}
|
||||
|
||||
// 获取 Claude 账号类型显示
|
||||
const getClaudeAccountType = (account) => {
|
||||
// 如果有订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
// 如果 subscriptionInfo 是字符串,尝试解析
|
||||
const info =
|
||||
typeof account.subscriptionInfo === 'string'
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// 添加调试日志
|
||||
console.log('Account subscription info:', {
|
||||
accountName: account.name,
|
||||
subscriptionInfo: info,
|
||||
hasClaudeMax: info.hasClaudeMax,
|
||||
hasClaudePro: info.hasClaudePro
|
||||
})
|
||||
|
||||
// 根据 has_claude_max 和 has_claude_pro 判断
|
||||
if (info.hasClaudeMax === true) {
|
||||
return 'Claude Max'
|
||||
} else if (info.hasClaudePro === true) {
|
||||
return 'Claude Pro'
|
||||
} else {
|
||||
return 'Claude Free'
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,返回默认值
|
||||
console.error('Failed to parse subscription info:', e)
|
||||
return 'Claude'
|
||||
}
|
||||
}
|
||||
|
||||
// 没有订阅信息,保持原有显示
|
||||
console.log('No subscription info for account:', account.name)
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
// 获取账户状态文本
|
||||
const getAccountStatusText = (account) => {
|
||||
// 检查是否被封锁
|
||||
@@ -1463,27 +1487,6 @@ const formatRelativeTime = (dateString) => {
|
||||
return formatLastUsed(dateString)
|
||||
}
|
||||
|
||||
// 刷新账户Token
|
||||
const refreshAccountToken = async (account) => {
|
||||
if (refreshingTokens.value[account.id]) return
|
||||
|
||||
try {
|
||||
refreshingTokens.value[account.id] = true
|
||||
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/refresh`)
|
||||
|
||||
if (data.success) {
|
||||
showToast('Token刷新成功', 'success')
|
||||
loadAccounts()
|
||||
} else {
|
||||
showToast(data.message || 'Token刷新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Token刷新失败', 'error')
|
||||
} finally {
|
||||
refreshingTokens.value[account.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换调度状态
|
||||
// const toggleDispatch = async (account) => {
|
||||
// await toggleSchedulable(account)
|
||||
|
||||
Reference in New Issue
Block a user