diff --git a/.env.example b/.env.example index ca9aabbf..ff156d8b 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20 # 🌐 代理配置 DEFAULT_PROXY_TIMEOUT=60000 MAX_PROXY_RETRIES=3 +# IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) +PROXY_USE_IPV4=true # 📈 使用限制 DEFAULT_TOKEN_LIMIT=1000000 @@ -43,8 +45,7 @@ LOG_MAX_FILES=5 CLEANUP_INTERVAL=3600000 TOKEN_USAGE_RETENTION=2592000000 HEALTH_CHECK_INTERVAL=60000 -SYSTEM_TIMEZONE=Asia/Shanghai -TIMEZONE_OFFSET=8 +TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区) METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟 # 🎨 Web 界面配置 diff --git a/.gitignore b/.gitignore index c8b75b27..fbe70338 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ pnpm-debug.log* # Claude specific directories .claude/ +# MCP configuration (local only) +.mcp.json +.spec-workflow/ + # Data directory (contains sensitive information) data/ !data/.gitkeep @@ -237,4 +241,4 @@ web/admin/ web/apiStats/ # Admin SPA build files -web/admin-spa/dist/ \ No newline at end of file +web/admin-spa/dist/ diff --git a/CLAUDE.md b/CLAUDE.md index b9239d68..f57ed7d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,12 +11,14 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud ## 核心架构 ### 关键架构概念 + - **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic - **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略 - **代理支持**: 每个Claude账户支持独立代理配置,OAuth token交换也通过代理进行 - **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis ### 主要服务组件 + - **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应 - **claudeAccountService.js**: Claude账户管理,OAuth token刷新和账户选择 - **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新和账户选择 @@ -24,7 +26,8 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud - **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持 ### 认证和代理流程 -1. 客户端使用自建API Key(cr_前缀格式)发送请求 + +1. 客户端使用自建API Key(cr\_前缀格式)发送请求 2. authenticateApiKey中间件验证API Key有效性和速率限制 3. claudeAccountService自动选择可用Claude账户 4. 检查OAuth access token有效性,过期则自动刷新(使用代理) @@ -33,6 +36,7 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud 7. 流式或非流式返回响应,记录使用统计 ### OAuth集成 + - **PKCE流程**: 完整的OAuth 2.0 PKCE实现,支持代理 - **自动刷新**: 智能token过期检测和自动刷新机制 - **代理支持**: OAuth授权和token交换全程支持代理配置 @@ -41,7 +45,8 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud ## 常用命令 ### 基本开发命令 -```bash + +````bash # 安装依赖和初始化 npm install npm run setup # 生成配置和管理员凭据 @@ -76,37 +81,43 @@ npm run service:stop # 停止服务 cp config/config.example.js config/config.js cp .env.example .env npm run setup # 自动生成密钥并创建管理员账户 -``` +```` ## Web界面功能 ### OAuth账户添加流程 + 1. **基本信息和代理设置**: 配置账户名称、描述和代理参数 -2. **OAuth授权**: +2. **OAuth授权**: - 生成授权URL → 用户打开链接并登录Claude Code账号 - 授权后会显示Authorization Code → 复制并粘贴到输入框 - 系统自动交换token并创建账户 ### 核心管理功能 + - **实时仪表板**: 系统统计、账户状态、使用量监控 - **API Key管理**: 创建、配额设置、使用统计查看 - **Claude账户管理**: OAuth账户添加、代理配置、状态监控 - **系统日志**: 实时日志查看,多级别过滤 +- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置 ## 重要端点 ### API转发端点 + - `POST /api/v1/messages` - 主要消息处理端点(支持流式) - `GET /api/v1/models` - 模型列表(兼容性) - `GET /api/v1/usage` - 使用统计查询 - `GET /api/v1/key-info` - API Key信息 ### OAuth管理端点 + - `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理) - `POST /admin/claude-accounts/exchange-code` - 交换authorization code - `POST /admin/claude-accounts` - 创建OAuth账户 ### 系统端点 + - `GET /health` - 健康检查 - `GET /web` - Web管理界面 - `GET /admin/dashboard` - 系统概览数据 @@ -114,22 +125,26 @@ npm run setup # 自动生成密钥并创建管理员账户 ## 故障排除 ### OAuth相关问题 + 1. **代理配置错误**: 检查代理设置是否正确,OAuth token交换也需要代理 2. **授权码无效**: 确保复制了完整的Authorization Code,没有遗漏字符 3. **Token刷新失败**: 检查refreshToken有效性和代理配置 ### Gemini Token刷新问题 + 1. **刷新失败**: 确保 refresh_token 有效且未过期 2. **错误日志**: 查看 `logs/token-refresh-error.log` 获取详细错误信息 3. **测试脚本**: 运行 `node scripts/test-gemini-refresh.js` 测试 token 刷新 ### 常见开发问题 + 1. **Redis连接失败**: 确认Redis服务运行,检查连接配置 2. **管理员登录失败**: 检查init.json同步到Redis,运行npm run setup -3. **API Key格式错误**: 确保使用cr_前缀格式 +3. **API Key格式错误**: 确保使用cr\_前缀格式 4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息 ### 调试工具 + - **日志系统**: Winston结构化日志,支持不同级别 - **CLI工具**: 命令行状态查看和管理 - **Web界面**: 实时日志查看和系统监控 @@ -138,19 +153,35 @@ npm run setup # 自动生成密钥并创建管理员账户 ## 开发最佳实践 ### 代码格式化要求 + - **必须使用 Prettier 格式化所有代码** - 后端代码(src/):运行 `npx prettier --write ` 格式化 - 前端代码(web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write ` 格式化 - 提交前检查格式:`npx prettier --check ` - 格式化所有文件:`npm run format`(如果配置了此脚本) +### 前端开发特殊要求 + +- **响应式设计**: 必须兼容不同设备尺寸(手机、平板、桌面),使用 Tailwind CSS 响应式前缀(sm:、md:、lg:、xl:) +- **暗黑模式兼容**: 项目已集成完整的暗黑模式支持,所有新增/修改的UI组件都必须同时兼容明亮模式和暗黑模式 + - 使用 Tailwind CSS 的 `dark:` 前缀为暗黑模式提供样式 + - 文本颜色:`text-gray-700 dark:text-gray-200` + - 背景颜色:`bg-white dark:bg-gray-800` + - 边框颜色:`border-gray-200 dark:border-gray-700` + - 状态颜色保持一致:`text-blue-500`、`text-green-600`、`text-red-500` 等 +- **主题切换**: 使用 `stores/theme.js` 中的 `useThemeStore()` 来实现主题切换功能 +- **玻璃态效果**: 保持现有的玻璃态设计风格,在暗黑模式下调整透明度和背景色 +- **图标和交互**: 确保所有图标、按钮、交互元素在两种模式下都清晰可见且易于操作 + ### 代码修改原则 + - 对现有文件进行修改时,首先检查代码库的现有模式和风格 - 尽可能重用现有的服务和工具函数,避免重复代码 - 遵循项目现有的错误处理和日志记录模式 - 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现) ### 测试和质量保证 + - 运行 `npm run lint` 进行代码风格检查(使用 ESLint) - 运行 `npm test` 执行测试套件(Jest + SuperTest 配置) - 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status` @@ -158,20 +189,26 @@ npm run setup # 自动生成密钥并创建管理员账户 - 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试 ### 开发工作流 + - **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式 - **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具 - **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理 - **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建 ### 常见文件位置 + - 核心服务逻辑:`src/services/` 目录 -- 路由处理:`src/routes/` 目录 +- 路由处理:`src/routes/` 目录 - 中间件:`src/middleware/` 目录 - 配置管理:`config/config.js` - Redis 模型:`src/models/redis.js` - 工具函数:`src/utils/` 目录 +- 前端主题管理:`web/admin-spa/src/stores/theme.js` +- 前端组件:`web/admin-spa/src/components/` 目录 +- 前端页面:`web/admin-spa/src/views/` 目录 ### 重要架构决策 + - 所有敏感数据(OAuth token、refreshToken)都使用 AES 加密存储在 Redis - 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理 - API Key 使用哈希存储,支持 `cr_` 前缀格式 @@ -179,6 +216,7 @@ npm run setup # 自动生成密钥并创建管理员账户 - 支持流式和非流式响应,客户端断开时自动清理资源 ### 核心数据流和性能优化 + - **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找 - **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据 - **多维度统计**: 支持按时间、模型、用户的实时使用统计 @@ -186,6 +224,7 @@ npm run setup # 自动生成密钥并创建管理员账户 - **原子操作**: Redis 管道操作确保数据一致性 ### 安全和容错机制 + - **多层加密**: API Key 哈希 + OAuth Token AES 加密 - **零信任验证**: 每个请求都需要完整的认证链 - **优雅降级**: Redis 连接失败时的回退机制 @@ -195,6 +234,7 @@ npm run setup # 自动生成密钥并创建管理员账户 ## 项目特定注意事项 ### Redis 数据结构 + - **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找) - **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据) - **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射) @@ -203,12 +243,14 @@ npm run setup # 自动生成密钥并创建管理员账户 - **系统信息**: `system_info` (系统状态缓存) ### 流式响应处理 + - 支持 SSE (Server-Sent Events) 流式传输 - 自动从流中解析 usage 数据并记录 - 客户端断开时通过 AbortController 清理资源 - 错误时发送适当的 SSE 错误事件 ### CLI 工具使用示例 + ```bash # 创建新的 API Key npm run cli keys create -- --name "MyApp" --limit 1000 @@ -224,8 +266,10 @@ npm run cli accounts refresh npm run cli admin create -- --username admin2 npm run cli admin reset-password -- --username admin ``` + # important-instruction-reminders + Do what has been asked; nothing more, nothing less. NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. -NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. \ No newline at end of file +NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. diff --git a/README.md b/README.md index 82625103..8e6cfd5a 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,6 @@ 📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 ---- - -> 💡 **感谢 [@vista8](https://x.com/vista8) 的推荐!** -> -> 如果你对Vibe coding感兴趣,推荐关注: -> -> - 🐦 **X**: [@vista8](https://x.com/vista8) - 分享前沿技术动态 -> - 📱 **公众号**: 向阳乔木推荐看 - ---- ## 🤔 这个项目适合你吗? @@ -321,20 +311,7 @@ npm run service:status # 拉取镜像(支持 amd64 和 arm64) docker pull weishaw/claude-relay-service:latest -# 使用 docker run 运行(注意设置必需的环境变量) -docker run -d \ - --name claude-relay \ - -p 3000:3000 \ - -v $(pwd)/data:/app/data \ - -v $(pwd)/logs:/app/logs \ - -e JWT_SECRET=your-random-secret-key-at-least-32-chars \ - -e ENCRYPTION_KEY=your-32-character-encryption-key \ - -e REDIS_HOST=redis \ - -e ADMIN_USERNAME=my_admin \ - -e ADMIN_PASSWORD=my_secure_password \ - weishaw/claude-relay-service:latest - -# 或使用 docker-compose +# 使用 docker-compose # 创建 .env 文件用于 docker-compose 的环境变量: cat > .env << 'EOF' # 必填:安全密钥(请修改为随机值) diff --git a/VERSION b/VERSION index 97553d5b..872610a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.115 +1.1.120 diff --git a/config/config.example.js b/config/config.example.js index f70c8ded..c7c2496f 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -57,7 +57,9 @@ const config = { // 🌐 代理配置 proxy: { timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000, - maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3 + maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3, + // IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) + useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6 }, // 📈 使用限制 diff --git a/docker-compose.yml b/docker-compose.yml index 0f31195b..608284e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: ports: # 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1 - "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000" + volumes: + - ./logs:/app/logs + - ./data:/app/data environment: # 🌐 服务器配置 - NODE_ENV=production @@ -56,7 +59,6 @@ services: - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000} - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000} - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} - - SYSTEM_TIMEZONE=${SYSTEM_TIMEZONE:-Asia/Shanghai} - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8} # 🎨 Web 界面配置 @@ -68,9 +70,6 @@ services: - DEBUG=${DEBUG:-false} - ENABLE_CORS=${ENABLE_CORS:-true} - TRUST_PROXY=${TRUST_PROXY:-true} - volumes: - - ./logs:/app/logs - - ./data:/app/data depends_on: - redis networks: diff --git a/scripts/test-api-response.js b/scripts/test-api-response.js index 02453708..8131e0f9 100644 --- a/scripts/test-api-response.js +++ b/scripts/test-api-response.js @@ -79,7 +79,7 @@ async function testApiResponse() { console.log('\n\n📊 验证结果:') // 检查 platform 字段 - const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude-oauth') + const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude') const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console') if (claudeWithPlatform.length === claudeAccounts.length) { diff --git a/src/app.js b/src/app.js index 2e920c73..ad9c0196 100644 --- a/src/app.js +++ b/src/app.js @@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') const userRoutes = require('./routes/userRoutes') +const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') // Import middleware @@ -243,6 +244,7 @@ class Application { this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/claude', openaiClaudeRoutes) this.app.use('/openai', openaiRoutes) + this.app.use('/azure', azureOpenaiRoutes) this.app.use('/admin/webhook', webhookRoutes) // 🏠 根路径重定向到新版管理界面 diff --git a/src/routes/admin.js b/src/routes/admin.js index 65983497..ee65dffd 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer const bedrockAccountService = require('../services/bedrockAccountService') const geminiAccountService = require('../services/geminiAccountService') const openaiAccountService = require('../services/openaiAccountService') +const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') const accountGroupService = require('../services/accountGroupService') const redis = require('../models/redis') const { authenticateAdmin } = require('../middleware/auth') @@ -13,13 +14,13 @@ const oauthHelper = require('../utils/oauthHelper') const CostCalculator = require('../utils/costCalculator') const pricingService = require('../services/pricingService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const webhookNotifier = require('../utils/webhookNotifier') const axios = require('axios') const crypto = require('crypto') const fs = require('fs') const path = require('path') const config = require('../../config/config') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') const router = express.Router() @@ -621,6 +622,170 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { } }) +// 批量编辑API Keys +router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds, updates } = req.body + + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'keyIds must be a non-empty array' + }) + } + + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ + error: 'Invalid input', + message: 'updates must be an object' + }) + } + + logger.info( + `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` + ) + logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 处理每个API Key + for (const keyId of keyIds) { + try { + // 获取当前API Key信息 + const currentKey = await redis.getApiKey(keyId) + if (!currentKey || Object.keys(currentKey).length === 0) { + results.failedCount++ + results.errors.push(`API key ${keyId} not found`) + continue + } + + // 构建最终更新数据 + const finalUpdates = {} + + // 处理普通字段 + if (updates.name) { + finalUpdates.name = updates.name + } + if (updates.tokenLimit !== undefined) { + finalUpdates.tokenLimit = updates.tokenLimit + } + if (updates.concurrencyLimit !== undefined) { + finalUpdates.concurrencyLimit = updates.concurrencyLimit + } + if (updates.rateLimitWindow !== undefined) { + finalUpdates.rateLimitWindow = updates.rateLimitWindow + } + if (updates.rateLimitRequests !== undefined) { + finalUpdates.rateLimitRequests = updates.rateLimitRequests + } + if (updates.dailyCostLimit !== undefined) { + finalUpdates.dailyCostLimit = updates.dailyCostLimit + } + if (updates.permissions !== undefined) { + finalUpdates.permissions = updates.permissions + } + if (updates.isActive !== undefined) { + finalUpdates.isActive = updates.isActive + } + if (updates.monthlyLimit !== undefined) { + finalUpdates.monthlyLimit = updates.monthlyLimit + } + if (updates.priority !== undefined) { + finalUpdates.priority = updates.priority + } + if (updates.enabled !== undefined) { + finalUpdates.enabled = updates.enabled + } + + // 处理账户绑定 + if (updates.claudeAccountId !== undefined) { + finalUpdates.claudeAccountId = updates.claudeAccountId + } + if (updates.claudeConsoleAccountId !== undefined) { + finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId + } + if (updates.geminiAccountId !== undefined) { + finalUpdates.geminiAccountId = updates.geminiAccountId + } + if (updates.openaiAccountId !== undefined) { + finalUpdates.openaiAccountId = updates.openaiAccountId + } + if (updates.bedrockAccountId !== undefined) { + finalUpdates.bedrockAccountId = updates.bedrockAccountId + } + + // 处理标签操作 + if (updates.tags !== undefined) { + if (updates.tagOperation) { + const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : [] + const operationTags = updates.tags + + switch (updates.tagOperation) { + case 'replace': { + finalUpdates.tags = operationTags + break + } + case 'add': { + const newTags = [...currentTags] + operationTags.forEach((tag) => { + if (!newTags.includes(tag)) { + newTags.push(tag) + } + }) + finalUpdates.tags = newTags + break + } + case 'remove': { + finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag)) + break + } + } + } else { + // 如果没有指定操作类型,默认为替换 + finalUpdates.tags = updates.tags + } + } + + // 执行更新 + await apiKeyService.updateApiKey(keyId, finalUpdates) + results.successCount++ + logger.success(`✅ Batch edit: API key ${keyId} updated successfully`) + } catch (error) { + results.failedCount++ + results.errors.push(`Failed to update key ${keyId}: ${error.message}`) + logger.error(`❌ Batch edit failed for key ${keyId}:`, error) + } + } + + // 记录批量编辑结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量编辑完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch edit API keys:', error) + return res.status(500).json({ + error: 'Batch edit failed', + message: error.message + }) + } +}) + // 更新API Key router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { @@ -799,7 +964,105 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) -// 删除API Key +// 批量删除API Keys(必须在 :keyId 路由之前定义) +router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 调试信息 + logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) + logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) + + // 参数验证 + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + logger.warn( + `🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}` + ) + return res.status(400).json({ + error: 'Invalid request', + message: 'keyIds 必须是一个非空数组' + }) + } + + if (keyIds.length > 100) { + return res.status(400).json({ + error: 'Too many keys', + message: '每次最多只能删除100个API Keys' + }) + } + + // 验证keyIds格式 + const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string') + if (invalidKeys.length > 0) { + return res.status(400).json({ + error: 'Invalid key IDs', + message: '包含无效的API Key ID' + }) + } + + logger.info( + `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` + ) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 逐个删除,记录成功和失败情况 + for (const keyId of keyIds) { + try { + // 检查API Key是否存在 + const apiKey = await redis.getApiKey(keyId) + if (!apiKey || Object.keys(apiKey).length === 0) { + results.failedCount++ + results.errors.push({ keyId, error: 'API Key 不存在' }) + continue + } + + // 执行删除 + await apiKeyService.deleteApiKey(keyId) + results.successCount++ + + logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`) + } catch (error) { + results.failedCount++ + results.errors.push({ + keyId, + error: error.message || '删除失败' + }) + + logger.error(`❌ Batch delete failed for key ${keyId}:`, error) + } + } + + // 记录批量删除结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量删除完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch delete API keys:', error) + return res.status(500).json({ + error: 'Batch delete failed', + message: error.message + }) + } +}) + +// 删除单个API Key(必须在批量删除路由之后定义) router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params @@ -1268,6 +1531,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { claudeAiOauth, proxy, accountType, + platform = 'claude', priority, groupId } = req.body @@ -1305,6 +1569,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { claudeAiOauth, proxy, accountType: accountType || 'shared', // 默认为共享类型 + platform, priority: priority || 50 // 默认优先级为50 }) @@ -1498,6 +1763,19 @@ router.put( const newSchedulable = !account.schedulable await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || account.claudeAiOauth?.email || 'Claude Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -1768,6 +2046,19 @@ router.put( const newSchedulable = !account.schedulable await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -2042,6 +2333,19 @@ router.put( .json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) } + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: accountResult.data.id, + accountName: accountResult.data.name || 'Bedrock Account', + platform: 'bedrock', + status: 'disabled', + errorCode: 'BEDROCK_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -2079,7 +2383,7 @@ router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req, // 生成 Gemini OAuth 授权 URL router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { try { - const { state } = req.body + const { state, proxy } = req.body // 接收代理配置 // 使用新的 codeassist.google.com 回调地址 const redirectUri = 'https://codeassist.google.com/authcode' @@ -2093,13 +2397,14 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, redirectUri: finalRedirectUri } = await geminiAccountService.generateAuthUrl(state, redirectUri) - // 创建 OAuth 会话,包含 codeVerifier + // 创建 OAuth 会话,包含 codeVerifier 和代理配置 const sessionId = authState await redis.setOAuthSession(sessionId, { state: authState, type: 'gemini', redirectUri: finalRedirectUri, codeVerifier, // 保存 PKCE code verifier + proxy: proxy || null, // 保存代理配置 createdAt: new Date().toISOString() }) @@ -2143,7 +2448,7 @@ router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, // 交换 Gemini 授权码 router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => { try { - const { code, sessionId } = req.body + const { code, sessionId, proxy: requestProxy } = req.body if (!code) { return res.status(400).json({ error: 'Authorization code is required' }) @@ -2151,21 +2456,40 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res let redirectUri = 'https://codeassist.google.com/authcode' let codeVerifier = null + let proxyConfig = null // 如果提供了 sessionId,从 OAuth 会话中获取信息 if (sessionId) { const sessionData = await redis.getOAuthSession(sessionId) if (sessionData) { - const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData + const { + redirectUri: sessionRedirectUri, + codeVerifier: sessionCodeVerifier, + proxy + } = sessionData redirectUri = sessionRedirectUri || redirectUri codeVerifier = sessionCodeVerifier + proxyConfig = proxy // 获取代理配置 logger.info( - `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}` + `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` ) } } - const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier) + // 如果请求体中直接提供了代理配置,优先使用它 + if (requestProxy) { + proxyConfig = requestProxy + logger.info( + `Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}` + ) + } + + const tokens = await geminiAccountService.exchangeCodeForTokens( + code, + redirectUri, + codeVerifier, + proxyConfig // 传递代理配置 + ) // 清理 OAuth 会话 if (sessionId) { @@ -2393,6 +2717,19 @@ router.put( const updatedAccount = await geminiAccountService.getAccount(accountId) const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable + // 如果账号被禁用,发送webhook通知 + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.accountName || 'Gemini Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -4575,19 +4912,10 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res } } - if (sessionData.proxy) { - const { type, host, port, username, password } = sessionData.proxy - if (type === 'socks5') { - // SOCKS5 代理 - const auth = username && password ? `${username}:${password}@` : '' - const socksUrl = `socks5://${auth}${host}:${port}` - axiosConfig.httpsAgent = new SocksProxyAgent(socksUrl) - } else if (type === 'http' || type === 'https') { - // HTTP/HTTPS 代理 - const auth = username && password ? `${username}:${password}@` : '' - const proxyUrl = `${type}://${auth}${host}:${port}` - axiosConfig.httpsAgent = new HttpsProxyAgent(proxyUrl) - } + // 配置代理(如果有) + const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent } // 交换 authorization code 获取 tokens @@ -4963,6 +5291,23 @@ router.put( const result = await openaiAccountService.toggleSchedulable(accountId) + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await redis.getOpenAiAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'OpenAI Account', + platform: 'openai', + status: 'disabled', + errorCode: 'OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + return res.json({ success: result.success, schedulable: result.schedulable, @@ -4979,4 +5324,308 @@ router.put( } ) +// 🌐 Azure OpenAI 账户管理 + +// 获取所有 Azure OpenAI 账户 +router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await azureOpenaiAccountService.getAllAccounts() + res.json({ + success: true, + data: accounts + }) + } catch (error) { + logger.error('Failed to fetch Azure OpenAI accounts:', error) + res.status(500).json({ + success: false, + message: 'Failed to fetch accounts', + error: error.message + }) + } +}) + +// 创建 Azure OpenAI 账户 +router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + accountType, + azureEndpoint, + apiVersion, + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + priority, + isActive, + schedulable + } = req.body + + // 验证必填字段 + if (!name) { + return res.status(400).json({ + success: false, + message: 'Account name is required' + }) + } + + if (!azureEndpoint) { + return res.status(400).json({ + success: false, + message: 'Azure endpoint is required' + }) + } + + if (!apiKey) { + return res.status(400).json({ + success: false, + message: 'API key is required' + }) + } + + if (!deploymentName) { + return res.status(400).json({ + success: false, + message: 'Deployment name is required' + }) + } + + // 验证 Azure endpoint 格式 + if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) { + return res.status(400).json({ + success: false, + message: + 'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com' + }) + } + + // 测试连接 + try { + const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${apiVersion || '2024-02-01'}` + await axios.get(testUrl, { + headers: { + 'api-key': apiKey + }, + timeout: 5000 + }) + } catch (testError) { + if (testError.response?.status === 404) { + logger.warn('Azure OpenAI deployment not found, but continuing with account creation') + } else if (testError.response?.status === 401) { + return res.status(400).json({ + success: false, + message: 'Invalid API key or unauthorized access' + }) + } + } + + const account = await azureOpenaiAccountService.createAccount({ + name, + description, + accountType: accountType || 'shared', + azureEndpoint, + apiVersion: apiVersion || '2024-02-01', + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + priority: priority || 50, + isActive: isActive !== false, + schedulable: schedulable !== false + }) + + res.json({ + success: true, + data: account, + message: 'Azure OpenAI account created successfully' + }) + } catch (error) { + logger.error('Failed to create Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to create account', + error: error.message + }) + } +}) + +// 更新 Azure OpenAI 账户 +router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + const account = await azureOpenaiAccountService.updateAccount(id, updates) + + res.json({ + success: true, + data: account, + message: 'Azure OpenAI account updated successfully' + }) + } catch (error) { + logger.error('Failed to update Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to update account', + error: error.message + }) + } +}) + +// 删除 Azure OpenAI 账户 +router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await azureOpenaiAccountService.deleteAccount(id) + + res.json({ + success: true, + message: 'Azure OpenAI account deleted successfully' + }) + } catch (error) { + logger.error('Failed to delete Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to delete account', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户状态 +router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await azureOpenaiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newStatus = account.isActive === 'true' ? 'false' : 'true' + await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus }) + + res.json({ + success: true, + message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`, + isActive: newStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle Azure OpenAI account status:', error) + res.status(500).json({ + success: false, + message: 'Failed to toggle account status', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户调度状态 +router.put( + '/azure-openai-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const result = await azureOpenaiAccountService.toggleSchedulable(accountId) + + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Azure OpenAI Account', + platform: 'azure-openai', + status: 'disabled', + errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + + return res.json({ + success: true, + schedulable: result.schedulable, + message: result.schedulable ? '已启用调度' : '已禁用调度' + }) + } catch (error) { + logger.error('切换 Azure OpenAI 账户调度状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换调度状态失败', + error: error.message + }) + } + } +) + +// 健康检查单个 Azure OpenAI 账户 +router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const healthResult = await azureOpenaiAccountService.healthCheckAccount(id) + + res.json({ + success: true, + data: healthResult + }) + } catch (error) { + logger.error('Failed to perform health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform health check', + error: error.message + }) + } +}) + +// 批量健康检查所有 Azure OpenAI 账户 +router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => { + try { + const healthResults = await azureOpenaiAccountService.performHealthChecks() + + res.json({ + success: true, + data: healthResults + }) + } catch (error) { + logger.error('Failed to perform batch health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform batch health check', + error: error.message + }) + } +}) + +// 迁移 API Keys 以支持 Azure OpenAI +router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => { + try { + const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport() + + res.json({ + success: true, + message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support` + }) + } catch (error) { + logger.error('Failed to migrate API keys:', error) + res.status(500).json({ + success: false, + message: 'Failed to migrate API keys', + error: error.message + }) + } +}) + module.exports = router diff --git a/src/routes/api.js b/src/routes/api.js index 3b1c4160..bad90a41 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -671,4 +671,103 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re } }) +// 🔢 Token计数端点 - count_tokens beta API +router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + try { + // 检查权限 + if ( + req.apiKey.permissions && + req.apiKey.permissions !== 'all' && + req.apiKey.permissions !== 'claude' + ) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: 'This API key does not have permission to access Claude' + } + }) + } + + logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 选择可用的Claude账户 + const requestedModel = req.body.model + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + + let response + if (accountType === 'claude-official') { + // 使用官方Claude账号转发count_tokens请求 + response = await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else if (accountType === 'claude-console') { + // 使用Console Claude账号转发count_tokens请求 + response = await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else { + // Bedrock不支持count_tokens + return res.status(501).json({ + error: { + type: 'not_supported', + message: 'Token counting is not supported for Bedrock accounts' + } + }) + } + + // 直接返回响应,不记录token使用量 + res.status(response.statusCode) + + // 设置响应头 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + // 尝试解析并返回JSON响应 + try { + const jsonData = JSON.parse(response.body) + res.json(jsonData) + } catch (parseError) { + res.send(response.body) + } + + logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`) + } catch (error) { + logger.error('❌ Token count error:', error) + res.status(500).json({ + error: { + type: 'server_error', + message: 'Failed to count tokens' + } + }) + } +}) + module.exports = router diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js new file mode 100644 index 00000000..50041980 --- /dev/null +++ b/src/routes/azureOpenaiRoutes.js @@ -0,0 +1,318 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') +const azureOpenaiRelayService = require('../services/azureOpenaiRelayService') +const apiKeyService = require('../services/apiKeyService') +const crypto = require('crypto') + +// 支持的模型列表 - 基于真实的 Azure OpenAI 模型 +const ALLOWED_MODELS = { + CHAT_MODELS: [ + 'gpt-4', + 'gpt-4-turbo', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-35-turbo', + 'gpt-35-turbo-16k' + ], + EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large'] +} + +const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS] + +// Azure OpenAI 稳定 API 版本 +// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用 + +// 原子使用统计报告器 +class AtomicUsageReporter { + constructor() { + this.reportedUsage = new Set() + this.pendingReports = new Map() + } + + async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId) { + if (this.reportedUsage.has(requestId)) { + logger.debug(`Usage already reported for request: ${requestId}`) + return false + } + + // 防止并发重复报告 + if (this.pendingReports.has(requestId)) { + return this.pendingReports.get(requestId) + } + + const reportPromise = this._performReport( + requestId, + usageData, + apiKeyId, + modelToRecord, + accountId + ) + this.pendingReports.set(requestId, reportPromise) + + try { + const result = await reportPromise + this.reportedUsage.add(requestId) + return result + } finally { + this.pendingReports.delete(requestId) + // 清理过期的已报告记录 + setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理 + } + } + + async _performReport(requestId, usageData, apiKeyId, modelToRecord, accountId) { + try { + const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0 + const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0 + const cacheCreateTokens = + usageData.prompt_tokens_details?.cache_creation_tokens || + usageData.input_tokens_details?.cache_creation_tokens || + 0 + const cacheReadTokens = + usageData.prompt_tokens_details?.cached_tokens || + usageData.input_tokens_details?.cached_tokens || + 0 + + await apiKeyService.recordUsage( + apiKeyId, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + accountId + ) + + // 同步更新 Azure 账户的 lastUsedAt 和累计使用量 + try { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (accountId) { + await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens) + } + } catch (acctErr) { + logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`) + } + + logger.info( + `📊 Azure OpenAI Usage recorded for ${requestId}: ` + + `model=${modelToRecord}, ` + + `input=${inputTokens}, output=${outputTokens}, ` + + `cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}` + ) + return true + } catch (error) { + logger.error('Failed to report Azure OpenAI usage:', error) + return false + } + } +} + +const usageReporter = new AtomicUsageReporter() + +// 健康检查 +router.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + service: 'azure-openai-relay', + timestamp: new Date().toISOString() + }) +}) + +// 获取可用模型列表(兼容 OpenAI API) +router.get('/models', authenticateApiKey, async (req, res) => { + try { + const models = ALL_ALLOWED_MODELS.map((model) => ({ + id: `azure/${model}`, + object: 'model', + created: Date.now(), + owned_by: 'azure-openai' + })) + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI models:', error) + res.status(500).json({ error: { message: 'Failed to fetch models' } }) + } +}) + +// 处理聊天完成请求 +router.post('/chat/completions', authenticateApiKey, async (req, res) => { + const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'chat/completions' + }) + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 处理嵌入请求 +router.post('/embeddings', authenticateApiKey, async (req, res) => { + const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + input: Array.isArray(req.body.input) ? req.body.input.length : 1 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: false, + endpoint: 'embeddings' + }) + + // 处理响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce(requestId, usageData, req.apiKey.id, modelToRecord, account.id) + } + } catch (error) { + logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 获取使用统计 +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const { start_date, end_date } = req.query + const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date) + + res.json({ + object: 'usage', + data: usage + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI usage:', error) + res.status(500).json({ error: { message: 'Failed to fetch usage data' } }) + } +}) + +module.exports = router diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index ce6ea479..c5d706a3 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -541,12 +541,24 @@ async function handleGenerateContent(req, res) { }) const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + const response = await geminiAccountService.generateContent( client, { model, request: actualRequestData }, user_prompt_id, account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project - req.apiKey?.id // 使用 API Key ID 作为 session ID + req.apiKey?.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 ) // 记录使用统计 @@ -573,7 +585,16 @@ async function handleGenerateContent(req, res) { res.json(response) } catch (error) { const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' - logger.error(`Error in generateContent endpoint (${version})`, { error: error.message }) + // 打印详细的错误信息 + logger.error(`Error in generateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) res.status(500).json({ error: { message: error.message || 'Internal server error', @@ -654,13 +675,25 @@ async function handleStreamGenerateContent(req, res) { }) const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: actualRequestData }, user_prompt_id, account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project req.apiKey?.id, // 使用 API Key ID 作为 session ID - abortController.signal // 传递中止信号 + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 ) // 设置 SSE 响应头 @@ -756,7 +789,16 @@ async function handleStreamGenerateContent(req, res) { }) } catch (error) { const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' - logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message }) + // 打印详细的错误信息 + logger.error(`Error in streamGenerateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) if (!res.headersSent) { res.status(500).json({ diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 2a237f26..9efb2981 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -8,30 +8,11 @@ const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const openaiAccountService = require('../services/openaiAccountService') const apiKeyService = require('../services/apiKeyService') const crypto = require('crypto') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') -// 创建代理 Agent +// 创建代理 Agent(使用统一的代理工具) function createProxyAgent(proxy) { - if (!proxy) { - return null - } - - try { - if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` - return new SocksProxyAgent(socksUrl) - } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` - return new HttpsProxyAgent(proxyUrl) - } - } catch (error) { - logger.warn('Failed to create proxy agent:', error) - } - - return null + return ProxyHelper.createProxyAgent(proxy) } // 使用统一调度器选择 OpenAI 账户 @@ -80,7 +61,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = accessToken, accountId: result.accountId, accountName: account.name, - proxy + proxy, + account } } catch (error) { logger.error('Failed to get OpenAI auth token:', error) @@ -146,11 +128,13 @@ router.post('/responses', authenticateApiKey, async (req, res) => { } // 使用调度器选择账户 - const { accessToken, accountId, proxy } = await getOpenAIAuthToken( - apiKeyData, - sessionId, - requestedModel - ) + const { + accessToken, + accountId, + accountName: _accountName, + proxy, + account + } = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel) // 基于白名单构造上游所需的请求头,确保键为小写且值受控 const incoming = req.headers || {} @@ -165,7 +149,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => { // 覆盖或新增必要头部 headers['authorization'] = `Bearer ${accessToken}` - headers['chatgpt-account-id'] = accountId + headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId headers['host'] = 'chatgpt.com' headers['accept'] = isStream ? 'text/event-stream' : 'application/json' headers['content-type'] = 'application/json' @@ -184,7 +168,9 @@ router.post('/responses', authenticateApiKey, async (req, res) => { // 如果有代理,添加代理配置 if (proxyAgent) { axiosConfig.httpsAgent = proxyAgent - logger.info('Using proxy for OpenAI request') + logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for OpenAI request') } // 根据 stream 参数决定请求类型 diff --git a/src/routes/webhook.js b/src/routes/webhook.js index 5c3adcef..3f31802a 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -1,18 +1,125 @@ const express = require('express') const router = express.Router() const logger = require('../utils/logger') -const webhookNotifier = require('../utils/webhookNotifier') +const webhookService = require('../services/webhookService') +const webhookConfigService = require('../services/webhookConfigService') const { authenticateAdmin } = require('../middleware/auth') +// 获取webhook配置 +router.get('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('获取webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: '获取webhook配置失败' + }) + } +}) + +// 保存webhook配置 +router.post('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.saveConfig(req.body) + res.json({ + success: true, + message: 'Webhook配置已保存', + config + }) + } catch (error) { + logger.error('保存webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '保存webhook配置失败' + }) + } +}) + +// 添加webhook平台 +router.post('/platforms', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.addPlatform(req.body) + res.json({ + success: true, + message: 'Webhook平台已添加', + platform + }) + } catch (error) { + logger.error('添加webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '添加webhook平台失败' + }) + } +}) + +// 更新webhook平台 +router.put('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.updatePlatform(req.params.id, req.body) + res.json({ + success: true, + message: 'Webhook平台已更新', + platform + }) + } catch (error) { + logger.error('更新webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '更新webhook平台失败' + }) + } +}) + +// 删除webhook平台 +router.delete('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + await webhookConfigService.deletePlatform(req.params.id) + res.json({ + success: true, + message: 'Webhook平台已删除' + }) + } catch (error) { + logger.error('删除webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '删除webhook平台失败' + }) + } +}) + +// 切换webhook平台启用状态 +router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.togglePlatform(req.params.id) + res.json({ + success: true, + message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`, + platform + }) + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '切换webhook平台状态失败' + }) + } +}) + // 测试Webhook连通性 router.post('/test', authenticateAdmin, async (req, res) => { try { - const { url } = req.body + const { url, type = 'custom', secret, enableSign } = req.body if (!url) { return res.status(400).json({ error: 'Missing webhook URL', - message: 'Please provide a webhook URL to test' + message: '请提供webhook URL' }) } @@ -22,99 +129,144 @@ router.post('/test', authenticateAdmin, async (req, res) => { } catch (urlError) { return res.status(400).json({ error: 'Invalid URL format', - message: 'Please provide a valid webhook URL' + message: '请提供有效的webhook URL' }) } - logger.info(`🧪 Testing webhook URL: ${url}`) + logger.info(`🧪 测试webhook: ${type} - ${url}`) - const result = await webhookNotifier.testWebhook(url) + // 创建临时平台配置 + const platform = { + type, + url, + secret, + enableSign, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) if (result.success) { - logger.info(`✅ Webhook test successful for: ${url}`) + logger.info(`✅ Webhook测试成功: ${url}`) res.json({ success: true, - message: 'Webhook test successful', + message: 'Webhook测试成功', url }) } else { - logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`) + logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`) res.status(400).json({ success: false, - message: 'Webhook test failed', + message: 'Webhook测试失败', url, error: result.error }) } } catch (error) { - logger.error('❌ Webhook test error:', error) + logger.error('❌ Webhook测试错误:', error) res.status(500).json({ error: 'Internal server error', - message: 'Failed to test webhook' + message: '测试webhook失败' }) } }) -// 手动触发账号异常通知(用于测试) +// 手动触发测试通知 router.post('/test-notification', authenticateAdmin, async (req, res) => { try { const { + type = 'test', accountId = 'test-account-id', - accountName = 'Test Account', + accountName = '测试账号', platform = 'claude-oauth', - status = 'error', - errorCode = 'TEST_ERROR', - reason = 'Manual test notification' + status = 'test', + errorCode = 'TEST_NOTIFICATION', + reason = '手动测试通知', + message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作' } = req.body - logger.info(`🧪 Sending test notification for account: ${accountName}`) + logger.info(`🧪 发送测试通知: ${type}`) - await webhookNotifier.sendAccountAnomalyNotification({ + // 先检查webhook配置 + const config = await webhookConfigService.getConfig() + logger.debug( + `Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}` + ) + if (!config.enabled) { + return res.status(400).json({ + success: false, + message: 'Webhook通知未启用,请先在设置中启用通知功能' + }) + } + + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`) + + if (enabledPlatforms.length === 0) { + return res.status(400).json({ + success: false, + message: '没有启用的通知平台,请先添加并启用至少一个通知平台' + }) + } + + const testData = { accountId, accountName, platform, status, errorCode, - reason - }) + reason, + message, + timestamp: new Date().toISOString() + } - logger.info(`✅ Test notification sent successfully`) + const result = await webhookService.sendNotification(type, testData) + + // 如果没有返回结果,说明可能是配置问题 + if (!result) { + return res.status(400).json({ + success: false, + message: 'Webhook服务未返回结果,请检查配置和日志', + enabledPlatforms: enabledPlatforms.length + }) + } + + // 如果没有成功和失败的记录 + if (result.succeeded === 0 && result.failed === 0) { + return res.status(400).json({ + success: false, + message: '没有发送任何通知,请检查通知类型配置', + result, + enabledPlatforms: enabledPlatforms.length + }) + } + + if (result.failed > 0) { + logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`) + return res.json({ + success: true, + message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`, + data: testData, + result + }) + } + + logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`) res.json({ success: true, - message: 'Test notification sent successfully', - data: { - accountId, - accountName, - platform, - status, - errorCode, - reason - } + message: `测试通知已成功发送到 ${result.succeeded} 个平台`, + data: testData, + result }) } catch (error) { - logger.error('❌ Failed to send test notification:', error) + logger.error('❌ 发送测试通知失败:', error) res.status(500).json({ error: 'Internal server error', - message: 'Failed to send test notification' + message: `发送测试通知失败: ${error.message}` }) } }) -// 获取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 9e5850be..61c87971 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -20,6 +20,7 @@ class ApiKeyService { claudeConsoleAccountId = null, geminiAccountId = null, openaiAccountId = null, + azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 permissions = 'all', // 'claude', 'gemini', 'openai', 'all' isActive = true, @@ -53,6 +54,7 @@ class ApiKeyService { claudeConsoleAccountId: claudeConsoleAccountId || '', geminiAccountId: geminiAccountId || '', openaiAccountId: openaiAccountId || '', + azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), @@ -88,6 +90,7 @@ class ApiKeyService { claudeConsoleAccountId: keyData.claudeConsoleAccountId, geminiAccountId: keyData.geminiAccountId, openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', @@ -190,6 +193,7 @@ class ApiKeyService { claudeConsoleAccountId: keyData.claudeConsoleAccountId, geminiAccountId: keyData.geminiAccountId, openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID permissions: keyData.permissions || 'all', tokenLimit: parseInt(keyData.tokenLimit), @@ -337,6 +341,7 @@ class ApiKeyService { 'claudeConsoleAccountId', 'geminiAccountId', 'openaiAccountId', + 'azureOpenaiAccountId', 'bedrockAccountId', // 添加 Bedrock 账号ID 'permissions', 'expiresAt', diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js new file mode 100644 index 00000000..49cb78cc --- /dev/null +++ b/src/services/azureOpenaiAccountService.js @@ -0,0 +1,479 @@ +const redisClient = require('../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const config = require('../../config/config') +const logger = require('../utils/logger') + +// 加密相关常量 +const ALGORITHM = 'aes-256-cbc' +const IV_LENGTH = 16 + +// 🚀 安全的加密密钥生成,支持动态salt +const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt' + +class EncryptionKeyManager { + constructor() { + this.keyCache = new Map() + this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时 + } + + getKey(version = 'current') { + const cached = this.keyCache.get(version) + if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) { + return cached.key + } + + // 生成新密钥 + const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) + this.keyCache.set(version, { + key, + timestamp: Date.now() + }) + + logger.debug('🔑 Azure OpenAI encryption key generated/refreshed') + return key + } + + // 清理过期密钥 + cleanup() { + const now = Date.now() + for (const [version, cached] of this.keyCache.entries()) { + if (now - cached.timestamp > this.keyRotationInterval) { + this.keyCache.delete(version) + } + } + } +} + +const encryptionKeyManager = new EncryptionKeyManager() + +// 定期清理过期密钥 +setInterval( + () => { + encryptionKeyManager.cleanup() + }, + 60 * 60 * 1000 +) // 每小时清理一次 + +// 生成加密密钥 - 使用安全的密钥管理器 +function generateEncryptionKey() { + return encryptionKeyManager.getKey() +} + +// Azure OpenAI 账户键前缀 +const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:' +const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:' + +// 加密函数 +function encrypt(text) { + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` +} + +// 解密函数 - 移除缓存以提高安全性 +function decrypt(text) { + if (!text) { + return '' + } + + try { + const key = generateEncryptionKey() + // IV 是固定长度的 32 个十六进制字符(16 字节) + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 + + if (ivHex.length !== 32 || !encryptedHex) { + throw new Error('Invalid encrypted text format') + } + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + const result = decrypted.toString() + + return result + } catch (error) { + logger.error('Azure OpenAI decryption error:', error.message) + return '' + } +} + +// 创建账户 +async function createAccount(accountData) { + const accountId = uuidv4() + const now = new Date().toISOString() + + const account = { + id: accountId, + name: accountData.name, + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + groupId: accountData.groupId || null, + priority: accountData.priority || 50, + // Azure OpenAI 特有字段 + azureEndpoint: accountData.azureEndpoint || '', + apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本 + deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称 + apiKey: encrypt(accountData.apiKey || ''), + // 支持的模型 + supportedModels: JSON.stringify( + accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k'] + ), + // 状态字段 + isActive: accountData.isActive !== false ? 'true' : 'false', + status: 'active', + schedulable: accountData.schedulable !== false ? 'true' : 'false', + createdAt: now, + updatedAt: now + } + + // 代理配置 + if (accountData.proxy) { + account.proxy = + typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) + } + + const client = redisClient.getClientSafe() + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + + logger.info(`Created Azure OpenAI account: ${accountId}`) + return account +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感数据(仅用于内部处理,不返回给前端) + if (accountData.apiKey) { + accountData.apiKey = decrypt(accountData.apiKey) + } + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + updates.updatedAt = new Date().toISOString() + + // 加密敏感数据 + if (updates.apiKey) { + updates.apiKey = encrypt(updates.apiKey) + } + + // 处理代理配置 + if (updates.proxy) { + updates.proxy = + typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) + } + + // 处理支持的模型 + if (updates.supportedModels) { + updates.supportedModels = + typeof updates.supportedModels === 'string' + ? updates.supportedModels + : JSON.stringify(updates.supportedModels) + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + } + + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Azure OpenAI account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + const client = redisClient.getClientSafe() + const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}` + + // 从Redis中删除账户数据 + await client.del(accountKey) + + // 从共享账户集合中移除 + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + + logger.info(`Deleted Azure OpenAI account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const client = redisClient.getClientSafe() + const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`) + + if (!keys || keys.length === 0) { + return [] + } + + const accounts = [] + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + // 不返回敏感数据给前端 + delete accountData.apiKey + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + accounts.push(accountData) + } + } + + return accounts +} + +// 获取共享账户 +async function getSharedAccounts() { + const client = redisClient.getClientSafe() + const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY) + + if (!accountIds || accountIds.length === 0) { + return [] + } + + const accounts = [] + for (const accountId of accountIds) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true') { + accounts.push(account) + } + } + + return accounts +} + +// 选择可用账户 +async function selectAvailableAccount(sessionId = null) { + // 如果有会话ID,尝试获取之前分配的账户 + if (sessionId) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + const accountId = await client.get(mappingKey) + + if (accountId) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true' && account.schedulable === 'true') { + logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`) + return account + } + } + } + + // 获取所有共享账户 + const sharedAccounts = await getSharedAccounts() + + // 过滤出可用的账户 + const availableAccounts = sharedAccounts.filter( + (acc) => acc.isActive === 'true' && acc.schedulable === 'true' + ) + + if (availableAccounts.length === 0) { + throw new Error('No available Azure OpenAI accounts') + } + + // 按优先级排序并选择 + availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50)) + const selectedAccount = availableAccounts[0] + + // 如果有会话ID,保存映射关系 + if (sessionId && selectedAccount) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期 + } + + logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`) + return selectedAccount +} + +// 更新账户使用量 +async function updateAccountUsage(accountId, tokens) { + const client = redisClient.getClientSafe() + const now = new Date().toISOString() + + // 使用 HINCRBY 原子操作更新使用量 + await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens) + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now) + + logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`) +} + +// 健康检查单个账户 +async function healthCheckAccount(accountId) { + try { + const account = await getAccount(accountId) + if (!account) { + return { id: accountId, status: 'error', message: 'Account not found' } + } + + // 简单检查配置是否完整 + if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) { + return { + id: accountId, + status: 'error', + message: 'Incomplete configuration' + } + } + + // 可以在这里添加实际的API调用测试 + // 暂时返回成功状态 + return { + id: accountId, + status: 'healthy', + message: 'Account is configured correctly' + } + } catch (error) { + logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error) + return { + id: accountId, + status: 'error', + message: error.message + } + } +} + +// 批量健康检查 +async function performHealthChecks() { + const accounts = await getAllAccounts() + const results = [] + + for (const account of accounts) { + const result = await healthCheckAccount(account.id) + results.push(result) + } + + return results +} + +// 切换账户的可调度状态 +async function toggleSchedulable(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulable = account.schedulable === 'true' ? 'false' : 'true' + await updateAccount(accountId, { schedulable: newSchedulable }) + + return { + id: accountId, + schedulable: newSchedulable === 'true' + } +} + +// 迁移 API Keys 以支持 Azure OpenAI +async function migrateApiKeysForAzureSupport() { + const client = redisClient.getClientSafe() + const apiKeyIds = await client.smembers('api_keys') + + let migratedCount = 0 + for (const keyId of apiKeyIds) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if (keyData && !keyData.azureOpenaiAccountId) { + // 添加 Azure OpenAI 账户ID字段(初始为空) + await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '') + migratedCount++ + } + } + + logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`) + return migratedCount +} + +module.exports = { + createAccount, + getAccount, + updateAccount, + deleteAccount, + getAllAccounts, + getSharedAccounts, + selectAvailableAccount, + updateAccountUsage, + healthCheckAccount, + performHealthChecks, + toggleSchedulable, + migrateApiKeysForAzureSupport, + encrypt, + decrypt +} diff --git a/src/services/azureOpenaiRelayService.js b/src/services/azureOpenaiRelayService.js new file mode 100644 index 00000000..9590884b --- /dev/null +++ b/src/services/azureOpenaiRelayService.js @@ -0,0 +1,529 @@ +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') + +// 转换模型名称(去掉 azure/ 前缀) +function normalizeModelName(model) { + if (model && model.startsWith('azure/')) { + return model.replace('azure/', '') + } + return model +} + +// 处理 Azure OpenAI 请求 +async function handleAzureOpenAIRequest({ + account, + requestBody, + headers: _headers = {}, // 前缀下划线表示未使用 + isStream = false, + endpoint = 'chat/completions' +}) { + // 声明变量在函数顶部,确保在 catch 块中也能访问 + let requestUrl = '' + let proxyAgent = null + let deploymentName = '' + + try { + // 构建 Azure OpenAI 请求 URL + const baseUrl = account.azureEndpoint + deploymentName = account.deploymentName || 'default' + // Azure Responses API requires preview versions; fall back appropriately + const apiVersion = + account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01') + if (endpoint === 'chat/completions') { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` + } else if (endpoint === 'responses') { + requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}` + } else { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}` + } + + // 准备请求头 + const requestHeaders = { + 'Content-Type': 'application/json', + 'api-key': account.apiKey + } + + // 移除不需要的头部 + delete requestHeaders['anthropic-version'] + delete requestHeaders['x-api-key'] + delete requestHeaders['host'] + + // 处理请求体 + const processedBody = { ...requestBody } + + // 标准化模型名称 + if (processedBody.model) { + processedBody.model = normalizeModelName(processedBody.model) + } else { + processedBody.model = 'gpt-4' + } + + // 使用统一的代理创建工具 + proxyAgent = ProxyHelper.createProxyAgent(account.proxy) + + // 配置请求选项 + const axiosConfig = { + method: 'POST', + url: requestUrl, + headers: requestHeaders, + data: processedBody, + timeout: 600000, // 10 minutes for Azure OpenAI + validateStatus: () => true, + // 添加连接保活选项 + keepAlive: true, + maxRedirects: 5, + // 防止socket hang up + socketKeepAlive: true + } + + // 如果有代理,添加代理配置 + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + // 为代理添加额外的keep-alive设置 + if (proxyAgent.options) { + proxyAgent.options.keepAlive = true + proxyAgent.options.keepAliveMsecs = 1000 + } + logger.debug( + `Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}` + ) + } + + // 流式请求特殊处理 + if (isStream) { + axiosConfig.responseType = 'stream' + requestHeaders.accept = 'text/event-stream' + } else { + requestHeaders.accept = 'application/json' + } + + logger.debug(`Making Azure OpenAI request`, { + requestUrl, + method: 'POST', + endpoint, + deploymentName, + apiVersion, + hasProxy: !!proxyAgent, + proxyInfo: ProxyHelper.maskProxyInfo(account.proxy), + isStream, + requestBodySize: JSON.stringify(processedBody).length + }) + + logger.debug('Azure OpenAI request headers', { + 'content-type': requestHeaders['Content-Type'], + 'user-agent': requestHeaders['user-agent'] || 'not-set', + customHeaders: Object.keys(requestHeaders).filter( + (key) => !['Content-Type', 'user-agent'].includes(key) + ) + }) + + logger.debug('Azure OpenAI request body', { + model: processedBody.model, + messages: processedBody.messages?.length || 0, + otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key)) + }) + + const requestStartTime = Date.now() + logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`) + + // 发送请求 + const response = await axios(axiosConfig) + + const requestDuration = Date.now() - requestStartTime + logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`) + + logger.debug(`Azure OpenAI response received`, { + status: response.status, + statusText: response.statusText, + duration: `${requestDuration}ms`, + responseHeaders: Object.keys(response.headers || {}), + hasData: !!response.data, + contentType: response.headers?.['content-type'] || 'unknown' + }) + + return response + } catch (error) { + const errorDetails = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: requestUrl || 'unknown', + endpoint, + deploymentName: deploymentName || account?.deploymentName || 'unknown', + hasProxy: !!proxyAgent, + proxyType: account?.proxy?.type || 'none', + isTimeout: error.code === 'ECONNABORTED', + isNetworkError: !error.response, + stack: error.stack + } + + // 特殊错误类型的详细日志 + if (error.code === 'ENOTFOUND') { + logger.error('DNS Resolution Failed for Azure OpenAI', { + ...errorDetails, + hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown', + suggestion: 'Check if Azure endpoint URL is correct and accessible' + }) + } else if (error.code === 'ECONNREFUSED') { + logger.error('Connection Refused by Azure OpenAI', { + ...errorDetails, + suggestion: 'Check if proxy settings are correct or Azure service is accessible' + }) + } else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) { + logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', { + ...errorDetails, + suggestion: + 'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.' + }) + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + logger.error('🚨 Azure OpenAI Request Timeout', { + ...errorDetails, + timeoutMs: 600000, + suggestion: + 'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.' + }) + } else if ( + error.code === 'CERT_AUTHORITY_INVALID' || + error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ) { + logger.error('SSL Certificate Error for Azure OpenAI', { + ...errorDetails, + suggestion: 'SSL certificate validation failed - check proxy SSL settings' + }) + } else if (error.response?.status === 401) { + logger.error('Azure OpenAI Authentication Failed', { + ...errorDetails, + suggestion: 'Check if Azure OpenAI API key is valid and not expired' + }) + } else if (error.response?.status === 404) { + logger.error('Azure OpenAI Deployment Not Found', { + ...errorDetails, + suggestion: 'Check if deployment name and Azure endpoint are correct' + }) + } else { + logger.error('Azure OpenAI Request Failed', errorDetails) + } + + throw error + } +} + +// 安全的流管理器 +class StreamManager { + constructor() { + this.activeStreams = new Set() + this.cleanupCallbacks = new Map() + } + + registerStream(streamId, cleanup) { + this.activeStreams.add(streamId) + this.cleanupCallbacks.set(streamId, cleanup) + } + + cleanup(streamId) { + if (this.activeStreams.has(streamId)) { + try { + const cleanup = this.cleanupCallbacks.get(streamId) + if (cleanup) { + cleanup() + } + } catch (error) { + logger.warn(`Stream cleanup error for ${streamId}:`, error.message) + } finally { + this.activeStreams.delete(streamId) + this.cleanupCallbacks.delete(streamId) + } + } + } + + getActiveStreamCount() { + return this.activeStreams.size + } +} + +const streamManager = new StreamManager() + +// SSE 缓冲区大小限制 +const MAX_BUFFER_SIZE = 64 * 1024 // 64KB +const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小 + +// 处理流式响应 +function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { + const { onData, onEnd, onError } = options + const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + logger.info(`Starting Azure OpenAI stream handling`, { + streamId, + upstreamStatus: upstreamResponse.status, + upstreamHeaders: Object.keys(upstreamResponse.headers || {}), + clientRemoteAddress: clientResponse.req?.connection?.remoteAddress, + hasOnData: !!onData, + hasOnEnd: !!onEnd, + hasOnError: !!onError + }) + + return new Promise((resolve, reject) => { + let buffer = '' + let usageData = null + let actualModel = null + let hasEnded = false + let eventCount = 0 + const maxEvents = 10000 // 最大事件数量限制 + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + clientResponse.setHeader('X-Accel-Buffering', 'no') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 立即刷新响应头 + if (typeof clientResponse.flushHeaders === 'function') { + clientResponse.flushHeaders() + } + + // 解析 SSE 事件以捕获 usage 数据 + const parseSSEForUsage = (data) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) // 移除 'data: ' 前缀 + if (jsonStr.trim() === '[DONE]') { + continue + } + const eventData = JSON.parse(jsonStr) + + // 获取模型信息 + if (eventData.model) { + actualModel = eventData.model + } + + // 获取使用统计(Responses API: response.completed -> response.usage) + if (eventData.type === 'response.completed' && eventData.response) { + if (eventData.response.model) { + actualModel = eventData.response.model + } + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData) + } + } + + // 兼容 Chat Completions 风格(顶层 usage) + if (!usageData && eventData.usage) { + usageData = eventData.usage + logger.debug('Captured Azure OpenAI usage (top-level):', usageData) + } + + // 检查是否是完成事件 + if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) { + // 这是最后一个 chunk + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + + // 注册流清理 + const cleanup = () => { + if (!hasEnded) { + hasEnded = true + try { + upstreamResponse.data?.removeAllListeners?.() + upstreamResponse.data?.destroy?.() + + if (!clientResponse.headersSent) { + clientResponse.status(502).end() + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (error) { + logger.warn('Stream cleanup error:', error.message) + } + } + } + + streamManager.registerStream(streamId, cleanup) + + upstreamResponse.data.on('data', (chunk) => { + try { + if (hasEnded || clientResponse.destroyed) { + return + } + + eventCount++ + if (eventCount > maxEvents) { + logger.warn(`Stream ${streamId} exceeded max events limit`) + cleanup() + return + } + + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!clientResponse.destroyed) { + clientResponse.write(chunk) + } + + // 同时解析数据以捕获 usage 信息,带缓冲区大小限制 + buffer += chunkStr + + // 防止缓冲区过大 + if (buffer.length > MAX_BUFFER_SIZE) { + logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`) + buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半 + } + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' // 保留最后一个可能不完整的事件 + + for (const event of events) { + if (event.trim() && event.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(event) + } + } + } + + if (onData) { + onData(chunk, { usageData, actualModel }) + } + } catch (error) { + logger.error('Error processing Azure OpenAI stream chunk:', error) + if (!hasEnded) { + cleanup() + reject(error) + } + } + }) + + upstreamResponse.data.on('end', () => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + try { + // 处理剩余的 buffer + if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(buffer) + } + + if (onEnd) { + onEnd({ usageData, actualModel }) + } + + if (!clientResponse.destroyed) { + clientResponse.end() + } + + resolve({ usageData, actualModel }) + } catch (error) { + logger.error('Stream end handling error:', error) + reject(error) + } + }) + + upstreamResponse.data.on('error', (error) => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + logger.error('Upstream stream error:', error) + + try { + if (onError) { + onError(error) + } + + if (!clientResponse.headersSent) { + clientResponse.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (cleanupError) { + logger.warn('Error during stream error cleanup:', cleanupError.message) + } + + reject(error) + }) + + // 客户端断开时清理 + const clientCleanup = () => { + streamManager.cleanup(streamId) + } + + clientResponse.on('close', clientCleanup) + clientResponse.on('aborted', clientCleanup) + clientResponse.on('error', clientCleanup) + }) +} + +// 处理非流式响应 +function handleNonStreamResponse(upstreamResponse, clientResponse) { + try { + // 设置状态码 + clientResponse.status(upstreamResponse.status) + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'application/json') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 返回响应数据 + const responseData = upstreamResponse.data + clientResponse.json(responseData) + + // 提取 usage 数据 + const usageData = responseData.usage + const actualModel = responseData.model + + return { usageData, actualModel, responseData } + } catch (error) { + logger.error('Error handling Azure OpenAI non-stream response:', error) + throw error + } +} + +module.exports = { + handleAzureOpenAIRequest, + handleStreamResponse, + handleNonStreamResponse, + normalizeModelName +} diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 6577535d..ffd390bd 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1,7 +1,6 @@ const { v4: uuidv4 } = require('uuid') const crypto = require('crypto') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') const axios = require('axios') const redis = require('../models/redis') const logger = require('../utils/logger') @@ -55,6 +54,7 @@ class ClaudeAccountService { proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } isActive = true, accountType = 'shared', // 'dedicated' or 'shared' + platform = 'claude', priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 subscriptionInfo = null // 手动设置的订阅信息 @@ -79,7 +79,8 @@ class ClaudeAccountService { scopes: claudeAiOauth.scopes.join(' '), proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), - accountType, // 账号类型:'dedicated' 或 'shared' + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', @@ -108,7 +109,8 @@ class ClaudeAccountService { scopes: '', proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), - accountType, // 账号类型:'dedicated' 或 'shared' + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', @@ -151,6 +153,7 @@ class ClaudeAccountService { isActive, proxy, accountType, + platform, priority, status: accountData.status, createdAt: accountData.createdAt, @@ -444,7 +447,7 @@ class ClaudeAccountService { errorMessage: account.errorMessage, accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 - platform: 'claude-oauth', // 添加平台标识,用于前端区分 + platform: account.platform || 'claude', // 添加平台标识,用于前端区分 createdAt: account.createdAt, lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, @@ -857,29 +860,19 @@ class ClaudeAccountService { } } - // 🌐 创建代理agent + // 🌐 创建代理agent(使用统一的代理工具) _createProxyAgent(proxyConfig) { - if (!proxyConfig) { - return null + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude') + } else { + logger.debug('🌐 No proxy configured for Claude request') } - - try { - const proxy = JSON.parse(proxyConfig) - - if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` - return new SocksProxyAgent(socksUrl) - } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` - return new HttpsProxyAgent(httpUrl) - } - } catch (error) { - logger.warn('⚠️ Invalid proxy configuration:', error) - } - - return null + return proxyAgent } // 🔐 加密敏感数据 @@ -1094,6 +1087,22 @@ class ClaudeAccountService { logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) } + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', + reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + return { success: true } } catch (error) { logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index fd211651..c2044895 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -1,7 +1,6 @@ const { v4: uuidv4 } = require('uuid') const crypto = require('crypto') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') const redis = require('../models/redis') const logger = require('../utils/logger') const config = require('../../config/config') @@ -367,6 +366,22 @@ class ClaudeConsoleAccountService { await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', + reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + logger.warn( `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` ) @@ -480,29 +495,19 @@ class ClaudeConsoleAccountService { } } - // 🌐 创建代理agent + // 🌐 创建代理agent(使用统一的代理工具) _createProxyAgent(proxyConfig) { - if (!proxyConfig) { - return null + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude Console') + } else { + logger.debug('🌐 No proxy configured for Claude Console request') } - - try { - const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig - - if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` - return new SocksProxyAgent(socksUrl) - } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` - return new HttpsProxyAgent(httpUrl) - } - } catch (error) { - logger.warn('⚠️ Invalid proxy configuration:', error) - } - - return null + return proxyAgent } // 🔐 加密敏感数据 diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 99297787..dafb7f98 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -84,7 +84,16 @@ class ClaudeConsoleRelayService { // 构建完整的API URL const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 - const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index fa6c39b4..49a9192a 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -2,8 +2,7 @@ const https = require('https') const zlib = require('zlib') const fs = require('fs') const path = require('path') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') const claudeAccountService = require('./claudeAccountService') const unifiedClaudeScheduler = require('./unifiedClaudeScheduler') const sessionHelper = require('../utils/sessionHelper') @@ -496,32 +495,28 @@ class ClaudeRelayService { } } - // 🌐 获取代理Agent + // 🌐 获取代理Agent(使用统一的代理工具) async _getProxyAgent(accountId) { try { const accountData = await claudeAccountService.getAllAccounts() const account = accountData.find((acc) => acc.id === accountId) if (!account || !account.proxy) { + logger.debug('🌐 No proxy configured for Claude account') return null } - const { proxy } = account - - if (proxy.type === 'socks5') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` - return new SocksProxyAgent(socksUrl) - } else if (proxy.type === 'http' || proxy.type === 'https') { - const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' - const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` - return new HttpsProxyAgent(httpUrl) + const proxyAgent = ProxyHelper.createProxyAgent(account.proxy) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}` + ) } + return proxyAgent } catch (error) { logger.warn('⚠️ Failed to create proxy agent:', error) + return null } - - return null } // 🔧 过滤客户端请求头 @@ -596,10 +591,18 @@ class ClaudeRelayService { } return new Promise((resolve, reject) => { + // 支持自定义路径(如 count_tokens) + let requestPath = url.pathname + if (requestOptions.customPath) { + const baseUrl = new URL('https://api.anthropic.com') + const customUrl = new URL(requestOptions.customPath, baseUrl) + requestPath = customUrl.pathname + } + const options = { hostname: url.hostname, port: url.port || 443, - path: url.pathname, + path: requestPath, method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index a63ffc99..78e1d5a1 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -5,6 +5,7 @@ const config = require('../../config/config') const logger = require('../utils/logger') const { OAuth2Client } = require('google-auth-library') const { maskToken } = require('../utils/tokenMask') +const ProxyHelper = require('../utils/proxyHelper') const { logRefreshStart, logRefreshSuccess, @@ -109,11 +110,32 @@ setInterval( 10 * 60 * 1000 ) -// 创建 OAuth2 客户端 -function createOAuth2Client(redirectUri = null) { +// 创建 OAuth2 客户端(支持代理配置) +function createOAuth2Client(redirectUri = null, proxyConfig = null) { // 如果没有提供 redirectUri,使用默认值 const uri = redirectUri || 'http://localhost:45462' - return new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, uri) + + // 准备客户端选项 + const clientOptions = { + clientId: OAUTH_CLIENT_ID, + clientSecret: OAUTH_CLIENT_SECRET, + redirectUri: uri + } + + // 如果有代理配置,设置 transporterOptions + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 通过 transporterOptions 传递代理配置给底层的 Gaxios + clientOptions.transporterOptions = { + agent: proxyAgent, + httpsAgent: proxyAgent + } + logger.debug('Created OAuth2Client with proxy configuration') + } + } + + return new OAuth2Client(clientOptions) } // 生成授权 URL (支持 PKCE) @@ -196,11 +218,25 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2 } } -// 交换授权码获取 tokens (支持 PKCE) -async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) { - const oAuth2Client = createOAuth2Client(redirectUri) - +// 交换授权码获取 tokens (支持 PKCE 和代理) +async function exchangeCodeForTokens( + code, + redirectUri = null, + codeVerifier = null, + proxyConfig = null +) { try { + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini token exchange: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini token exchange') + } + const tokenParams = { code, redirect_uri: redirectUri @@ -228,8 +264,9 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu } // 刷新访问令牌 -async function refreshAccessToken(refreshToken) { - const oAuth2Client = createOAuth2Client() +async function refreshAccessToken(refreshToken, proxyConfig = null) { + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(null, proxyConfig) try { // 设置 refresh_token @@ -237,6 +274,14 @@ async function refreshAccessToken(refreshToken) { refresh_token: refreshToken }) + if (proxyConfig) { + logger.info( + `🔄 Using proxy for Gemini token refresh: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🔄 No proxy configured for Gemini token refresh') + } + // 调用 refreshAccessToken 获取新的 tokens const response = await oAuth2Client.refreshAccessToken() const { credentials } = response @@ -261,7 +306,9 @@ async function refreshAccessToken(refreshToken) { logger.error('Error refreshing access token:', { message: error.message, code: error.code, - response: error.response?.data + response: error.response?.data, + hasProxy: !!proxyConfig, + proxy: proxyConfig ? ProxyHelper.maskProxyInfo(proxyConfig) : 'No proxy' }) throw new Error(`Failed to refresh access token: ${error.message}`) } @@ -786,7 +833,8 @@ async function refreshAccountToken(accountId) { logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`) // account.refreshToken 已经是解密后的值(从 getAccount 返回) - const newTokens = await refreshAccessToken(account.refreshToken) + // 传入账户的代理配置 + const newTokens = await refreshAccessToken(account.refreshToken, account.proxy) // 更新账户信息 const updates = { @@ -1169,7 +1217,8 @@ async function generateContent( requestData, userPromptId, projectId = null, - sessionId = null + sessionId = null, + proxyConfig = null ) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' @@ -1206,6 +1255,17 @@ async function generateContent( timeout: 60000 // 生成内容可能需要更长时间 } + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini generateContent') + } + const response = await axios(axiosConfig) logger.info('✅ generateContent API调用成功') @@ -1219,7 +1279,8 @@ async function generateContentStream( userPromptId, projectId = null, sessionId = null, - signal = null + signal = null, + proxyConfig = null ) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' @@ -1260,6 +1321,17 @@ async function generateContentStream( timeout: 60000 } + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini streamGenerateContent') + } + // 如果提供了中止信号,添加到配置中 if (signal) { axiosConfig.signal = signal diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js index 35423632..60030d3e 100644 --- a/src/services/geminiRelayService.js +++ b/src/services/geminiRelayService.js @@ -1,6 +1,5 @@ const axios = require('axios') -const { HttpsProxyAgent } = require('https-proxy-agent') -const { SocksProxyAgent } = require('socks-proxy-agent') +const ProxyHelper = require('../utils/proxyHelper') const logger = require('../utils/logger') const config = require('../../config/config') const apiKeyService = require('./apiKeyService') @@ -9,34 +8,9 @@ const apiKeyService = require('./apiKeyService') const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp' -// 创建代理 agent +// 创建代理 agent(使用统一的代理工具) function createProxyAgent(proxyConfig) { - if (!proxyConfig) { - return null - } - - try { - const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig - - if (!proxy.type || !proxy.host || !proxy.port) { - return null - } - - const proxyUrl = - proxy.username && proxy.password - ? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}` - : `${proxy.type}://${proxy.host}:${proxy.port}` - - if (proxy.type === 'socks5') { - return new SocksProxyAgent(proxyUrl) - } else if (proxy.type === 'http' || proxy.type === 'https') { - return new HttpsProxyAgent(proxyUrl) - } - } catch (error) { - logger.error('Error creating proxy agent:', error) - } - - return null + return ProxyHelper.createProxyAgent(proxyConfig) } // 转换 OpenAI 消息格式到 Gemini 格式 @@ -306,7 +280,9 @@ async function sendGeminiRequest({ const proxyAgent = createProxyAgent(proxy) if (proxyAgent) { axiosConfig.httpsAgent = proxyAgent - logger.debug('Using proxy for Gemini request') + logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for Gemini API request') } // 添加 AbortController 信号支持 @@ -412,6 +388,11 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us- const proxyAgent = createProxyAgent(proxy) if (proxyAgent) { axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini models request') } try { @@ -508,7 +489,11 @@ async function countTokens({ const proxyAgent = createProxyAgent(proxy) if (proxyAgent) { axiosConfig.httpsAgent = proxyAgent - logger.debug('Using proxy for Gemini countTokens request') + logger.info( + `🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens request') } try { diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index 5326abb2..1e88cdec 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -2,8 +2,7 @@ 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 ProxyHelper = require('../utils/proxyHelper') const config = require('../../config/config') const logger = require('../utils/logger') // const { maskToken } = require('../utils/tokenMask') @@ -133,18 +132,14 @@ async function refreshAccessToken(refreshToken, proxy = null) { } // 配置代理(如果有) - 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 proxyAgent = ProxyHelper.createProxyAgent(proxy) + if (proxyAgent) { + requestOptions.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for OpenAI token refresh') } // 发送请求 diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js new file mode 100644 index 00000000..18a460f6 --- /dev/null +++ b/src/services/webhookConfigService.js @@ -0,0 +1,272 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +class WebhookConfigService { + constructor() { + this.KEY_PREFIX = 'webhook_config' + this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default` + } + + /** + * 获取webhook配置 + */ + async getConfig() { + try { + const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY) + if (!configStr) { + // 返回默认配置 + return this.getDefaultConfig() + } + return JSON.parse(configStr) + } catch (error) { + logger.error('获取webhook配置失败:', error) + return this.getDefaultConfig() + } + } + + /** + * 保存webhook配置 + */ + async saveConfig(config) { + try { + // 验证配置 + this.validateConfig(config) + + // 添加更新时间 + config.updatedAt = new Date().toISOString() + + await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config)) + logger.info('✅ Webhook配置已保存') + + return config + } catch (error) { + logger.error('保存webhook配置失败:', error) + throw error + } + } + + /** + * 验证配置 + */ + validateConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + // 验证平台配置 + if (config.platforms) { + const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom'] + + for (const platform of config.platforms) { + if (!validPlatforms.includes(platform.type)) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + if (!platform.url || !this.isValidUrl(platform.url)) { + throw new Error(`无效的webhook URL: ${platform.url}`) + } + + // 验证平台特定的配置 + this.validatePlatformConfig(platform) + } + } + } + + /** + * 验证平台特定配置 + */ + validatePlatformConfig(platform) { + switch (platform.type) { + case 'wechat_work': + // 企业微信不需要额外配置 + break + case 'dingtalk': + // 钉钉可能需要secret用于签名 + if (platform.enableSign && !platform.secret) { + throw new Error('钉钉启用签名时必须提供secret') + } + break + case 'feishu': + // 飞书可能需要签名 + if (platform.enableSign && !platform.secret) { + throw new Error('飞书启用签名时必须提供secret') + } + break + case 'slack': + // Slack webhook URL通常包含token + if (!platform.url.includes('hooks.slack.com')) { + logger.warn('⚠️ Slack webhook URL格式可能不正确') + } + break + case 'discord': + // Discord webhook URL格式检查 + if (!platform.url.includes('discord.com/api/webhooks')) { + logger.warn('⚠️ Discord webhook URL格式可能不正确') + } + break + case 'custom': + // 自定义webhook,用户自行负责格式 + break + } + } + + /** + * 验证URL格式 + */ + isValidUrl(url) { + try { + new URL(url) + return true + } catch { + return false + } + } + + /** + * 获取默认配置 + */ + getDefaultConfig() { + return { + enabled: false, + platforms: [], + notificationTypes: { + accountAnomaly: true, // 账号异常 + quotaWarning: true, // 配额警告 + systemError: true, // 系统错误 + securityAlert: true, // 安全警报 + test: true // 测试通知 + }, + retrySettings: { + maxRetries: 3, + retryDelay: 1000, // 毫秒 + timeout: 10000 // 毫秒 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + } + + /** + * 添加webhook平台 + */ + async addPlatform(platform) { + try { + const config = await this.getConfig() + + // 生成唯一ID + platform.id = platform.id || uuidv4() + platform.enabled = platform.enabled !== false + platform.createdAt = new Date().toISOString() + + // 验证平台配置 + this.validatePlatformConfig(platform) + + // 添加到配置 + config.platforms = config.platforms || [] + config.platforms.push(platform) + + await this.saveConfig(config) + + return platform + } catch (error) { + logger.error('添加webhook平台失败:', error) + throw error + } + } + + /** + * 更新webhook平台 + */ + async updatePlatform(platformId, updates) { + try { + const config = await this.getConfig() + + const index = config.platforms.findIndex((p) => p.id === platformId) + if (index === -1) { + throw new Error('找不到指定的webhook平台') + } + + // 合并更新 + config.platforms[index] = { + ...config.platforms[index], + ...updates, + updatedAt: new Date().toISOString() + } + + // 验证更新后的配置 + this.validatePlatformConfig(config.platforms[index]) + + await this.saveConfig(config) + + return config.platforms[index] + } catch (error) { + logger.error('更新webhook平台失败:', error) + throw error + } + } + + /** + * 删除webhook平台 + */ + async deletePlatform(platformId) { + try { + const config = await this.getConfig() + + config.platforms = config.platforms.filter((p) => p.id !== platformId) + + await this.saveConfig(config) + + logger.info(`✅ 已删除webhook平台: ${platformId}`) + return true + } catch (error) { + logger.error('删除webhook平台失败:', error) + throw error + } + } + + /** + * 切换webhook平台启用状态 + */ + async togglePlatform(platformId) { + try { + const config = await this.getConfig() + + const platform = config.platforms.find((p) => p.id === platformId) + if (!platform) { + throw new Error('找不到指定的webhook平台') + } + + platform.enabled = !platform.enabled + platform.updatedAt = new Date().toISOString() + + await this.saveConfig(config) + + logger.info(`✅ Webhook平台 ${platformId} 已${platform.enabled ? '启用' : '禁用'}`) + return platform + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + throw error + } + } + + /** + * 获取启用的平台列表 + */ + async getEnabledPlatforms() { + try { + const config = await this.getConfig() + + if (!config.enabled || !config.platforms) { + return [] + } + + return config.platforms.filter((p) => p.enabled) + } catch (error) { + logger.error('获取启用的webhook平台失败:', error) + return [] + } + } +} + +module.exports = new WebhookConfigService() diff --git a/src/services/webhookService.js b/src/services/webhookService.js new file mode 100644 index 00000000..ad2778ff --- /dev/null +++ b/src/services/webhookService.js @@ -0,0 +1,495 @@ +const axios = require('axios') +const crypto = require('crypto') +const logger = require('../utils/logger') +const webhookConfigService = require('./webhookConfigService') + +class WebhookService { + constructor() { + this.platformHandlers = { + wechat_work: this.sendToWechatWork.bind(this), + dingtalk: this.sendToDingTalk.bind(this), + feishu: this.sendToFeishu.bind(this), + slack: this.sendToSlack.bind(this), + discord: this.sendToDiscord.bind(this), + custom: this.sendToCustom.bind(this) + } + } + + /** + * 发送通知到所有启用的平台 + */ + async sendNotification(type, data) { + try { + const config = await webhookConfigService.getConfig() + + // 检查是否启用webhook + if (!config.enabled) { + logger.debug('Webhook通知已禁用') + return + } + + // 检查通知类型是否启用(test类型始终允许发送) + if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) { + logger.debug(`通知类型 ${type} 已禁用`) + return + } + + // 获取启用的平台 + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + if (enabledPlatforms.length === 0) { + logger.debug('没有启用的webhook平台') + return + } + + logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`) + + // 并发发送到所有平台 + const promises = enabledPlatforms.map((platform) => + this.sendToPlatform(platform, type, data, config.retrySettings) + ) + + const results = await Promise.allSettled(promises) + + // 记录结果 + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed > 0) { + logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`) + } else { + logger.info(`✅ 所有webhook通知发送成功`) + } + + return { succeeded, failed } + } catch (error) { + logger.error('发送webhook通知失败:', error) + throw error + } + } + + /** + * 发送到特定平台 + */ + async sendToPlatform(platform, type, data, retrySettings) { + try { + const handler = this.platformHandlers[platform.type] + if (!handler) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // 使用平台特定的处理器 + await this.retryWithBackoff( + () => handler(platform, type, data), + retrySettings?.maxRetries || 3, + retrySettings?.retryDelay || 1000 + ) + + logger.info(`✅ 成功发送到 ${platform.name || platform.type}`) + } catch (error) { + logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message) + throw error + } + } + + /** + * 企业微信webhook + */ + async sendToWechatWork(platform, type, data) { + const content = this.formatMessageForWechatWork(type, data) + + const payload = { + msgtype: 'markdown', + markdown: { + content + } + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 钉钉webhook + */ + async sendToDingTalk(platform, type, data) { + const content = this.formatMessageForDingTalk(type, data) + + let { url } = platform + const payload = { + msgtype: 'markdown', + markdown: { + title: this.getNotificationTitle(type), + text: content + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Date.now() + const sign = this.generateDingTalkSign(platform.secret, timestamp) + url = `${url}×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` + } + + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * 飞书webhook + */ + async sendToFeishu(platform, type, data) { + const content = this.formatMessageForFeishu(type, data) + + const payload = { + msg_type: 'interactive', + card: { + elements: [ + { + tag: 'markdown', + content + } + ], + header: { + title: { + tag: 'plain_text', + content: this.getNotificationTitle(type) + }, + template: this.getFeishuCardColor(type) + } + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Math.floor(Date.now() / 1000) + const sign = this.generateFeishuSign(platform.secret, timestamp) + payload.timestamp = timestamp.toString() + payload.sign = sign + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Slack webhook + */ + async sendToSlack(platform, type, data) { + const text = this.formatMessageForSlack(type, data) + + const payload = { + text, + username: 'Claude Relay Service', + icon_emoji: this.getSlackEmoji(type) + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Discord webhook + */ + async sendToDiscord(platform, type, data) { + const embed = this.formatMessageForDiscord(type, data) + + const payload = { + username: 'Claude Relay Service', + embeds: [embed] + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 自定义webhook + */ + async sendToCustom(platform, type, data) { + // 使用通用格式 + const payload = { + type, + service: 'claude-relay-service', + timestamp: new Date().toISOString(), + data + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 发送HTTP请求 + */ + async sendHttpRequest(url, payload, timeout) { + const response = await axios.post(url, payload, { + timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-relay-service/2.0' + } + }) + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.data + } + + /** + * 重试机制 + */ + async retryWithBackoff(fn, maxRetries, baseDelay) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = baseDelay * Math.pow(2, i) // 指数退避 + logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + /** + * 生成钉钉签名 + */ + generateDingTalkSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', secret) + hmac.update(stringToSign) + return hmac.digest('base64') + } + + /** + * 生成飞书签名 + */ + generateFeishuSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + hmac.update('') + return hmac.digest('base64') + } + + /** + * 格式化企业微信消息 + */ + formatMessageForWechatWork(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return ( + `## ${title}\n\n` + + `> **服务**: Claude Relay Service\n` + + `> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + ) + } + + /** + * 格式化钉钉消息 + */ + formatMessageForDingTalk(type, data) { + const details = this.formatNotificationDetails(data) + + return ( + `#### 服务: Claude Relay Service\n` + + `#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + ) + } + + /** + * 格式化飞书消息 + */ + formatMessageForFeishu(type, data) { + return this.formatNotificationDetails(data) + } + + /** + * 格式化Slack消息 + */ + formatMessageForSlack(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return `*${title}*\n${details}` + } + + /** + * 格式化Discord消息 + */ + formatMessageForDiscord(type, data) { + const title = this.getNotificationTitle(type) + const color = this.getDiscordColor(type) + const fields = this.formatNotificationFields(data) + + return { + title, + color, + fields, + timestamp: new Date().toISOString(), + footer: { + text: 'Claude Relay Service' + } + } + } + + /** + * 获取通知标题 + */ + getNotificationTitle(type) { + const titles = { + accountAnomaly: '⚠️ 账号异常通知', + quotaWarning: '📊 配额警告', + systemError: '❌ 系统错误', + securityAlert: '🔒 安全警报', + test: '🧪 测试通知' + } + + return titles[type] || '📢 系统通知' + } + + /** + * 格式化通知详情 + */ + formatNotificationDetails(data) { + const lines = [] + + if (data.accountName) { + lines.push(`**账号**: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`**平台**: ${data.platform}`) + } + + if (data.status) { + lines.push(`**状态**: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`**错误代码**: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`**原因**: ${data.reason}`) + } + + if (data.message) { + lines.push(`**消息**: ${data.message}`) + } + + if (data.quota) { + lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`**使用率**: ${data.usage}%`) + } + + return lines.join('\n') + } + + /** + * 格式化Discord字段 + */ + formatNotificationFields(data) { + const fields = [] + + if (data.accountName) { + fields.push({ name: '账号', value: data.accountName, inline: true }) + } + + if (data.platform) { + fields.push({ name: '平台', value: data.platform, inline: true }) + } + + if (data.status) { + fields.push({ name: '状态', value: data.status, inline: true }) + } + + if (data.errorCode) { + fields.push({ name: '错误代码', value: data.errorCode, inline: false }) + } + + if (data.reason) { + fields.push({ name: '原因', value: data.reason, inline: false }) + } + + if (data.message) { + fields.push({ name: '消息', value: data.message, inline: false }) + } + + return fields + } + + /** + * 获取飞书卡片颜色 + */ + getFeishuCardColor(type) { + const colors = { + accountAnomaly: 'orange', + quotaWarning: 'yellow', + systemError: 'red', + securityAlert: 'red', + test: 'blue' + } + + return colors[type] || 'blue' + } + + /** + * 获取Slack emoji + */ + getSlackEmoji(type) { + const emojis = { + accountAnomaly: ':warning:', + quotaWarning: ':chart_with_downwards_trend:', + systemError: ':x:', + securityAlert: ':lock:', + test: ':test_tube:' + } + + return emojis[type] || ':bell:' + } + + /** + * 获取Discord颜色 + */ + getDiscordColor(type) { + const colors = { + accountAnomaly: 0xff9800, // 橙色 + quotaWarning: 0xffeb3b, // 黄色 + systemError: 0xf44336, // 红色 + securityAlert: 0xf44336, // 红色 + test: 0x2196f3 // 蓝色 + } + + return colors[type] || 0x9e9e9e // 灰色 + } + + /** + * 测试webhook连接 + */ + async testWebhook(platform) { + try { + const testData = { + message: 'Claude Relay Service webhook测试', + timestamp: new Date().toISOString() + } + + await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) + + return { success: true } + } catch (error) { + return { + success: false, + error: error.message + } + } + } +} + +module.exports = new WebhookService() diff --git a/src/utils/logger.js b/src/utils/logger.js index 9de2ec8f..ac4cd618 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -5,7 +5,7 @@ const path = require('path') const fs = require('fs') const os = require('os') -// 安全的 JSON 序列化函数,处理循环引用 +// 安全的 JSON 序列化函数,处理循环引用和特殊字符 const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const seen = new WeakSet() // 如果是fullDepth模式,增加深度限制 @@ -16,6 +16,28 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { return '[Max Depth Reached]' } + // 处理字符串值,清理可能导致JSON解析错误的特殊字符 + if (typeof value === 'string') { + try { + // 移除或转义可能导致JSON解析错误的字符 + let cleanValue = value + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符 + .replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符 + // eslint-disable-next-line no-control-regex + .replace(/\u0000/g, '') // 移除NUL字节 + + // 如果字符串过长,截断并添加省略号 + if (cleanValue.length > 1000) { + cleanValue = `${cleanValue.substring(0, 997)}...` + } + + return cleanValue + } catch (error) { + return '[Invalid String Data]' + } + } + if (value !== null && typeof value === 'object') { if (seen.has(value)) { return '[Circular Reference]' @@ -40,7 +62,10 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { } else { const result = {} for (const [k, v] of Object.entries(value)) { - result[k] = replacer(k, v, depth + 1) + // 确保键名也是安全的 + // eslint-disable-next-line no-control-regex + const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k + result[safeKey] = replacer(safeKey, v, depth + 1) } return result } @@ -50,9 +75,20 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { } try { - return JSON.stringify(replacer('', obj)) + const processed = replacer('', obj) + return JSON.stringify(processed) } catch (error) { - return JSON.stringify({ error: 'Failed to serialize object', message: error.message }) + // 如果JSON.stringify仍然失败,使用更保守的方法 + try { + return JSON.stringify({ + error: 'Failed to serialize object', + message: error.message, + type: typeof obj, + keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined + }) + } catch (finalError) { + return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}' + } } } @@ -60,8 +96,8 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const createLogFormat = (colorize = false) => { const formats = [ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.errors({ stack: true }), - winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] }) + winston.format.errors({ stack: true }) + // 移除 winston.format.metadata() 来避免自动包装 ] if (colorize) { @@ -69,7 +105,7 @@ const createLogFormat = (colorize = false) => { } formats.push( - winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => { + winston.format.printf(({ level, message, timestamp, stack, ...rest }) => { const emoji = { error: '❌', warn: '⚠️ ', @@ -80,12 +116,7 @@ const createLogFormat = (colorize = false) => { let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` - // 添加元数据 - if (metadata && Object.keys(metadata).length > 0) { - logMessage += ` | ${safeStringify(metadata)}` - } - - // 添加其他属性 + // 直接处理额外数据,不需要metadata包装 const additionalData = { ...rest } delete additionalData.level delete additionalData.message diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 36cb48aa..ac33b71e 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -4,8 +4,7 @@ */ const crypto = require('crypto') -const { SocksProxyAgent } = require('socks-proxy-agent') -const { HttpsProxyAgent } = require('https-proxy-agent') +const ProxyHelper = require('./proxyHelper') const axios = require('axios') const logger = require('./logger') @@ -125,36 +124,12 @@ function generateSetupTokenParams() { } /** - * 创建代理agent + * 创建代理agent(使用统一的代理工具) * @param {object|null} proxyConfig - 代理配置对象 * @returns {object|null} 代理agent或null */ function createProxyAgent(proxyConfig) { - if (!proxyConfig) { - return null - } - - try { - if (proxyConfig.type === 'socks5') { - const auth = - proxyConfig.username && proxyConfig.password - ? `${proxyConfig.username}:${proxyConfig.password}@` - : '' - const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}` - return new SocksProxyAgent(socksUrl) - } else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') { - const auth = - proxyConfig.username && proxyConfig.password - ? `${proxyConfig.username}:${proxyConfig.password}@` - : '' - const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}` - return new HttpsProxyAgent(httpUrl) - } - } catch (error) { - console.warn('⚠️ Invalid proxy configuration:', error) - } - - return null + return ProxyHelper.createProxyAgent(proxyConfig) } /** @@ -182,6 +157,14 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro const agent = createProxyAgent(proxyConfig) try { + if (agent) { + logger.info( + `🌐 Using proxy for OAuth token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for OAuth token exchange') + } + logger.debug('🔄 Attempting OAuth token exchange', { url: OAUTH_CONFIG.TOKEN_URL, codeLength: cleanedCode.length, @@ -379,6 +362,14 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr const agent = createProxyAgent(proxyConfig) try { + if (agent) { + logger.info( + `🌐 Using proxy for Setup Token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Setup Token exchange') + } + logger.debug('🔄 Attempting Setup Token exchange', { url: OAUTH_CONFIG.TOKEN_URL, codeLength: cleanedCode.length, diff --git a/src/utils/proxyHelper.js b/src/utils/proxyHelper.js new file mode 100644 index 00000000..ca409e62 --- /dev/null +++ b/src/utils/proxyHelper.js @@ -0,0 +1,212 @@ +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const logger = require('./logger') +const config = require('../../config/config') + +/** + * 统一的代理创建工具 + * 支持 SOCKS5 和 HTTP/HTTPS 代理,可配置 IPv4/IPv6 + */ +class ProxyHelper { + /** + * 创建代理 Agent + * @param {object|string|null} proxyConfig - 代理配置对象或 JSON 字符串 + * @param {object} options - 额外选项 + * @param {boolean|number} options.useIPv4 - 是否使用 IPv4 (true=IPv4, false=IPv6, undefined=auto) + * @returns {Agent|null} 代理 Agent 实例或 null + */ + static createProxyAgent(proxyConfig, options = {}) { + if (!proxyConfig) { + return null + } + + try { + // 解析代理配置 + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 验证必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + logger.warn('⚠️ Invalid proxy configuration: missing required fields (type, host, port)') + return null + } + + // 获取 IPv4/IPv6 配置 + const useIPv4 = ProxyHelper._getIPFamilyPreference(options.useIPv4) + + // 构建认证信息 + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + + // 根据代理类型创建 Agent + if (proxy.type === 'socks5') { + const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` + const socksOptions = {} + + // 设置 IP 协议族(如果指定) + if (useIPv4 !== null) { + socksOptions.family = useIPv4 ? 4 : 6 + } + + return new SocksProxyAgent(socksUrl, socksOptions) + } else if (proxy.type === 'http' || proxy.type === 'https') { + const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + const httpOptions = {} + + // HttpsProxyAgent 支持 family 参数(通过底层的 agent-base) + if (useIPv4 !== null) { + httpOptions.family = useIPv4 ? 4 : 6 + } + + return new HttpsProxyAgent(proxyUrl, httpOptions) + } else { + logger.warn(`⚠️ Unsupported proxy type: ${proxy.type}`) + return null + } + } catch (error) { + logger.warn('⚠️ Failed to create proxy agent:', error.message) + return null + } + } + + /** + * 获取 IP 协议族偏好设置 + * @param {boolean|number|string} preference - 用户偏好设置 + * @returns {boolean|null} true=IPv4, false=IPv6, null=auto + * @private + */ + static _getIPFamilyPreference(preference) { + // 如果没有指定偏好,使用配置文件或默认值 + if (preference === undefined) { + // 从配置文件读取默认设置,默认使用 IPv4 + const defaultUseIPv4 = config.proxy?.useIPv4 + if (defaultUseIPv4 !== undefined) { + return defaultUseIPv4 + } + // 默认值:IPv4(兼容性更好) + return true + } + + // 处理各种输入格式 + if (typeof preference === 'boolean') { + return preference + } + if (typeof preference === 'number') { + return preference === 4 ? true : preference === 6 ? false : null + } + if (typeof preference === 'string') { + const lower = preference.toLowerCase() + if (lower === 'ipv4' || lower === '4') { + return true + } + if (lower === 'ipv6' || lower === '6') { + return false + } + if (lower === 'auto' || lower === 'both') { + return null + } + } + + // 无法识别的值,返回默认(IPv4) + return true + } + + /** + * 验证代理配置 + * @param {object|string} proxyConfig - 代理配置 + * @returns {boolean} 是否有效 + */ + static validateProxyConfig(proxyConfig) { + if (!proxyConfig) { + return false + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 检查必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + return false + } + + // 检查支持的类型 + if (!['socks5', 'http', 'https'].includes(proxy.type)) { + return false + } + + // 检查端口范围 + const port = parseInt(proxy.port) + if (isNaN(port) || port < 1 || port > 65535) { + return false + } + + return true + } catch (error) { + return false + } + } + + /** + * 获取代理配置的描述信息 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 代理描述 + */ + static getProxyDescription(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + const hasAuth = proxy.username && proxy.password + return `${proxy.type}://${proxy.host}:${proxy.port}${hasAuth ? ' (with auth)' : ''}` + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 脱敏代理配置信息用于日志记录 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 脱敏后的代理信息 + */ + static maskProxyInfo(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + let proxyDesc = `${proxy.type}://${proxy.host}:${proxy.port}` + + // 如果有认证信息,进行脱敏处理 + if (proxy.username && proxy.password) { + const maskedUsername = + proxy.username.length <= 2 + ? proxy.username + : proxy.username[0] + + '*'.repeat(Math.max(1, proxy.username.length - 2)) + + proxy.username.slice(-1) + const maskedPassword = '*'.repeat(Math.min(8, proxy.password.length)) + proxyDesc += ` (auth: ${maskedUsername}:${maskedPassword})` + } + + return proxyDesc + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 创建代理 Agent(兼容旧的函数接口) + * @param {object|string|null} proxyConfig - 代理配置 + * @param {boolean} useIPv4 - 是否使用 IPv4 + * @returns {Agent|null} 代理 Agent 实例或 null + * @deprecated 使用 createProxyAgent 替代 + */ + static createProxy(proxyConfig, useIPv4 = true) { + logger.warn('⚠️ ProxyHelper.createProxy is deprecated, use createProxyAgent instead') + return ProxyHelper.createProxyAgent(proxyConfig, { useIPv4 }) + } +} + +module.exports = ProxyHelper diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index c95f3156..59c15147 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -1,13 +1,9 @@ -const axios = require('axios') const logger = require('./logger') -const config = require('../../config/config') +const webhookService = require('../services/webhookService') 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 + // 保留此类用于兼容性,实际功能委托给webhookService } /** @@ -22,94 +18,40 @@ class WebhookNotifier { * @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: { + try { + // 使用新的webhookService发送通知 + await webhookService.sendNotification('accountAnomaly', { accountId: notification.accountId, accountName: notification.accountName, platform: notification.platform, status: notification.status, - errorCode: notification.errorCode, + errorCode: + notification.errorCode || this._getErrorCode(notification.platform, notification.status), 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' - } + timestamp: notification.timestamp || new Date().toISOString() }) - - 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}`) + logger.error('Failed to send account anomaly notification:', error) } } /** - * 测试Webhook连通性 + * 测试Webhook连通性(兼容旧接口) * @param {string} url - Webhook URL + * @param {string} type - 平台类型(可选) */ - async testWebhook(url) { - const testPayload = { - type: 'test', - data: { - message: 'Claude Relay Service webhook test', - timestamp: new Date().toISOString(), - service: 'claude-relay-service' - } - } - + async testWebhook(url, type = 'custom') { try { - await this._sendWebhook(url, testPayload) - return { success: true } + // 创建临时平台配置 + const platform = { + type, + url, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) + return result } catch (error) { return { success: false, error: error.message } } diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 60401ee7..6d04a5f8 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -18,12 +18,14 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@vitejs/plugin-vue": "^4.5.2", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.6.14", @@ -806,6 +808,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.7", @@ -3465,6 +3483,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", @@ -3655,7 +3720,7 @@ }, "node_modules/prettier-plugin-tailwindcss": { "version": "0.6.14", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, "license": "MIT", diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json index 9cb15379..4881e1b3 100644 --- a/web/admin-spa/package.json +++ b/web/admin-spa/package.json @@ -21,12 +21,14 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@vitejs/plugin-vue": "^4.5.2", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/web/admin-spa/src/App.vue b/web/admin-spa/src/App.vue index a4c345a8..c68bfba7 100644 --- a/web/admin-spa/src/App.vue +++ b/web/admin-spa/src/App.vue @@ -11,14 +11,22 @@ + + diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index a0068ddb..6cfd0fb1 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -9,10 +9,12 @@ > -

创建新的 API Key

+

+ 创建新的 API Key +

@@ -160,11 +178,13 @@
-
创建新标签:
+
+ 创建新标签: +
-

用于标记不同团队或用途,方便筛选管理

+

+ 用于标记不同团队或用途,方便筛选管理 +

-
+
-

速率限制设置 (可选)

+

+ 速率限制设置 (可选) +

- -

时间段单位

+

时间段单位

- + -

窗口内最大请求

+

窗口内最大请求

- + -

窗口内最大Token

+

+ 窗口内最大Token +

-
-
💡 使用示例
-
+
+
+ 💡 使用示例 +
+
示例1: 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
@@ -254,34 +288,34 @@
-
-

+

设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制

- + -

+

设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制

- +