Merge remote-tracking branch 'f3n9/main' into user-management-new

This commit is contained in:
Feng Yue
2025-08-18 15:32:17 +08:00
34 changed files with 2797 additions and 335 deletions

View File

@@ -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
View File

@@ -9,7 +9,7 @@
[![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml)
[![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](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>

View File

@@ -1 +1 @@
1.1.107
1.1.114

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
})

View File

@@ -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 // 传递中止信号
)

View File

@@ -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
)

View File

@@ -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
View 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

View File

@@ -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')

View File

@@ -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凭证建议更新账户以启用加密')

View File

@@ -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)

View File

@@ -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

View File

@@ -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 (

View File

@@ -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)
}
// 处理限流状态

View File

@@ -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,

View File

@@ -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 // 暴露缓存对象以便测试和监控
}

View File

@@ -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)
}
}
}

View File

@@ -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) {

View File

@@ -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
View 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()

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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) {

View 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()

View File

@@ -555,14 +555,37 @@
</div>
</div>
<!-- ClaudeClaude 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: '',

View File

@@ -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 = {

View File

@@ -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 || []

View File

@@ -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)