diff --git a/.env.example b/.env.example index 04cf2a43..ca9aabbf 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 diff --git a/README.md b/README.md index 0542c9ed..82625103 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml) [](https://hub.docker.com/r/weishaw/claude-relay-service) -**🔐 自行搭建Claude API中转服务,支持多账户管理** +**🔐 自行搭建Claude API中转服务,支持多账户管理** [English](#english) • [中文文档](#中文文档) • [📸 界面预览](docs/preview.md) • [📢 公告频道](https://t.me/claude_relay_service) @@ -35,11 +35,11 @@ --- > 💡 **感谢 [@vista8](https://x.com/vista8) 的推荐!** -> +> > 如果你对Vibe coding感兴趣,推荐关注: -> +> > - 🐦 **X**: [@vista8](https://x.com/vista8) - 分享前沿技术动态 -> - 📱 **公众号**: 向阳乔木推荐看 +> - 📱 **公众号**: 向阳乔木推荐看 --- @@ -62,14 +62,14 @@ ✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容 ✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护 ✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站 -✅ **地区受限**: 无法直接访问Claude官方服务 +✅ **地区受限**: 无法直接访问Claude官方服务 ### 不适合的场景 ❌ **纯小白**: 完全不懂技术,连服务器都不会买 ❌ **偶尔使用**: 一个月用不了几次,没必要折腾 ❌ **注册问题**: 无法自行注册Claude账号 -❌ **支付问题**: 没有支付渠道订阅Claude Code +❌ **支付问题**: 没有支付渠道订阅Claude Code **如果你只是普通用户,对隐私要求不高,随便玩玩、想快速体验 Claude,那选个你熟知的镜像站会更合适。** @@ -77,7 +77,6 @@ ## 💭 为什么要自己搭? - ### 现有镜像站可能的问题 - 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了 @@ -98,11 +97,13 @@ > 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图 ### 基础功能 + - ✅ **多账户管理**: 可以添加多个Claude账户自动轮换 - ✅ **自定义API Key**: 给每个人分配独立的Key - ✅ **使用统计**: 详细记录每个人用了多少token ### 高级功能 + - 🔄 **智能切换**: 账户出问题自动换下一个 - 🚀 **性能优化**: 连接池、缓存,减少延迟 - 📊 **监控面板**: Web界面查看所有数据 @@ -114,6 +115,7 @@ ## 📋 部署要求 ### 硬件要求(最低配置) + - **CPU**: 1核心就够了 - **内存**: 512MB(建议1GB) - **硬盘**: 30GB可用空间 @@ -122,11 +124,13 @@ - **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api ### 软件要求 + - **Node.js** 18或更高版本 - **Redis** 6或更高版本 - **操作系统**: 建议Linux ### 费用估算 + - **服务器**: 轻量云服务器,一个月30-60块 - **Claude订阅**: 看你怎么分摊了 - **其他**: 域名(可选) @@ -174,11 +178,11 @@ crs uninstall # 卸载服务 $ crs install # 会依次询问: -安装目录 (默认: ~/claude-relay-service): +安装目录 (默认: ~/claude-relay-service): 服务端口 (默认: 3000): 8080 -Redis 地址 (默认: localhost): -Redis 端口 (默认: 6379): -Redis 密码 (默认: 无密码): +Redis 地址 (默认: localhost): +Redis 端口 (默认: 6379): +Redis 密码 (默认: 无密码): # 安装完成后自动启动并显示: 服务已成功安装并启动! @@ -203,6 +207,7 @@ Redis 密码 (默认: 无密码): ### 第一步:环境准备 **Ubuntu/Debian用户:** + ```bash # 安装Node.js curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - @@ -215,6 +220,7 @@ sudo systemctl start redis-server ``` **CentOS/RHEL用户:** + ```bash # 安装Node.js curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - @@ -243,6 +249,7 @@ cp .env.example .env ### 第三步:配置文件设置 **编辑 `.env` 文件:** + ```bash # 这两个密钥随便生成,但要记住 JWT_SECRET=你的超级秘密密钥 @@ -252,19 +259,26 @@ ENCRYPTION_KEY=32位的加密密钥随便写 REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= + +# Webhook通知配置(可选) +WEBHOOK_ENABLED=true +WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key +WEBHOOK_TIMEOUT=10000 +WEBHOOK_RETRIES=3 ``` **编辑 `config/config.js` 文件:** + ```javascript module.exports = { server: { - port: 3000, // 服务端口,可以改 - host: '0.0.0.0' // 不用改 + port: 3000, // 服务端口,可以改 + host: '0.0.0.0' // 不用改 }, redis: { - host: '127.0.0.1', // Redis地址 - port: 6379 // Redis端口 - }, + host: '127.0.0.1', // Redis地址 + port: 6379 // Redis端口 + } // 其他配置保持默认就行 } ``` @@ -372,6 +386,7 @@ docker-compose up -d ### Docker Compose 配置 docker-compose.yml 已包含: + - ✅ 自动初始化管理员账号 - ✅ 数据持久化(logs和data目录自动挂载) - ✅ Redis数据库 @@ -382,10 +397,12 @@ docker-compose.yml 已包含: ### 环境变量说明 #### 必填项 + - `JWT_SECRET`: JWT密钥,至少32个字符 - `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 #### 可选项 + - `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) - `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) - `LOG_LEVEL`: 日志级别(默认:info) @@ -394,11 +411,13 @@ docker-compose.yml 已包含: ### 管理员凭据获取方式 1. **查看容器日志** + ```bash docker logs claude-relay-service ``` 2. **查看挂载的文件** + ```bash cat ./data/init.json ``` @@ -419,6 +438,7 @@ docker-compose.yml 已包含: 浏览器访问:`http://你的服务器IP:3000/web` 管理员账号: + - 自动生成:查看 data/init.json - 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置 - Docker 部署:查看容器日志 `docker logs claude-relay-service` @@ -456,12 +476,14 @@ docker-compose.yml 已包含: 现在你可以用自己的服务替换官方API了: **Claude Code 设置环境变量:** + ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" ``` **Gemini CLI 设置环境变量:** + ```bash export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可 @@ -469,43 +491,49 @@ export GOOGLE_GENAI_USE_GCA="true" ``` **使用 Claude Code:** + ```bash claude ``` **使用 Gemini CLI:** + ```bash gemini # 或其他 Gemini CLI 命令 ``` **Codex 设置环境变量:** + ```bash export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥 ``` - ### 5. 第三方工具API接入 本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等): **Claude标准格式:** + ``` # 如果工具支持Claude标准格式,请使用该接口 -http://你的服务器:3000/claude/ +http://你的服务器:3000/claude/ ``` **OpenAI兼容格式:** + ``` # 适用于需要OpenAI格式的第三方工具 http://你的服务器:3000/openai/claude/v1/ ``` **接入示例:** + - **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses` - **其他支持自定义API的工具**: 根据工具要求选择合适的格式 **重要说明:** + - 所有格式都支持相同的功能,仅是路径不同 - `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages` - 选择适合你使用工具的格式即可 @@ -513,6 +541,67 @@ http://你的服务器:3000/openai/claude/v1/ --- +## 📢 Webhook 通知功能 + +### 功能说明 + +当系统检测到账号异常时,会自动发送 webhook 通知,支持企业微信、钉钉、Slack 等平台。 + +### 通知触发场景 + +- **Claude OAuth 账户**: token 过期或未授权时 +- **Claude Console 账户**: 系统检测到账户被封锁时 +- **Gemini 账户**: token 刷新失败时 +- **手动禁用账户**: 管理员手动禁用账户时 + +### 配置方法 + +**1. 环境变量配置** + +```bash +# 启用 webhook 通知 +WEBHOOK_ENABLED=true + +# 企业微信 webhook 地址(替换为你的实际地址) +WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key + +# 多个地址用逗号分隔 +WEBHOOK_URLS=https://webhook1.com,https://webhook2.com + +# 请求超时时间(毫秒,默认10秒) +WEBHOOK_TIMEOUT=10000 + +# 重试次数(默认3次) +WEBHOOK_RETRIES=3 +``` + +**2. 企业微信设置** + +1. 在企业微信群中添加「群机器人」 +2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx` +3. 将地址配置到 `WEBHOOK_URLS` 环境变量 + +### 通知内容格式 + +系统会发送结构化的通知消息: + +``` +账户名称 账号异常,异常代码 ERROR_CODE +平台:claude-oauth +时间:2025-08-14 17:30:00 +原因:Token expired +``` + +### 测试 Webhook + +可以通过管理后台测试 webhook 连通性: + +1. 登录管理后台:`http://你的服务器:3000/web` +2. 访问:`/admin/webhook/test` +3. 发送测试通知确认配置正确 + +--- + ## 🔧 日常维护 ### 服务管理 @@ -567,6 +656,7 @@ npm run service:status ``` **注意事项:** + - 升级前建议备份重要配置文件(.env, config/config.js) - 查看更新日志了解是否有破坏性变更 - 如果有数据库结构变更,会自动迁移 @@ -615,12 +705,14 @@ clientRestrictions: { ### 日志示例 认证成功时的日志: + ``` 🔓 Authenticated request from key: 测试Key (key-id) in 5ms User-Agent: "claude-cli/1.0.58 (external, cli)" ``` 客户端限制检查日志: + ``` 🔍 Checking client restriction for key: key-id (测试Key) User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" @@ -631,6 +723,7 @@ clientRestrictions: { ### 常见问题处理 **Redis连不上?** + ```bash # 检查Redis是否启动 redis-cli ping @@ -639,11 +732,13 @@ redis-cli ping ``` **OAuth授权失败?** + - 检查代理设置是否正确 - 确保能正常访问 claude.ai - 清除浏览器缓存重试 **API请求失败?** + - 检查API Key是否正确 - 查看日志文件找错误信息 - 确认Claude账户状态正常 @@ -652,7 +747,6 @@ redis-cli ping ## 🛠️ 进阶 - ### 生产环境部署建议(重要!) **强烈建议使用Caddy反向代理(自动HTTPS)** @@ -660,6 +754,7 @@ redis-cli ping 建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单: **1. 安装Caddy** + ```bash # Ubuntu/Debian sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https @@ -677,18 +772,19 @@ sudo yum install caddy **2. Caddy配置(超简单!)** 编辑 `/etc/caddy/Caddyfile`: + ``` your-domain.com { # 反向代理到本地服务 reverse_proxy 127.0.0.1:3000 { # 支持流式响应(SSE) flush_interval -1 - + # 传递真实IP header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} - + # 超时设置(适合长连接) transport http { read_timeout 300s @@ -696,7 +792,7 @@ your-domain.com { dial_timeout 30s } } - + # 安全头部 header { Strict-Transport-Security "max-age=31536000; includeSubDomains" @@ -708,6 +804,7 @@ your-domain.com { ``` **3. 启动Caddy** + ```bash # 测试配置 sudo caddy validate --config /etc/caddy/Caddyfile @@ -723,34 +820,37 @@ sudo systemctl status caddy **4. 更新服务配置** 修改你的服务配置,让它只监听本地: + ```javascript // config/config.js module.exports = { server: { port: 3000, - host: '127.0.0.1' // 只监听本地,通过nginx代理 + host: '127.0.0.1' // 只监听本地,通过nginx代理 } // ... 其他配置 } ``` **Caddy优势:** + - 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置 - 🛡️ **安全默认**: 默认启用现代安全协议和加密套件 - 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输 - 📊 **简单配置**: 配置文件极其简洁,易于维护 - ⚡ **HTTP/2**: 默认启用HTTP/2,提升传输性能 - --- ## 💡 使用建议 ### 账户管理 + - **定期检查**: 每周看看账户状态,及时处理异常 - **合理分配**: 可以给不同的人分配不同的apikey,可以根据不同的apikey来分析用量 ### 安全建议 + - **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全 - **定期备份**: 重要配置和数据要备份 - **监控日志**: 定期查看异常日志 @@ -762,12 +862,14 @@ module.exports = { ## 🆘 遇到问题怎么办? ### 自助排查 + 1. **查看日志**: `logs/` 目录下的日志文件 2. **检查配置**: 确认配置文件设置正确 3. **测试连通性**: 用 curl 测试API是否正常 4. **重启服务**: 有时候重启一下就好了 ### 寻求帮助 + - **GitHub Issues**: 提交详细的错误信息 - **查看文档**: 仔细阅读错误信息和文档 - **社区讨论**: 看看其他人是否遇到类似问题 @@ -775,6 +877,7 @@ module.exports = { --- ## 📄 许可证 + 本项目采用 [MIT许可证](LICENSE)。 --- @@ -785,4 +888,4 @@ module.exports = { **🤝 有问题欢迎提Issue,有改进建议欢迎PR** - \ No newline at end of file + diff --git a/VERSION b/VERSION index 8c43a3a9..1f837000 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.107 +1.1.114 diff --git a/config/config.example.js b/config/config.example.js index 414d6e1f..f70c8ded 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -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; \ No newline at end of file +module.exports = config diff --git a/src/app.js b/src/app.js index 63cc1c47..2e920c73 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ const config = require('../config/config') const logger = require('./utils/logger') const redis = require('./models/redis') const pricingService = require('./services/pricingService') +const cacheMonitor = require('./utils/cacheMonitor') // Import routes const apiRoutes = require('./routes/api') @@ -21,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') const userRoutes = require('./routes/userRoutes') +const webhookRoutes = require('./routes/webhook') // Import middleware const { @@ -49,6 +51,9 @@ class Application { logger.info('🔄 Initializing pricing service...') await pricingService.initialize() + // 📊 初始化缓存监控 + await this.initializeCacheMonitoring() + // 🔧 初始化管理员凭据 logger.info('🔄 Initializing admin credentials...') await this.initializeAdmin() @@ -238,6 +243,7 @@ class Application { this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/claude', openaiClaudeRoutes) this.app.use('/openai', openaiRoutes) + this.app.use('/admin/webhook', webhookRoutes) // 🏠 根路径重定向到新版管理界面 this.app.get('/', (req, res) => { @@ -456,6 +462,40 @@ class Application { } } + // 📊 初始化缓存监控 + async initializeCacheMonitoring() { + try { + logger.info('🔄 Initializing cache monitoring...') + + // 注册各个服务的缓存实例 + const services = [ + { name: 'claudeAccount', service: require('./services/claudeAccountService') }, + { name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') }, + { name: 'bedrockAccount', service: require('./services/bedrockAccountService') } + ] + + // 注册已加载的服务缓存 + for (const { name, service } of services) { + if (service && (service._decryptCache || service.decryptCache)) { + const cache = service._decryptCache || service.decryptCache + cacheMonitor.registerCache(`${name}_decrypt`, cache) + logger.info(`✅ Registered ${name} decrypt cache for monitoring`) + } + } + + // 初始化时打印一次统计 + setTimeout(() => { + const stats = cacheMonitor.getGlobalStats() + logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`) + }, 5000) + + logger.success('✅ Cache monitoring initialized') + } catch (error) { + logger.error('❌ Failed to initialize cache monitoring:', error) + // 不阻止应用启动 + } + } + startCleanupTasks() { // 🧹 每小时清理一次过期数据 setInterval(async () => { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 2bfb5ae6..788248bb 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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, diff --git a/src/models/redis.js b/src/models/redis.js index d93fec17..9f82b391 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -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) diff --git a/src/routes/admin.js b/src/routes/admin.js index 9c668f6d..65983497 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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 { diff --git a/src/routes/api.js b/src/routes/api.js index a2ab2562..3b1c4160 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -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) }) diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 87416f79..ce6ea479 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -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 // 传递中止信号 ) diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index 63308a72..b2c43ed9 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -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 ) diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 2679da6f..0f46595c 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -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] diff --git a/src/routes/webhook.js b/src/routes/webhook.js new file mode 100644 index 00000000..5c3adcef --- /dev/null +++ b/src/routes/webhook.js @@ -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 diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index a7832731..9e5850be 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -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') diff --git a/src/services/bedrockAccountService.js b/src/services/bedrockAccountService.js index a4fdbde3..b5e9e1a9 100644 --- a/src/services/bedrockAccountService.js +++ b/src/services/bedrockAccountService.js @@ -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凭证,建议更新账户以启用加密') diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 2029957b..6577535d 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -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) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 8c43b4b6..fd211651 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -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 diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index d59eea11..99297787 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -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 ( diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index b2ac5fec..fa6c39b4 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -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) } // 处理限流状态 diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 498ab4fe..a63ffc99 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -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, diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index fe2baf26..5326abb2 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -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 // 暴露缓存对象以便测试和监控 } diff --git a/src/services/pricingService.js b/src/services/pricingService.js index e31960b7..5ded4c0a 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -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) } } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 47ee1499..287bb465 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -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) { diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index 93b5e108..f800621c 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -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})` ) // 更新账户的最后使用时间 diff --git a/src/utils/cacheMonitor.js b/src/utils/cacheMonitor.js new file mode 100644 index 00000000..ece5e478 --- /dev/null +++ b/src/utils/cacheMonitor.js @@ -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() diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index 5caab3b7..a0fe6700 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -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 diff --git a/src/utils/logger.js b/src/utils/logger.js index 29045620..9de2ec8f 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -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, diff --git a/src/utils/lruCache.js b/src/utils/lruCache.js new file mode 100644 index 00000000..993089ba --- /dev/null +++ b/src/utils/lruCache.js @@ -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 diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 008eb4cc..36cb48aa 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -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) { diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js new file mode 100644 index 00000000..c95f3156 --- /dev/null +++ b/src/utils/webhookNotifier.js @@ -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() diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 14f1a215..661bc7f4 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -555,14 +555,37 @@ - -
+ + Pro 账号不支持 Claude Opus 4 模型 +
+Google Cloud/Workspace 账号可能需要提供项目 ID
+ + Pro 账号不支持 Claude Opus 4 模型 +
+选择专属账号后,此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 = { diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 796768d1..65374e8d 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -339,6 +339,18 @@ platform="openai" />
修改绑定账号将影响此API Key的请求路由
@@ -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 || [] diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index c6802e13..079ffa17 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -261,7 +261,7 @@ Gemini - {{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }} + {{ getGeminiAuthType() }} OpenAi - Oauth + {{ getOpenAIAuthType() }}