From e01e53910859b456790f01d4b96180224f5e3e80 Mon Sep 17 00:00:00 2001 From: weidian Date: Wed, 13 Aug 2025 17:52:46 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=BC=82=E5=B8=B8=E7=8A=B6=E6=80=81=20Webhook=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 - 新增账号禁用/异常状态的 Webhook 实时通知机制 - 支持 Claude OAuth、Claude Console、Gemini 三种平台的账号监控 - 提供完整的 Webhook 管理 API 和配置选项 ## 主要变更 ### 新增文件 - `src/utils/webhookNotifier.js`: Webhook 通知核心服务 - `src/routes/webhook.js`: Webhook 管理 API 路由 ### 功能集成 - Claude OAuth 账号:unauthorized 状态 + token 刷新错误通知 - Claude Console 账号:blocked 状态通知 - Gemini 账号:token 刷新错误通知 ### 配置支持 - 新增环境变量:WEBHOOK_ENABLED, WEBHOOK_URLS, WEBHOOK_TIMEOUT, WEBHOOK_RETRIES - 支持多个 Webhook URL 并发通知 - 自动重试机制(指数退避)+ 超时保护 ### 管理端点 - POST /admin/webhook/test: 测试连通性 - POST /admin/webhook/test-notification: 发送测试通知 - GET /admin/webhook/config: 查看配置信息 ## 通知格式 ```json { "type": "account_anomaly", "data": { "accountId": "uuid", "accountName": "账号名称", "platform": "claude-oauth|claude-console|gemini", "status": "unauthorized|blocked|error", "errorCode": "CLAUDE_OAUTH_UNAUTHORIZED", "reason": "具体异常原因", "timestamp": "2025-01-13T10:30:00.000Z" } } ``` 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 8 +- config/config.example.js | 8 ++ src/app.js | 2 + src/routes/webhook.js | 120 ++++++++++++++++ src/services/claudeAccountService.js | 30 ++++ src/services/claudeConsoleAccountService.js | 21 +++ src/services/geminiAccountService.js | 15 ++ src/utils/webhookNotifier.js | 144 ++++++++++++++++++++ 8 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/routes/webhook.js create mode 100644 src/utils/webhookNotifier.js diff --git a/.env.example b/.env.example index a796b496..e2949998 100644 --- a/.env.example +++ b/.env.example @@ -58,4 +58,10 @@ ENABLE_CORS=true TRUST_PROXY=true # 🔒 客户端限制(可选) -# ALLOW_CUSTOM_CLIENTS=false \ No newline at end of file +# ALLOW_CUSTOM_CLIENTS=false + +# 📢 Webhook 通知配置 +WEBHOOK_ENABLED=true +WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify +WEBHOOK_TIMEOUT=10000 +WEBHOOK_RETRIES=3 \ No newline at end of file diff --git a/config/config.example.js b/config/config.example.js index b3342c08..3bfb0fbb 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -120,6 +120,14 @@ const config = { allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' }, + // 📢 Webhook通知配置 + webhook: { + enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 + urls: process.env.WEBHOOK_URLS ? process.env.WEBHOOK_URLS.split(',').map(url => url.trim()) : [], + timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 + retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 + }, + // 🛠️ 开发配置 development: { debug: process.env.DEBUG === 'true', diff --git a/src/app.js b/src/app.js index 9af1a91e..0a0caba4 100644 --- a/src/app.js +++ b/src/app.js @@ -20,6 +20,7 @@ const geminiRoutes = require('./routes/geminiRoutes') const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') +const webhookRoutes = require('./routes/webhook') // Import middleware const { @@ -236,6 +237,7 @@ class Application { this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/claude', openaiClaudeRoutes) this.app.use('/openai', openaiRoutes) + this.app.use('/admin/webhook', webhookRoutes) // 🏠 根路径重定向到新版管理界面 this.app.get('/', (req, res) => { diff --git a/src/routes/webhook.js b/src/routes/webhook.js new file mode 100644 index 00000000..df670eb4 --- /dev/null +++ b/src/routes/webhook.js @@ -0,0 +1,120 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const webhookNotifier = require('../utils/webhookNotifier') +const { authenticateAdmin } = require('../middleware/auth') + +// 测试Webhook连通性 +router.post('/test', authenticateAdmin, async (req, res) => { + try { + const { url } = req.body + + if (!url) { + return res.status(400).json({ + error: 'Missing webhook URL', + message: 'Please provide a webhook URL to test' + }) + } + + // 验证URL格式 + try { + new URL(url) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid URL format', + message: 'Please provide a valid webhook URL' + }) + } + + logger.info(`🧪 Testing webhook URL: ${url}`) + + const result = await webhookNotifier.testWebhook(url) + + if (result.success) { + logger.info(`✅ Webhook test successful for: ${url}`) + res.json({ + success: true, + message: 'Webhook test successful', + url: url + }) + } else { + logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`) + res.status(400).json({ + success: false, + message: 'Webhook test failed', + url: url, + error: result.error + }) + } + } catch (error) { + logger.error('❌ Webhook test error:', error) + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to test webhook' + }) + } +}) + +// 手动触发账号异常通知(用于测试) +router.post('/test-notification', authenticateAdmin, async (req, res) => { + try { + const { + accountId = 'test-account-id', + accountName = 'Test Account', + platform = 'claude-oauth', + status = 'error', + errorCode = 'TEST_ERROR', + reason = 'Manual test notification' + } = req.body + + logger.info(`🧪 Sending test notification for account: ${accountName}`) + + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName, + platform, + status, + errorCode, + reason + }) + + logger.info(`✅ Test notification sent successfully`) + + res.json({ + success: true, + message: 'Test notification sent successfully', + data: { + accountId, + accountName, + platform, + status, + errorCode, + reason + } + }) + } catch (error) { + logger.error('❌ Failed to send test notification:', error) + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to send test notification' + }) + } +}) + +// 获取Webhook配置信息 +router.get('/config', authenticateAdmin, (req, res) => { + const config = require('../../config/config') + + res.json({ + success: true, + config: { + enabled: config.webhook?.enabled !== false, + urls: config.webhook?.urls || [], + timeout: config.webhook?.timeout || 10000, + retries: config.webhook?.retries || 3, + urlCount: (config.webhook?.urls || []).length + } + }) +}) + +module.exports = router diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 2029957b..da512f97 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -228,6 +228,21 @@ class ClaudeAccountService { accountData.status = 'error' accountData.errorMessage = error.message await redis.setClaudeAccount(accountId, accountData) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } } logger.error(`❌ Failed to refresh token for account ${accountId}:`, error) @@ -1223,6 +1238,21 @@ class ClaudeAccountService { `⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling` ) + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'unauthorized', + errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED', + reason: 'Account unauthorized (401 errors detected)' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + return { success: true } } catch (error) { logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index e7385bdd..5b23d6d1 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -395,6 +395,9 @@ class ClaudeConsoleAccountService { try { const client = redis.getClientSafe() + // 获取账户信息用于webhook通知 + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + const updates = { status: 'blocked', errorMessage: reason, @@ -404,6 +407,24 @@ class ClaudeConsoleAccountService { await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`) + + // 发送Webhook通知 + if (accountData && Object.keys(accountData).length > 0) { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'blocked', + errorCode: 'CLAUDE_CONSOLE_BLOCKED', + reason: reason + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + return { success: true } } catch (error) { logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 498ab4fe..179116b3 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -764,6 +764,21 @@ async function refreshAccountToken(accountId) { status: 'error', errorMessage: error.message }) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name, + platform: 'gemini', + status: 'error', + errorCode: 'GEMINI_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } } catch (updateError) { logger.error('Failed to update account status after refresh error:', updateError) } diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js new file mode 100644 index 00000000..648debca --- /dev/null +++ b/src/utils/webhookNotifier.js @@ -0,0 +1,144 @@ +const axios = require('axios') +const logger = require('./logger') +const config = require('../../config/config') + +class WebhookNotifier { + constructor() { + this.webhookUrls = config.webhook?.urls || [] + this.timeout = config.webhook?.timeout || 10000 + this.retries = config.webhook?.retries || 3 + this.enabled = config.webhook?.enabled !== false + } + + /** + * 发送账号异常通知 + * @param {Object} notification - 通知内容 + * @param {string} notification.accountId - 账号ID + * @param {string} notification.accountName - 账号名称 + * @param {string} notification.platform - 平台类型 (claude-oauth, claude-console, gemini) + * @param {string} notification.status - 异常状态 (unauthorized, blocked, error) + * @param {string} notification.errorCode - 异常代码 + * @param {string} notification.reason - 异常原因 + * @param {string} notification.timestamp - 时间戳 + */ + async sendAccountAnomalyNotification(notification) { + if (!this.enabled || this.webhookUrls.length === 0) { + logger.debug('Webhook notification disabled or no URLs configured') + return + } + + const payload = { + type: 'account_anomaly', + data: { + accountId: notification.accountId, + accountName: notification.accountName, + platform: notification.platform, + status: notification.status, + errorCode: notification.errorCode, + reason: notification.reason, + timestamp: notification.timestamp || new Date().toISOString(), + service: 'claude-relay-service' + } + } + + logger.info( + `📢 Sending account anomaly webhook notification: ${notification.accountName} (${notification.accountId}) - ${notification.status}` + ) + + const promises = this.webhookUrls.map((url) => this._sendWebhook(url, payload)) + + try { + await Promise.allSettled(promises) + } catch (error) { + logger.error('Failed to send webhook notifications:', error) + } + } + + /** + * 发送Webhook请求 + * @param {string} url - Webhook URL + * @param {Object} payload - 请求载荷 + */ + async _sendWebhook(url, payload, attempt = 1) { + try { + const response = await axios.post(url, payload, { + timeout: this.timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-relay-service/webhook-notifier' + } + }) + + if (response.status >= 200 && response.status < 300) { + logger.info(`✅ Webhook sent successfully to ${url}`) + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + } catch (error) { + logger.error( + `❌ Failed to send webhook to ${url} (attempt ${attempt}/${this.retries}):`, + error.message + ) + + // 重试机制 + if (attempt < this.retries) { + const delay = Math.pow(2, attempt - 1) * 1000 // 指数退避 + logger.info(`🔄 Retrying webhook to ${url} in ${delay}ms...`) + + await new Promise((resolve) => setTimeout(resolve, delay)) + return this._sendWebhook(url, payload, attempt + 1) + } + + logger.error(`💥 All ${this.retries} webhook attempts failed for ${url}`) + } + } + + /** + * 测试Webhook连通性 + * @param {string} url - Webhook URL + */ + async testWebhook(url) { + const testPayload = { + type: 'test', + data: { + message: 'Claude Relay Service webhook test', + timestamp: new Date().toISOString(), + service: 'claude-relay-service' + } + } + + try { + await this._sendWebhook(url, testPayload) + return { success: true } + } catch (error) { + return { success: false, error: error.message } + } + } + + /** + * 获取错误代码映射 + * @param {string} platform - 平台类型 + * @param {string} status - 状态 + * @param {string} reason - 原因 + */ + _getErrorCode(platform, status, reason) { + const errorCodes = { + 'claude-oauth': { + unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', + error: 'CLAUDE_OAUTH_ERROR' + }, + 'claude-console': { + blocked: 'CLAUDE_CONSOLE_BLOCKED', + error: 'CLAUDE_CONSOLE_ERROR' + }, + gemini: { + error: 'GEMINI_ERROR', + unauthorized: 'GEMINI_UNAUTHORIZED' + } + } + + return errorCodes[platform]?.[status] || 'UNKNOWN_ERROR' + } +} + +module.exports = new WebhookNotifier() From d2bcb8ef5e580e0dafa03d75d11f2008b6bb5662 Mon Sep 17 00:00:00 2001 From: iRubbish Date: Thu, 14 Aug 2025 09:54:15 +0800 Subject: [PATCH 02/24] =?UTF-8?q?style:=20=E4=BD=BF=E7=94=A8=20prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 格式化 config/config.example.js 文件 - 确保所有代码符合项目代码风格规范 - 解决自动构建中的格式检查问题 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- config/config.example.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/config/config.example.js b/config/config.example.js index 3bfb0fbb..e3584e48 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -1,5 +1,5 @@ -const path = require('path'); -require('dotenv').config(); +const path = require('path') +require('dotenv').config() const config = { // 🌐 服务器配置 @@ -29,14 +29,16 @@ const config = { retryDelayOnFailover: 100, maxRetriesPerRequest: 3, lazyConnect: true, - enableTLS: process.env.REDIS_ENABLE_TLS === 'true', + enableTLS: process.env.REDIS_ENABLE_TLS === 'true' }, // 🎯 Claude API配置 claude: { apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01', - betaHeader: process.env.CLAUDE_BETA_HEADER || 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + betaHeader: + process.env.CLAUDE_BETA_HEADER || + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' }, // ☁️ Bedrock API配置 @@ -45,7 +47,8 @@ const config = { defaultRegion: process.env.AWS_REGION || 'us-east-1', smallFastModelRegion: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION, defaultModel: process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0', - smallFastModel: process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + smallFastModel: + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0', maxOutputTokens: parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096, maxThinkingTokens: parseInt(process.env.MAX_THINKING_TOKENS) || 1024, enablePromptCaching: process.env.DISABLE_PROMPT_CACHING !== '1' @@ -82,7 +85,9 @@ const config = { // 🎨 Web界面配置 web: { title: process.env.WEB_TITLE || 'Claude Relay Service', - description: process.env.WEB_DESCRIPTION || 'Multi-account Claude API relay service with beautiful management interface', + description: + process.env.WEB_DESCRIPTION || + 'Multi-account Claude API relay service with beautiful management interface', logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png', enableCors: process.env.ENABLE_CORS === 'true', sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET' @@ -123,7 +128,9 @@ const config = { // 📢 Webhook通知配置 webhook: { enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 - urls: process.env.WEBHOOK_URLS ? process.env.WEBHOOK_URLS.split(',').map(url => url.trim()) : [], + urls: process.env.WEBHOOK_URLS + ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim()) + : [], timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 }, @@ -133,6 +140,6 @@ const config = { debug: process.env.DEBUG === 'true', hotReload: process.env.HOT_RELOAD === 'true' } -}; +} -module.exports = config; \ No newline at end of file +module.exports = config From 2b77fdc06c570c12eef3dcd7c99ccce1f580b377 Mon Sep 17 00:00:00 2001 From: iRubbish Date: Thu, 14 Aug 2025 14:14:01 +0800 Subject: [PATCH 03/24] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ESLint=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复正则表达式中不必要的转义字符 - 使用对象解构优化代码风格 - 修复未使用变量的命名规范 - 确保所有修改文件通过 ESLint 检查 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- config/config.example.js | 4 ++-- src/app.js | 4 ++-- src/routes/webhook.js | 4 ++-- src/services/claudeConsoleAccountService.js | 2 +- src/utils/webhookNotifier.js | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/config/config.example.js b/config/config.example.js index e3584e48..1ab101cc 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -103,7 +103,7 @@ const config = { description: 'Official Claude Code CLI', // 匹配 Claude CLI 的 User-Agent // 示例: claude-cli/1.0.58 (external, cli) - userAgentPattern: /^claude-cli\/[\d\.]+\s+\(/i + userAgentPattern: /^claude-cli\/[\d.]+\s+\(/i }, { id: 'gemini_cli', @@ -111,7 +111,7 @@ const config = { description: 'Gemini Command Line Interface', // 匹配 GeminiCLI 的 User-Agent // 示例: GeminiCLI/v18.20.8 (darwin; arm64) - userAgentPattern: /^GeminiCLI\/v?[\d\.]+\s+\(/i + userAgentPattern: /^GeminiCLI\/v?[\d.]+\s+\(/i } // 添加自定义客户端示例: // { diff --git a/src/app.js b/src/app.js index 0a0caba4..e7dfd7e7 100644 --- a/src/app.js +++ b/src/app.js @@ -271,8 +271,8 @@ class Application { } if (!version) { try { - const packageJson = require('../package.json') - version = packageJson.version + const { version: pkgVersion } = require('../package.json') + version = pkgVersion } catch (error) { version = '1.0.0' } diff --git a/src/routes/webhook.js b/src/routes/webhook.js index df670eb4..5c3adcef 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -35,14 +35,14 @@ router.post('/test', authenticateAdmin, async (req, res) => { res.json({ success: true, message: 'Webhook test successful', - url: url + url }) } else { logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`) res.status(400).json({ success: false, message: 'Webhook test failed', - url: url, + url, error: result.error }) } diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 5b23d6d1..91df2600 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -418,7 +418,7 @@ class ClaudeConsoleAccountService { platform: 'claude-console', status: 'blocked', errorCode: 'CLAUDE_CONSOLE_BLOCKED', - reason: reason + reason }) } catch (webhookError) { logger.error('Failed to send webhook notification:', webhookError) diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index 648debca..1015f581 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -119,9 +119,9 @@ class WebhookNotifier { * 获取错误代码映射 * @param {string} platform - 平台类型 * @param {string} status - 状态 - * @param {string} reason - 原因 + * @param {string} _reason - 原因 (未使用) */ - _getErrorCode(platform, status, reason) { + _getErrorCode(platform, status, _reason) { const errorCodes = { 'claude-oauth': { unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', From b33c6594917afddb0ccc1cc55ffba7f000d81c27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Aug 2025 06:31:28 +0000 Subject: [PATCH 04/24] chore: sync VERSION file with release v1.1.108 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 8c43a3a9..c69d5ced 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.107 +1.1.108 From 0e5f4e03c10bf7eadef0a705799d128a39a453b3 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Thu, 14 Aug 2025 16:43:58 +0800 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EClaude=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AE=A2=E9=98=85=E7=B1=BB=E5=9E=8B=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=201.=20OAuth=E5=8F=AF=E8=87=AA=E5=8A=A8=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=B1=BB=E5=9E=8B=EF=BC=8CSetup=20Token?= =?UTF-8?q?=E8=AF=B7=E8=87=AA=E8=A1=8C=E9=80=89=E6=8B=A9=E3=80=82=E6=97=A0?= =?UTF-8?q?=E8=AE=BA=E9=82=A3=E7=A7=8D=E7=B1=BB=E5=9E=8B=E9=83=BD=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E8=87=AA=E5=B7=B1=E6=94=B9=202.=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=EF=BC=8CPro=E8=B4=A6=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8E=A5=E5=8F=97opus=E6=A8=A1=E5=9E=8B=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=9A=84=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 40 ++ src/services/claudeAccountService.js | 351 +++++++++++++++++- src/services/unifiedClaudeScheduler.js | 29 ++ src/utils/logger.js | 43 ++- src/utils/oauthHelper.js | 84 ++++- .../src/components/accounts/AccountForm.vue | 120 +++++- web/admin-spa/src/views/AccountsView.vue | 52 ++- 7 files changed, 697 insertions(+), 22 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 997bef0b..3ecf71ff 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1376,6 +1376,46 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) } }) +// 更新单个Claude账户的Profile信息 +router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) + + logger.success(`✅ Updated profile for Claude account: ${accountId}`) + return res.json({ + success: true, + message: 'Account profile updated successfully', + data: profileInfo + }) + } catch (error) { + logger.error('❌ Failed to update account profile:', error) + return res + .status(500) + .json({ error: 'Failed to update account profile', message: error.message }) + } +}) + +// 批量更新所有Claude账户的Profile信息 +router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => { + try { + const result = await claudeAccountService.updateAllAccountProfiles() + + logger.success('✅ Batch profile update completed') + return res.json({ + success: true, + message: 'Batch profile update completed', + data: result + }) + } catch (error) { + logger.error('❌ Failed to update all account profiles:', error) + return res + .status(500) + .json({ error: 'Failed to update all account profiles', message: error.message }) + } +}) + // 刷新Claude账户token router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { try { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 2029957b..46b65c07 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -39,7 +39,8 @@ class ClaudeAccountService { isActive = true, accountType = 'shared', // 'dedicated' or 'shared' priority = 50, // 调度优先级 (1-100,数字越小优先级越高) - schedulable = true // 是否可被调度 + schedulable = true, // 是否可被调度 + subscriptionInfo = null // 手动设置的订阅信息 } = options const accountId = uuidv4() @@ -68,7 +69,13 @@ class ClaudeAccountService { lastRefreshAt: '', status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', - schedulable: schedulable.toString() // 是否可被调度 + schedulable: schedulable.toString(), // 是否可被调度 + // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 + subscriptionInfo: subscriptionInfo + ? JSON.stringify(subscriptionInfo) + : claudeAiOauth.subscriptionInfo + ? JSON.stringify(claudeAiOauth.subscriptionInfo) + : '' } } else { // 兼容旧格式 @@ -91,7 +98,9 @@ class ClaudeAccountService { lastRefreshAt: '', status: 'created', // created, active, expired, error errorMessage: '', - schedulable: schedulable.toString() // 是否可被调度 + schedulable: schedulable.toString(), // 是否可被调度 + // 手动设置的订阅信息 + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } } @@ -99,6 +108,24 @@ class ClaudeAccountService { logger.success(`🏢 Created Claude account: ${name} (${accountId})`) + // 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息 + if (claudeAiOauth && claudeAiOauth.accessToken) { + // 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + const agent = this._createProxyAgent(proxy) + await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent) + logger.info(`📊 Successfully fetched profile info for new account: ${name}`) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`) + } + } else { + logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`) + } + } + return { id: accountId, name, @@ -188,8 +215,39 @@ class ClaudeAccountService { ) if (response.status === 200) { + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Token refresh response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Token refresh response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + const { access_token, refresh_token, expires_in } = response.data + // 检查是否有套餐信息 + if ( + response.data.subscription || + response.data.plan || + response.data.tier || + response.data.account_type + ) { + const subscriptionInfo = { + subscription: response.data.subscription, + plan: response.data.plan, + tier: response.data.tier, + accountType: response.data.account_type, + features: response.data.features, + limits: response.data.limits + } + logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo) + + // 将套餐信息存储在账户数据中 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + } + // 更新账户数据 accountData.accessToken = this._encryptSensitiveData(access_token) accountData.refreshToken = this._encryptSensitiveData(refresh_token) @@ -200,6 +258,22 @@ class ClaudeAccountService { await redis.setClaudeAccount(accountId, accountData) + // 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息 + // 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + await this.fetchAndUpdateAccountProfile(accountId, access_token, agent) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`) + } + } else { + logger.debug( + `⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)` + ) + } + // 记录刷新成功 logRefreshSuccess(accountId, accountData.name, 'claude', { accessToken: access_token, @@ -343,6 +417,10 @@ class ClaudeAccountService { lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, expiresAt: account.expiresAt, + // 添加套餐信息(如果存在) + subscriptionInfo: account.subscriptionInfo + ? JSON.parse(account.subscriptionInfo) + : null, // 添加限流状态信息 rateLimitStatus: rateLimitInfo ? { @@ -393,7 +471,8 @@ class ClaudeAccountService { 'claudeAiOauth', 'accountType', 'priority', - 'schedulable' + 'schedulable', + 'subscriptionInfo' ] const updatedData = { ...accountData } @@ -408,6 +487,9 @@ class ClaudeAccountService { updatedData[field] = value ? JSON.stringify(value) : '' } else if (field === 'priority') { updatedData[field] = value.toString() + } else if (field === 'subscriptionInfo') { + // 处理订阅信息更新 + updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { @@ -482,15 +564,43 @@ class ClaudeAccountService { } } - // 🎯 智能选择可用账户(支持sticky会话) - async selectAvailableAccount(sessionHash = null) { + // 🎯 智能选择可用账户(支持sticky会话和模型过滤) + async selectAvailableAccount(sessionHash = null, modelName = null) { try { const accounts = await redis.getAllClaudeAccounts() - const activeAccounts = accounts.filter( + let activeAccounts = accounts.filter( (account) => account.isActive === 'true' && account.status !== 'error' ) + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + activeAccounts = activeAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (activeAccounts.length === 0) { + throw new Error('No Claude accounts available that support Opus model') + } + } + if (activeAccounts.length === 0) { throw new Error('No active Claude accounts available') } @@ -541,8 +651,8 @@ class ClaudeAccountService { } } - // 🎯 基于API Key选择账户(支持专属绑定和共享池) - async selectAccountForApiKey(apiKeyData, sessionHash = null) { + // 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤) + async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) { try { // 如果API Key绑定了专属账户,优先使用 if (apiKeyData.claudeAccountId) { @@ -562,13 +672,41 @@ class ClaudeAccountService { // 如果没有绑定账户或绑定账户不可用,从共享池选择 const accounts = await redis.getAllClaudeAccounts() - const sharedAccounts = accounts.filter( + let sharedAccounts = accounts.filter( (account) => account.isActive === 'true' && account.status !== 'error' && (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 ) + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + sharedAccounts = sharedAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (sharedAccounts.length === 0) { + throw new Error('No shared Claude accounts available that support Opus model') + } + } + if (sharedAccounts.length === 0) { throw new Error('No active shared Claude accounts available') } @@ -1117,6 +1255,199 @@ class ClaudeAccountService { } } + // 📊 获取账号 Profile 信息并更新账号类型 + async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 检查账户是否有 user:profile 权限 + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.warn( + `⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile` + ) + throw new Error('Account does not have user:profile permission') + } + + // 如果没有提供 accessToken,使用账号存储的 token + if (!accessToken) { + accessToken = this._decryptSensitiveData(accountData.accessToken) + if (!accessToken) { + throw new Error('No access token available') + } + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`) + + // 请求 profile 接口 + const response = await axios.get('https://api.anthropic.com/api/oauth/profile', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + httpsAgent: agent, + timeout: 15000 + }) + + if (response.status === 200 && response.data) { + const profileData = response.data + + logger.info('✅ Successfully fetched profile data:', { + email: profileData.account?.email, + hasClaudeMax: profileData.account?.has_claude_max, + hasClaudePro: profileData.account?.has_claude_pro, + organizationType: profileData.organization?.organization_type + }) + + // 构建订阅信息 + const subscriptionInfo = { + // 账号信息 + email: profileData.account?.email, + fullName: profileData.account?.full_name, + displayName: profileData.account?.display_name, + hasClaudeMax: profileData.account?.has_claude_max || false, + hasClaudePro: profileData.account?.has_claude_pro || false, + accountUuid: profileData.account?.uuid, + + // 组织信息 + organizationName: profileData.organization?.name, + organizationUuid: profileData.organization?.uuid, + billingType: profileData.organization?.billing_type, + rateLimitTier: profileData.organization?.rate_limit_tier, + organizationType: profileData.organization?.organization_type, + + // 账号类型(基于 has_claude_max 和 has_claude_pro 判断) + accountType: + profileData.account?.has_claude_max === true + ? 'claude_max' + : profileData.account?.has_claude_pro === true + ? 'claude_pro' + : 'free', + + // 更新时间 + profileFetchedAt: new Date().toISOString() + } + + // 更新账户数据 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + accountData.profileUpdatedAt = new Date().toISOString() + + // 如果提供了邮箱,更新邮箱字段 + if (profileData.account?.email) { + accountData.email = this._encryptSensitiveData(profileData.account.email) + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success( + `✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}` + ) + + return subscriptionInfo + } else { + throw new Error(`Failed to fetch profile with status: ${response.status}`) + } + } catch (error) { + if (error.response?.status === 401) { + logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`) + } else if (error.response?.status === 403) { + logger.warn( + `⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions` + ) + } else { + logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message) + } + throw error + } + } + + // 🔄 手动更新所有账号的 Profile 信息 + async updateAllAccountProfiles() { + try { + logger.info('🔄 Starting batch profile update for all accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let successCount = 0 + let failureCount = 0 + const results = [] + + for (const account of accounts) { + // 跳过未激活或错误状态的账号 + if (account.isActive !== 'true' || account.status === 'error') { + logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`) + continue + } + + // 跳过没有 user:profile 权限的账号(Setup Token 账号) + const hasProfileScope = account.scopes && account.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.info( + `⏩ Skipping account without user:profile scope: ${account.name} (${account.id})` + ) + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: 'No user:profile permission (Setup Token account)' + }) + continue + } + + try { + // 获取有效的 access token + const accessToken = await this.getValidAccessToken(account.id) + if (accessToken) { + const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken) + successCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: true, + accountType: profileInfo.accountType + }) + } + } catch (error) { + failureCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: error.message + }) + logger.warn( + `⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}` + ) + } + + // 添加延迟以避免触发限流 + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`) + + return { + totalAccounts: accounts.length, + successCount, + failureCount, + results + } + } catch (error) { + logger.error('❌ Failed to update account profiles:', error) + throw error + } + } + // 🔄 初始化所有账户的会话窗口(从历史数据恢复) async initializeSessionWindows(forceRecalculate = false) { try { diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 47ee1499..287bb465 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -267,6 +267,35 @@ class UnifiedClaudeScheduler { ) { // 检查是否可调度 + // 检查模型支持(如果请求的是 Opus 模型) + if (requestedModel && requestedModel.toLowerCase().includes('opus')) { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`) + continue // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + logger.info( + `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model` + ) + continue // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`) + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + } + // 检查是否被限流 const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) if (!isRateLimited) { diff --git a/src/utils/logger.js b/src/utils/logger.js index 29045620..9de2ec8f 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -6,11 +6,13 @@ const fs = require('fs') const os = require('os') // 安全的 JSON 序列化函数,处理循环引用 -const safeStringify = (obj, maxDepth = 3) => { +const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const seen = new WeakSet() + // 如果是fullDepth模式,增加深度限制 + const actualMaxDepth = fullDepth ? 10 : maxDepth const replacer = (key, value, depth = 0) => { - if (depth > maxDepth) { + if (depth > actualMaxDepth) { return '[Max Depth Reached]' } @@ -152,6 +154,21 @@ const securityLogger = winston.createLogger({ silent: false }) +// 🔐 创建专门的认证详细日志记录器(记录完整的认证响应) +const authDetailLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ level, message, timestamp, data }) => { + // 使用更深的深度和格式化的JSON输出 + const jsonData = data ? JSON.stringify(data, null, 2) : '{}' + return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}` + }) + ), + transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')], + silent: false +}) + // 🌟 增强的 Winston logger const logger = winston.createLogger({ level: process.env.LOG_LEVEL || config.logging.level, @@ -327,6 +344,28 @@ logger.healthCheck = () => { } } +// 🔐 记录认证详细信息的方法 +logger.authDetail = (message, data = {}) => { + try { + // 记录到主日志(简化版) + logger.info(`🔐 ${message}`, { + type: 'auth-detail', + summary: { + hasAccessToken: !!data.access_token, + hasRefreshToken: !!data.refresh_token, + scopes: data.scope || data.scopes, + organization: data.organization?.name, + account: data.account?.email_address + } + }) + + // 记录到专门的认证详细日志文件(完整数据) + authDetailLogger.info(message, { data }) + } catch (error) { + logger.error('Failed to log auth detail:', error) + } +} + // 🎬 启动日志记录系统 logger.start('Logger initialized', { level: process.env.LOG_LEVEL || config.logging.level, diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 008eb4cc..36cb48aa 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -16,7 +16,7 @@ const OAUTH_CONFIG = { CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', SCOPES: 'org:create_api_key user:profile user:inference', - SCOPES_SETUP: 'user:inference' + SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 } /** @@ -203,23 +203,55 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro timeout: 30000 }) + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('OAuth token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 OAuth token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + logger.success('✅ OAuth token exchange successful', { status: response.status, hasAccessToken: !!response.data?.access_token, hasRefreshToken: !!response.data?.refresh_token, - scopes: response.data?.scope + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits }) const { data } = response - // 返回Claude格式的token数据 - return { + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], isMax: true } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in OAuth response:', result.subscriptionInfo) + } + + return result } catch (error) { // 处理axios错误响应 if (error.response) { @@ -340,7 +372,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr redirect_uri: OAUTH_CONFIG.REDIRECT_URI, code_verifier: codeVerifier, state, - expires_in: 31536000 + expires_in: 31536000 // Setup Token 可以设置较长的过期时间 } // 创建代理agent @@ -368,16 +400,54 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr timeout: 30000 }) + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Setup Token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Setup Token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('✅ Setup Token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + const { data } = response - // 返回Claude格式的token数据 - return { + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { accessToken: data.access_token, refreshToken: '', expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], isMax: true } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in Setup Token response:', result.subscriptionInfo) + } + + return result } catch (error) { // 使用与标准OAuth相同的错误处理逻辑 if (error.response) { diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 14f1a215..4f833da3 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -555,6 +555,44 @@ + +
+ +
+ + + +
+

+ + Pro 和 Free 账号不支持 Claude Opus 4 模型 +

+
+
Google Cloud/Workspace 账号可能需要提供项目 ID

+ +
+ +
+ + + +
+

+ + Pro 和 Free 账号不支持 Claude Opus 4 模型 +

+
+
Oauth
- Claude + {{ + getClaudeAccountType(account) + }} {{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }}
+
+ + 未知 +
@@ -1378,6 +1387,45 @@ const handleEditSuccess = () => { loadAccounts() } +// 获取 Claude 账号类型显示 +const getClaudeAccountType = (account) => { + // 如果有订阅信息 + if (account.subscriptionInfo) { + try { + // 如果 subscriptionInfo 是字符串,尝试解析 + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // 添加调试日志 + console.log('Account subscription info:', { + accountName: account.name, + subscriptionInfo: info, + hasClaudeMax: info.hasClaudeMax, + hasClaudePro: info.hasClaudePro + }) + + // 根据 has_claude_max 和 has_claude_pro 判断 + if (info.hasClaudeMax === true) { + return 'Claude Max' + } else if (info.hasClaudePro === true) { + return 'Claude Pro' + } else { + return 'Claude Free' + } + } catch (e) { + // 解析失败,返回默认值 + console.error('Failed to parse subscription info:', e) + return 'Claude' + } + } + + // 没有订阅信息,保持原有显示 + console.log('No subscription info for account:', account.name) + return 'Claude' +} + // 获取账户状态文本 const getAccountStatusText = (account) => { // 检查是否被封锁 From f1febd320e4ec5ae233066dbf580c44ad13654bc Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Thu, 14 Aug 2025 16:49:20 +0800 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89Free=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/accounts/AccountForm.vue | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 4f833da3..c8dcdcb8 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -577,19 +577,10 @@ /> Claude Pro -

- Pro 和 Free 账号不支持 Claude Opus 4 模型 + Pro 账号不支持 Claude Opus 4 模型

@@ -1021,19 +1012,10 @@ /> Claude Pro -

- Pro 和 Free 账号不支持 Claude Opus 4 模型 + Pro 账号不支持 Claude Opus 4 模型

From d2ddd0cdd83f82381fe227b9ab65fc9453f73182 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Aug 2025 08:51:34 +0000 Subject: [PATCH 07/24] chore: sync VERSION file with release v1.1.109 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c69d5ced..7babc036 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.108 +1.1.109 From c9b0c3eb824bb8b5be3df6ad032cecd974550353 Mon Sep 17 00:00:00 2001 From: iRubbish Date: Thu, 14 Aug 2025 18:28:48 +0800 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20webhook=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E7=A6=81=E7=94=A8=E8=B4=A6=E5=8F=B7=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加手动禁用账号时的 webhook 通知功能 - 支持所有账号类型:Claude OAuth、Claude Console、Gemini - 新增错误代码:MANUALLY_DISABLED 系列 - 更新 README.md 文档,添加完整的 webhook 配置说明 - 包含企业微信配置示例和测试方法 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 153 ++++++++++++++++---- src/services/claudeAccountService.js | 20 +++ src/services/claudeConsoleAccountService.js | 20 +++ src/services/geminiAccountService.js | 17 +++ src/utils/webhookNotifier.js | 9 +- 5 files changed, 191 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0542c9ed..82625103 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml) [![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service) -**🔐 自行搭建Claude API中转服务,支持多账户管理** +**🔐 自行搭建Claude API中转服务,支持多账户管理** [English](#english) • [中文文档](#中文文档) • [📸 界面预览](docs/preview.md) • [📢 公告频道](https://t.me/claude_relay_service) @@ -35,11 +35,11 @@ --- > 💡 **感谢 [@vista8](https://x.com/vista8) 的推荐!** -> +> > 如果你对Vibe coding感兴趣,推荐关注: -> +> > - 🐦 **X**: [@vista8](https://x.com/vista8) - 分享前沿技术动态 -> - 📱 **公众号**: 向阳乔木推荐看 +> - 📱 **公众号**: 向阳乔木推荐看 --- @@ -62,14 +62,14 @@ ✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容 ✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护 ✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站 -✅ **地区受限**: 无法直接访问Claude官方服务 +✅ **地区受限**: 无法直接访问Claude官方服务 ### 不适合的场景 ❌ **纯小白**: 完全不懂技术,连服务器都不会买 ❌ **偶尔使用**: 一个月用不了几次,没必要折腾 ❌ **注册问题**: 无法自行注册Claude账号 -❌ **支付问题**: 没有支付渠道订阅Claude Code +❌ **支付问题**: 没有支付渠道订阅Claude Code **如果你只是普通用户,对隐私要求不高,随便玩玩、想快速体验 Claude,那选个你熟知的镜像站会更合适。** @@ -77,7 +77,6 @@ ## 💭 为什么要自己搭? - ### 现有镜像站可能的问题 - 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了 @@ -98,11 +97,13 @@ > 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图 ### 基础功能 + - ✅ **多账户管理**: 可以添加多个Claude账户自动轮换 - ✅ **自定义API Key**: 给每个人分配独立的Key - ✅ **使用统计**: 详细记录每个人用了多少token ### 高级功能 + - 🔄 **智能切换**: 账户出问题自动换下一个 - 🚀 **性能优化**: 连接池、缓存,减少延迟 - 📊 **监控面板**: Web界面查看所有数据 @@ -114,6 +115,7 @@ ## 📋 部署要求 ### 硬件要求(最低配置) + - **CPU**: 1核心就够了 - **内存**: 512MB(建议1GB) - **硬盘**: 30GB可用空间 @@ -122,11 +124,13 @@ - **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api ### 软件要求 + - **Node.js** 18或更高版本 - **Redis** 6或更高版本 - **操作系统**: 建议Linux ### 费用估算 + - **服务器**: 轻量云服务器,一个月30-60块 - **Claude订阅**: 看你怎么分摊了 - **其他**: 域名(可选) @@ -174,11 +178,11 @@ crs uninstall # 卸载服务 $ crs install # 会依次询问: -安装目录 (默认: ~/claude-relay-service): +安装目录 (默认: ~/claude-relay-service): 服务端口 (默认: 3000): 8080 -Redis 地址 (默认: localhost): -Redis 端口 (默认: 6379): -Redis 密码 (默认: 无密码): +Redis 地址 (默认: localhost): +Redis 端口 (默认: 6379): +Redis 密码 (默认: 无密码): # 安装完成后自动启动并显示: 服务已成功安装并启动! @@ -203,6 +207,7 @@ Redis 密码 (默认: 无密码): ### 第一步:环境准备 **Ubuntu/Debian用户:** + ```bash # 安装Node.js curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - @@ -215,6 +220,7 @@ sudo systemctl start redis-server ``` **CentOS/RHEL用户:** + ```bash # 安装Node.js curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - @@ -243,6 +249,7 @@ cp .env.example .env ### 第三步:配置文件设置 **编辑 `.env` 文件:** + ```bash # 这两个密钥随便生成,但要记住 JWT_SECRET=你的超级秘密密钥 @@ -252,19 +259,26 @@ ENCRYPTION_KEY=32位的加密密钥随便写 REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= + +# Webhook通知配置(可选) +WEBHOOK_ENABLED=true +WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key +WEBHOOK_TIMEOUT=10000 +WEBHOOK_RETRIES=3 ``` **编辑 `config/config.js` 文件:** + ```javascript module.exports = { server: { - port: 3000, // 服务端口,可以改 - host: '0.0.0.0' // 不用改 + port: 3000, // 服务端口,可以改 + host: '0.0.0.0' // 不用改 }, redis: { - host: '127.0.0.1', // Redis地址 - port: 6379 // Redis端口 - }, + host: '127.0.0.1', // Redis地址 + port: 6379 // Redis端口 + } // 其他配置保持默认就行 } ``` @@ -372,6 +386,7 @@ docker-compose up -d ### Docker Compose 配置 docker-compose.yml 已包含: + - ✅ 自动初始化管理员账号 - ✅ 数据持久化(logs和data目录自动挂载) - ✅ Redis数据库 @@ -382,10 +397,12 @@ docker-compose.yml 已包含: ### 环境变量说明 #### 必填项 + - `JWT_SECRET`: JWT密钥,至少32个字符 - `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 #### 可选项 + - `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) - `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) - `LOG_LEVEL`: 日志级别(默认:info) @@ -394,11 +411,13 @@ docker-compose.yml 已包含: ### 管理员凭据获取方式 1. **查看容器日志** + ```bash docker logs claude-relay-service ``` 2. **查看挂载的文件** + ```bash cat ./data/init.json ``` @@ -419,6 +438,7 @@ docker-compose.yml 已包含: 浏览器访问:`http://你的服务器IP:3000/web` 管理员账号: + - 自动生成:查看 data/init.json - 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置 - Docker 部署:查看容器日志 `docker logs claude-relay-service` @@ -456,12 +476,14 @@ docker-compose.yml 已包含: 现在你可以用自己的服务替换官方API了: **Claude Code 设置环境变量:** + ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" ``` **Gemini CLI 设置环境变量:** + ```bash export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可 @@ -469,43 +491,49 @@ export GOOGLE_GENAI_USE_GCA="true" ``` **使用 Claude Code:** + ```bash claude ``` **使用 Gemini CLI:** + ```bash gemini # 或其他 Gemini CLI 命令 ``` **Codex 设置环境变量:** + ```bash export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥 ``` - ### 5. 第三方工具API接入 本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等): **Claude标准格式:** + ``` # 如果工具支持Claude标准格式,请使用该接口 -http://你的服务器:3000/claude/ +http://你的服务器:3000/claude/ ``` **OpenAI兼容格式:** + ``` # 适用于需要OpenAI格式的第三方工具 http://你的服务器:3000/openai/claude/v1/ ``` **接入示例:** + - **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses` - **其他支持自定义API的工具**: 根据工具要求选择合适的格式 **重要说明:** + - 所有格式都支持相同的功能,仅是路径不同 - `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages` - 选择适合你使用工具的格式即可 @@ -513,6 +541,67 @@ http://你的服务器:3000/openai/claude/v1/ --- +## 📢 Webhook 通知功能 + +### 功能说明 + +当系统检测到账号异常时,会自动发送 webhook 通知,支持企业微信、钉钉、Slack 等平台。 + +### 通知触发场景 + +- **Claude OAuth 账户**: token 过期或未授权时 +- **Claude Console 账户**: 系统检测到账户被封锁时 +- **Gemini 账户**: token 刷新失败时 +- **手动禁用账户**: 管理员手动禁用账户时 + +### 配置方法 + +**1. 环境变量配置** + +```bash +# 启用 webhook 通知 +WEBHOOK_ENABLED=true + +# 企业微信 webhook 地址(替换为你的实际地址) +WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key + +# 多个地址用逗号分隔 +WEBHOOK_URLS=https://webhook1.com,https://webhook2.com + +# 请求超时时间(毫秒,默认10秒) +WEBHOOK_TIMEOUT=10000 + +# 重试次数(默认3次) +WEBHOOK_RETRIES=3 +``` + +**2. 企业微信设置** + +1. 在企业微信群中添加「群机器人」 +2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx` +3. 将地址配置到 `WEBHOOK_URLS` 环境变量 + +### 通知内容格式 + +系统会发送结构化的通知消息: + +``` +账户名称 账号异常,异常代码 ERROR_CODE +平台:claude-oauth +时间:2025-08-14 17:30:00 +原因:Token expired +``` + +### 测试 Webhook + +可以通过管理后台测试 webhook 连通性: + +1. 登录管理后台:`http://你的服务器:3000/web` +2. 访问:`/admin/webhook/test` +3. 发送测试通知确认配置正确 + +--- + ## 🔧 日常维护 ### 服务管理 @@ -567,6 +656,7 @@ npm run service:status ``` **注意事项:** + - 升级前建议备份重要配置文件(.env, config/config.js) - 查看更新日志了解是否有破坏性变更 - 如果有数据库结构变更,会自动迁移 @@ -615,12 +705,14 @@ clientRestrictions: { ### 日志示例 认证成功时的日志: + ``` 🔓 Authenticated request from key: 测试Key (key-id) in 5ms User-Agent: "claude-cli/1.0.58 (external, cli)" ``` 客户端限制检查日志: + ``` 🔍 Checking client restriction for key: key-id (测试Key) User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" @@ -631,6 +723,7 @@ clientRestrictions: { ### 常见问题处理 **Redis连不上?** + ```bash # 检查Redis是否启动 redis-cli ping @@ -639,11 +732,13 @@ redis-cli ping ``` **OAuth授权失败?** + - 检查代理设置是否正确 - 确保能正常访问 claude.ai - 清除浏览器缓存重试 **API请求失败?** + - 检查API Key是否正确 - 查看日志文件找错误信息 - 确认Claude账户状态正常 @@ -652,7 +747,6 @@ redis-cli ping ## 🛠️ 进阶 - ### 生产环境部署建议(重要!) **强烈建议使用Caddy反向代理(自动HTTPS)** @@ -660,6 +754,7 @@ redis-cli ping 建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单: **1. 安装Caddy** + ```bash # Ubuntu/Debian sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https @@ -677,18 +772,19 @@ sudo yum install caddy **2. Caddy配置(超简单!)** 编辑 `/etc/caddy/Caddyfile`: + ``` your-domain.com { # 反向代理到本地服务 reverse_proxy 127.0.0.1:3000 { # 支持流式响应(SSE) flush_interval -1 - + # 传递真实IP header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} - + # 超时设置(适合长连接) transport http { read_timeout 300s @@ -696,7 +792,7 @@ your-domain.com { dial_timeout 30s } } - + # 安全头部 header { Strict-Transport-Security "max-age=31536000; includeSubDomains" @@ -708,6 +804,7 @@ your-domain.com { ``` **3. 启动Caddy** + ```bash # 测试配置 sudo caddy validate --config /etc/caddy/Caddyfile @@ -723,34 +820,37 @@ sudo systemctl status caddy **4. 更新服务配置** 修改你的服务配置,让它只监听本地: + ```javascript // config/config.js module.exports = { server: { port: 3000, - host: '127.0.0.1' // 只监听本地,通过nginx代理 + host: '127.0.0.1' // 只监听本地,通过nginx代理 } // ... 其他配置 } ``` **Caddy优势:** + - 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置 - 🛡️ **安全默认**: 默认启用现代安全协议和加密套件 - 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输 - 📊 **简单配置**: 配置文件极其简洁,易于维护 - ⚡ **HTTP/2**: 默认启用HTTP/2,提升传输性能 - --- ## 💡 使用建议 ### 账户管理 + - **定期检查**: 每周看看账户状态,及时处理异常 - **合理分配**: 可以给不同的人分配不同的apikey,可以根据不同的apikey来分析用量 ### 安全建议 + - **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全 - **定期备份**: 重要配置和数据要备份 - **监控日志**: 定期查看异常日志 @@ -762,12 +862,14 @@ module.exports = { ## 🆘 遇到问题怎么办? ### 自助排查 + 1. **查看日志**: `logs/` 目录下的日志文件 2. **检查配置**: 确认配置文件设置正确 3. **测试连通性**: 用 curl 测试API是否正常 4. **重启服务**: 有时候重启一下就好了 ### 寻求帮助 + - **GitHub Issues**: 提交详细的错误信息 - **查看文档**: 仔细阅读错误信息和文档 - **社区讨论**: 看看其他人是否遇到类似问题 @@ -775,6 +877,7 @@ module.exports = { --- ## 📄 许可证 + 本项目采用 [MIT许可证](LICENSE)。 --- @@ -785,4 +888,4 @@ module.exports = { **🤝 有问题欢迎提Issue,有改进建议欢迎PR** - \ No newline at end of file + diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index da512f97..6560cabe 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -468,6 +468,26 @@ class ClaudeAccountService { updatedData.updatedAt = new Date().toISOString() + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && accountData.isActive === 'true') { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || 'Unknown Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + await redis.setClaudeAccount(accountId, updatedData) logger.success(`📝 Updated Claude account: ${accountId}`) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 6cc5d092..3963c10b 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -261,6 +261,26 @@ class ClaudeConsoleAccountService { updatedData.updatedAt = new Date().toISOString() + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === false && existingAccount.isActive === true) { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || existingAccount.name || 'Unknown Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`) logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 179116b3..ee8d2b60 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -455,6 +455,23 @@ async function updateAccount(accountId, updates) { } } + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && existingAccount.isActive !== 'false') { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updates.name || existingAccount.name || 'Unknown Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for manual account disable:', webhookError) + } + } + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) logger.info(`Updated Gemini account: ${accountId}`) diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index 1015f581..c95f3156 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -125,15 +125,18 @@ class WebhookNotifier { const errorCodes = { 'claude-oauth': { unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', - error: 'CLAUDE_OAUTH_ERROR' + error: 'CLAUDE_OAUTH_ERROR', + disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED' }, 'claude-console': { blocked: 'CLAUDE_CONSOLE_BLOCKED', - error: 'CLAUDE_CONSOLE_ERROR' + error: 'CLAUDE_CONSOLE_ERROR', + disabled: 'CLAUDE_CONSOLE_MANUALLY_DISABLED' }, gemini: { error: 'GEMINI_ERROR', - unauthorized: 'GEMINI_UNAUTHORIZED' + unauthorized: 'GEMINI_UNAUTHORIZED', + disabled: 'GEMINI_MANUALLY_DISABLED' } } From 3e605f0052ed4aef08bef670011b634767af8393 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Aug 2025 13:47:13 +0000 Subject: [PATCH 09/24] chore: sync VERSION file with release v1.1.110 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7babc036..b2f4ea62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.109 +1.1.110 From 812e98355fc7bc1e6fd26957e7ad1f326988fcb6 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 15 Aug 2025 16:56:44 +0800 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20OpenAI=20tok?= =?UTF-8?q?en=20=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E8=B4=A6=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要更改: 1. OpenAI Token 自动刷新 - 实现 refreshAccessToken 函数,支持 OAuth 2.0 refresh_token grant type - 使用 Codex CLI 官方 CLIENT_ID (app_EMoamEEZ73f0CkXaXp7hrann) - 支持 SOCKS5 和 HTTP/HTTPS 代理 - 自动更新 access token、id token 和 refresh token 2. 账户管理界面优化 - 移除手动刷新 token 按钮(桌面端和移动端) - 保留后端自动刷新机制 - 优化代码结构,删除不再需要的函数和变量 3. 测试和文档 - 添加 test-openai-refresh.js 测试脚本 - 创建详细的实现文档 技术细节: - Token 端点: https://auth.openai.com/oauth/token - 默认有效期: 1小时 - 加密存储: AES-256-CBC 所有平台现在都支持自动 token 刷新: ✅ Claude - OAuth 自动刷新 ✅ Gemini - Google OAuth2 自动刷新 ✅ OpenAI - OAuth 自动刷新(新实现) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/test-openai-refresh.js | 103 +++++++++++++++ src/middleware/auth.js | 3 + src/routes/admin.js | 8 ++ src/services/apiKeyService.js | 5 + src/services/claudeAccountService.js | 5 + src/services/geminiAccountService.js | 9 +- src/services/openaiAccountService.js | 125 ++++++++++++++++-- src/services/unifiedOpenAIScheduler.js | 120 ++++++++--------- .../src/components/accounts/AccountForm.vue | 34 +++-- .../components/apikeys/CreateApiKeyModal.vue | 43 +++++- .../components/apikeys/EditApiKeyModal.vue | 46 ++++++- web/admin-spa/src/views/AccountsView.vue | 101 ++++---------- 12 files changed, 424 insertions(+), 178 deletions(-) create mode 100644 scripts/test-openai-refresh.js diff --git a/scripts/test-openai-refresh.js b/scripts/test-openai-refresh.js new file mode 100644 index 00000000..5158e9f8 --- /dev/null +++ b/scripts/test-openai-refresh.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * OpenAI Token 刷新功能测试脚本 + * 用于测试 openaiAccountService 的 token 刷新功能 + */ + +const openaiAccountService = require('../src/services/openaiAccountService') +const logger = require('../src/utils/logger') + +// 测试配置(可以通过环境变量或命令行参数传入) +const TEST_REFRESH_TOKEN = process.env.OPENAI_REFRESH_TOKEN || process.argv[2] + +async function testRefreshToken() { + if (!TEST_REFRESH_TOKEN) { + console.error('❌ 请提供 refresh token 作为参数或设置环境变量 OPENAI_REFRESH_TOKEN') + console.log('使用方法:') + console.log(' node scripts/test-openai-refresh.js ') + console.log(' 或') + console.log(' OPENAI_REFRESH_TOKEN= node scripts/test-openai-refresh.js') + process.exit(1) + } + + console.log('🔄 开始测试 OpenAI token 刷新功能...\n') + + try { + // 测试不带代理的刷新 + console.log('1️⃣ 测试直接刷新(无代理)...') + const result = await openaiAccountService.refreshAccessToken(TEST_REFRESH_TOKEN) + + console.log('✅ 刷新成功!') + console.log(' Access Token:', result.access_token ? result.access_token.substring(0, 30) + '...' : 'N/A') + console.log(' ID Token:', result.id_token ? result.id_token.substring(0, 30) + '...' : 'N/A') + console.log(' Refresh Token:', result.refresh_token ? result.refresh_token.substring(0, 30) + '...' : 'N/A') + console.log(' 有效期:', result.expires_in, '秒') + console.log(' 过期时间:', new Date(result.expiry_date).toLocaleString()) + + // 如果返回了新的 refresh token + if (result.refresh_token && result.refresh_token !== TEST_REFRESH_TOKEN) { + console.log('\n⚠️ 注意:收到了新的 refresh token,请保存以供后续使用') + } + + // 测试带代理的刷新(如果配置了代理) + if (process.env.PROXY_HOST && process.env.PROXY_PORT) { + console.log('\n2️⃣ 测试通过代理刷新...') + const proxy = { + type: process.env.PROXY_TYPE || 'http', + host: process.env.PROXY_HOST, + port: parseInt(process.env.PROXY_PORT), + username: process.env.PROXY_USERNAME, + password: process.env.PROXY_PASSWORD + } + + console.log(' 代理配置:', `${proxy.type}://${proxy.host}:${proxy.port}`) + + const proxyResult = await openaiAccountService.refreshAccessToken( + result.refresh_token || TEST_REFRESH_TOKEN, + proxy + ) + + console.log('✅ 通过代理刷新成功!') + console.log(' Access Token:', proxyResult.access_token ? proxyResult.access_token.substring(0, 30) + '...' : 'N/A') + } + + // 测试完整的账户刷新流程(如果提供了账户ID) + if (process.env.OPENAI_ACCOUNT_ID) { + console.log('\n3️⃣ 测试账户刷新流程...') + console.log(' 账户ID:', process.env.OPENAI_ACCOUNT_ID) + + try { + const account = await openaiAccountService.getAccount(process.env.OPENAI_ACCOUNT_ID) + if (account) { + console.log(' 账户名称:', account.name) + console.log(' 当前过期时间:', account.expiresAt) + + const refreshResult = await openaiAccountService.refreshAccountToken(process.env.OPENAI_ACCOUNT_ID) + console.log('✅ 账户 token 刷新成功!') + console.log(' 新的过期时间:', new Date(refreshResult.expiry_date).toLocaleString()) + } + } catch (error) { + console.log('⚠️ 账户刷新测试失败:', error.message) + } + } + + console.log('\n✅ 所有测试完成!') + + } catch (error) { + console.error('\n❌ 测试失败:', error.message) + if (error.response) { + console.error('响应状态:', error.response.status) + console.error('响应数据:', error.response.data) + } + process.exit(1) + } +} + +// 运行测试 +testRefreshToken().then(() => { + process.exit(0) +}).catch((error) => { + console.error('Unexpected error:', error) + process.exit(1) +}) \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 660bb72d..38c43485 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -303,7 +303,10 @@ const authenticateApiKey = async (req, res, next) => { name: validation.keyData.name, tokenLimit: validation.keyData.tokenLimit, claudeAccountId: validation.keyData.claudeAccountId, + claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, // 添加 Claude Console 账号ID geminiAccountId: validation.keyData.geminiAccountId, + openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID + bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID permissions: validation.keyData.permissions, concurrencyLimit: validation.keyData.concurrencyLimit, rateLimitWindow: validation.keyData.rateLimitWindow, diff --git a/src/routes/admin.js b/src/routes/admin.js index 3ecf71ff..f6e9cf2d 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -391,6 +391,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { claudeConsoleAccountId, geminiAccountId, openaiAccountId, + bedrockAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -487,6 +488,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { claudeConsoleAccountId, geminiAccountId, openaiAccountId, + bedrockAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -633,6 +635,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { claudeConsoleAccountId, geminiAccountId, openaiAccountId, + bedrockAccountId, permissions, enableModelRestriction, restrictedModels, @@ -696,6 +699,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.openaiAccountId = openaiAccountId || '' } + if (bedrockAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.bedrockAccountId = bedrockAccountId || '' + } + if (permissions !== undefined) { // 验证权限值 if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 4309e665..fcb49e34 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -20,6 +20,7 @@ class ApiKeyService { claudeConsoleAccountId = null, geminiAccountId = null, openaiAccountId = null, + bedrockAccountId = null, // 添加 Bedrock 账号ID支持 permissions = 'all', // 'claude', 'gemini', 'openai', 'all' isActive = true, concurrencyLimit = 0, @@ -52,6 +53,7 @@ class ApiKeyService { claudeConsoleAccountId: claudeConsoleAccountId || '', geminiAccountId: geminiAccountId || '', openaiAccountId: openaiAccountId || '', + bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), @@ -84,6 +86,7 @@ class ApiKeyService { claudeConsoleAccountId: keyData.claudeConsoleAccountId, geminiAccountId: keyData.geminiAccountId, openaiAccountId: keyData.openaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), @@ -171,6 +174,7 @@ class ApiKeyService { claudeConsoleAccountId: keyData.claudeConsoleAccountId, geminiAccountId: keyData.geminiAccountId, openaiAccountId: keyData.openaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID permissions: keyData.permissions || 'all', tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), @@ -304,6 +308,7 @@ class ApiKeyService { 'claudeConsoleAccountId', 'geminiAccountId', 'openaiAccountId', + 'bedrockAccountId', // 添加 Bedrock 账号ID 'permissions', 'expiresAt', 'enableModelRestriction', diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 1dc26459..23f0c9d4 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -432,6 +432,11 @@ class ClaudeAccountService { lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, expiresAt: account.expiresAt, + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况,避免返回 [''] + scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [], + // 添加 refreshToken 是否存在的标记(不返回实际值) + hasRefreshToken: !!account.refreshToken, // 添加套餐信息(如果存在) subscriptionInfo: account.subscriptionInfo ? JSON.parse(account.subscriptionInfo) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index ee8d2b60..050eac9f 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -291,7 +291,8 @@ async function createAccount(accountData) { accessToken: accessToken ? encrypt(accessToken) : '', refreshToken: refreshToken ? encrypt(refreshToken) : '', expiresAt, - scopes: accountData.scopes || OAUTH_SCOPES.join(' '), + // 只有OAuth方式才有scopes,手动添加的没有 + scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', // 代理设置 proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '', @@ -551,6 +552,12 @@ async function getAllAccounts() { geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串和默认值的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: !!accountData.refreshToken, // 添加限流状态信息(统一格式) rateLimitStatus: rateLimitInfo ? { diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index fe2baf26..d5e13cf6 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -1,6 +1,9 @@ const redisClient = require('../models/redis') const { v4: uuidv4 } = require('uuid') const crypto = require('crypto') +const axios = require('axios') +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') const config = require('../../config/config') const logger = require('../utils/logger') // const { maskToken } = require('../utils/tokenMask') @@ -65,15 +68,85 @@ function decrypt(text) { } // 刷新访问令牌 -async function refreshAccessToken(_refreshToken) { +async function refreshAccessToken(refreshToken, proxy = null) { try { - // OpenAI OAuth token 刷新实现 - // TODO: 实现具体的 OpenAI OAuth token 刷新逻辑 - logger.warn('OpenAI token refresh not yet implemented') - return null + // Codex CLI 的官方 CLIENT_ID + const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + + // 准备请求数据 + const requestData = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: refreshToken, + scope: 'openid profile email' + }).toString() + + // 配置请求选项 + const requestOptions = { + method: 'POST', + url: 'https://auth.openai.com/oauth/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': requestData.length + }, + data: requestData, + timeout: 30000 // 30秒超时 + } + + // 配置代理(如果有) + if (proxy && proxy.host && proxy.port) { + if (proxy.type === 'socks5') { + const proxyAuth = proxy.username && proxy.password + ? `${proxy.username}:${proxy.password}@` + : '' + const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}` + requestOptions.httpsAgent = new SocksProxyAgent(socksProxy) + } else if (proxy.type === 'http' || proxy.type === 'https') { + const proxyAuth = proxy.username && proxy.password + ? `${proxy.username}:${proxy.password}@` + : '' + const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}` + requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy) + } + } + + // 发送请求 + const response = await axios(requestOptions) + + if (response.status === 200 && response.data) { + const result = response.data + + logger.info('✅ Successfully refreshed OpenAI token') + + // 返回新的 token 信息 + return { + access_token: result.access_token, + id_token: result.id_token, + refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的 + expires_in: result.expires_in || 3600, + expiry_date: Date.now() + ((result.expires_in || 3600) * 1000) // 计算过期时间 + } + } else { + throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) + } } catch (error) { - logger.error('Error refreshing OpenAI access token:', error) - throw error + if (error.response) { + // 服务器响应了错误状态码 + logger.error('OpenAI token refresh failed:', { + status: error.response.status, + data: error.response.data, + headers: error.response.headers + }) + throw new Error(`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + } else if (error.request) { + // 请求已发出但没有收到响应 + logger.error('OpenAI token refresh no response:', error.message) + throw new Error(`Token refresh failed: No response from server - ${error.message}`) + } else { + // 设置请求时发生错误 + logger.error('OpenAI token refresh error:', error.message) + throw new Error(`Token refresh failed: ${error.message}`) + } } } @@ -102,17 +175,41 @@ async function refreshAccountToken(accountId) { throw new Error('No refresh token available') } + // 获取代理配置 + let proxy = null + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) + } + } + try { - const newTokens = await refreshAccessToken(refreshToken) + const newTokens = await refreshAccessToken(refreshToken, proxy) if (!newTokens) { throw new Error('Failed to refresh token') } - // 更新账户信息 - await updateAccount(accountId, { + // 准备更新数据 + const updates = { accessToken: encrypt(newTokens.access_token), expiresAt: new Date(newTokens.expiry_date).toISOString() - }) + } + + // 如果有新的 ID token,也更新它 + if (newTokens.id_token) { + updates.idToken = encrypt(newTokens.id_token) + } + + // 如果返回了新的 refresh token,更新它 + if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) { + updates.refreshToken = encrypt(newTokens.refresh_token) + logger.info(`Updated refresh token for account ${accountId}`) + } + + // 更新账户信息 + await updateAccount(accountId, updates) logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date) return newTokens @@ -374,6 +471,12 @@ async function getAllAccounts() { openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: !!accountData.refreshToken, // 添加限流状态信息(统一格式) rateLimitStatus: rateLimitInfo ? { diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index 93b5e108..f800621c 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -35,6 +35,28 @@ class UnifiedOpenAIScheduler { // 普通专属账户 const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` + logger.warn(`⚠️ ${errorMsg}`) + throw new Error(errorMsg) + } + + // 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查) + if ( + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + const modelSupported = boundAccount.supportedModels.includes(requestedModel) + if (!modelSupported) { + const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}` + logger.warn(`⚠️ ${errorMsg}`) + throw new Error(errorMsg) + } + } + logger.info( `🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}` ) @@ -45,9 +67,12 @@ class UnifiedOpenAIScheduler { accountType: 'openai' } } else { - logger.warn( - `⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available, falling back to pool` - ) + // 专属账户不可用时直接报错,不降级到共享池 + const errorMsg = boundAccount + ? `Dedicated account ${boundAccount.name} is not available (inactive or error status)` + : `Dedicated account ${apiKeyData.openaiAccountId} not found` + logger.warn(`⚠️ ${errorMsg}`) + throw new Error(errorMsg) } } @@ -90,8 +115,12 @@ class UnifiedOpenAIScheduler { } } - // 按优先级和最后使用时间排序 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + // 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致) + const sortedAccounts = availableAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) // 选择第一个账户 const selectedAccount = sortedAccounts[0] @@ -109,7 +138,7 @@ class UnifiedOpenAIScheduler { } logger.info( - `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}` ) // 更新账户的最后使用时间 @@ -125,49 +154,12 @@ class UnifiedOpenAIScheduler { } } - // 📋 获取所有可用账户 + // 📋 获取所有可用账户(仅共享池) async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { const availableAccounts = [] - // 如果API Key绑定了专属账户,优先返回 - if (apiKeyData.openaiAccountId) { - const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - const isRateLimited = await this.isAccountRateLimited(boundAccount.id) - if (!isRateLimited) { - // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) - // 如果没有设置supportedModels或为空数组,则支持所有模型 - if ( - requestedModel && - boundAccount.supportedModels && - boundAccount.supportedModels.length > 0 - ) { - const modelSupported = boundAccount.supportedModels.includes(requestedModel) - if (!modelSupported) { - logger.warn( - `⚠️ Bound OpenAI account ${boundAccount.name} does not support model ${requestedModel}` - ) - return availableAccounts - } - } - - logger.info( - `🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId})` - ) - return [ - { - ...boundAccount, - accountId: boundAccount.id, - accountType: 'openai', - priority: parseInt(boundAccount.priority) || 50, - lastUsedAt: boundAccount.lastUsedAt || '0' - } - ] - } - } else { - logger.warn(`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available`) - } - } + // 注意:专属账户的处理已经在 selectAccountForApiKey 中完成 + // 这里只处理共享池账户 // 获取所有OpenAI账户(共享池) const openaiAccounts = await openaiAccountService.getAllAccounts() @@ -221,20 +213,20 @@ class UnifiedOpenAIScheduler { return availableAccounts } - // 🔢 按优先级和最后使用时间排序账户 - _sortAccountsByPriority(accounts) { - return accounts.sort((a, b) => { - // 首先按优先级排序(数字越小优先级越高) - if (a.priority !== b.priority) { - return a.priority - b.priority - } + // 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序) + // _sortAccountsByPriority(accounts) { + // return accounts.sort((a, b) => { + // // 首先按优先级排序(数字越小优先级越高) + // if (a.priority !== b.priority) { + // return a.priority - b.priority + // } - // 优先级相同时,按最后使用时间排序(最久未使用的优先) - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) - } + // // 优先级相同时,按最后使用时间排序(最久未使用的优先) + // const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + // const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + // return aLastUsed - bLastUsed + // }) + // } // 🔍 检查账户是否可用 async _isAccountAvailable(accountId, accountType) { @@ -449,8 +441,12 @@ class UnifiedOpenAIScheduler { throw new Error(`No available accounts in group ${group.name}`) } - // 按优先级和最后使用时间排序 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + // 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致) + const sortedAccounts = availableAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) // 选择第一个账户 const selectedAccount = sortedAccounts[0] @@ -468,7 +464,7 @@ class UnifiedOpenAIScheduler { } logger.info( - `🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority}` + `🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})` ) // 更新账户的最后使用时间 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index c8dcdcb8..661bc7f4 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -584,14 +584,8 @@

- -
+ +
@@ -1019,14 +1013,8 @@

- -
+ +
{ if (form.value.projectId) { data.projectId = form.value.projectId } + // 添加 Gemini 优先级 + data.priority = form.value.priority || 50 } else if (form.value.platform === 'openai') { data.openaiOauth = tokenInfo.tokens || tokenInfo data.accountInfo = tokenInfo.accountInfo @@ -1869,7 +1859,7 @@ const createAccount = async () => { accessToken: form.value.accessToken, refreshToken: form.value.refreshToken || '', expiresAt: Date.now() + expiresInMs, - scopes: ['user:inference'] + scopes: [] // 手动添加没有 scopes } data.priority = form.value.priority || 50 // 添加订阅类型信息 @@ -1896,6 +1886,9 @@ const createAccount = async () => { if (form.value.projectId) { data.projectId = form.value.projectId } + + // 添加 Gemini 优先级 + data.priority = form.value.priority || 50 } else if (form.value.platform === 'openai') { // OpenAI手动模式需要构建openaiOauth对象 const expiresInMs = form.value.refreshToken @@ -2058,7 +2051,7 @@ const updateAccount = async () => { accessToken: form.value.accessToken || '', refreshToken: form.value.refreshToken || '', expiresAt: Date.now() + expiresInMs, - scopes: ['user:inference'] + scopes: props.account.scopes || [] // 保持原有的 scopes,如果没有则为空数组 } } else if (props.account.platform === 'gemini') { // Gemini需要构建geminiOauth对象 @@ -2109,6 +2102,11 @@ const updateAccount = async () => { data.priority = form.value.priority || 50 } + // Gemini 账号优先级更新 + if (props.account.platform === 'gemini') { + data.priority = form.value.priority || 50 + } + // Claude Console 特定更新 if (props.account.platform === 'claude-console') { data.apiUrl = form.value.apiUrl diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 1949eb5f..a0068ddb 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -436,6 +436,18 @@ platform="openai" />
+
+ + +

选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池 @@ -618,6 +630,7 @@ const localAccounts = ref({ claude: [], gemini: [], openai: [], + bedrock: [], // 添加 Bedrock 账号列表 claudeGroups: [], geminiGroups: [], openaiGroups: [] @@ -658,6 +671,7 @@ const form = reactive({ claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', + bedrockAccountId: '', // 添加 Bedrock 账号ID enableModelRestriction: false, restrictedModels: [], modelInput: '', @@ -676,6 +690,7 @@ onMounted(async () => { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], openai: props.accounts.openai || [], + bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], openaiGroups: props.accounts.openaiGroups || [] @@ -687,13 +702,15 @@ onMounted(async () => { const refreshAccounts = async () => { accountsLoading.value = true try { - const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([ - apiClient.get('/admin/claude-accounts'), - apiClient.get('/admin/claude-console-accounts'), - apiClient.get('/admin/gemini-accounts'), - apiClient.get('/admin/openai-accounts'), - apiClient.get('/admin/account-groups') - ]) + const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = + await Promise.all([ + apiClient.get('/admin/claude-accounts'), + apiClient.get('/admin/claude-console-accounts'), + apiClient.get('/admin/gemini-accounts'), + apiClient.get('/admin/openai-accounts'), + apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/account-groups') + ]) // 合并Claude OAuth账户和Claude Console账户 const claudeAccounts = [] @@ -734,6 +751,13 @@ const refreshAccounts = async () => { })) } + if (bedrockData.success) { + localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ + ...account, + isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容 + })) + } + // 处理分组数据 if (groupsData.success) { const allGroups = groupsData.data || [] @@ -939,6 +963,11 @@ const createApiKey = async () => { baseData.openaiAccountId = form.openaiAccountId } + // Bedrock账户绑定 + if (form.bedrockAccountId) { + baseData.bedrockAccountId = form.bedrockAccountId + } + if (form.createType === 'single') { // 单个创建 const data = { diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 796768d1..65374e8d 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -339,6 +339,18 @@ platform="openai" />

+
+ + +

修改绑定账号将影响此API Key的请求路由

@@ -522,6 +534,7 @@ const localAccounts = ref({ claude: [], gemini: [], openai: [], + bedrock: [], // 添加 Bedrock 账号列表 claudeGroups: [], geminiGroups: [], openaiGroups: [] @@ -551,6 +564,7 @@ const form = reactive({ claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', + bedrockAccountId: '', // 添加 Bedrock 账号ID enableModelRestriction: false, restrictedModels: [], modelInput: '', @@ -673,6 +687,13 @@ const updateApiKey = async () => { data.openaiAccountId = null } + // Bedrock账户绑定 + if (form.bedrockAccountId) { + data.bedrockAccountId = form.bedrockAccountId + } else { + data.bedrockAccountId = null + } + // 模型限制 - 始终提交这些字段 data.enableModelRestriction = form.enableModelRestriction data.restrictedModels = form.restrictedModels @@ -703,13 +724,15 @@ const updateApiKey = async () => { const refreshAccounts = async () => { accountsLoading.value = true try { - const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([ - apiClient.get('/admin/claude-accounts'), - apiClient.get('/admin/claude-console-accounts'), - apiClient.get('/admin/gemini-accounts'), - apiClient.get('/admin/openai-accounts'), - apiClient.get('/admin/account-groups') - ]) + const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = + await Promise.all([ + apiClient.get('/admin/claude-accounts'), + apiClient.get('/admin/claude-console-accounts'), + apiClient.get('/admin/gemini-accounts'), + apiClient.get('/admin/openai-accounts'), + apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/account-groups') + ]) // 合并Claude OAuth账户和Claude Console账户 const claudeAccounts = [] @@ -750,6 +773,13 @@ const refreshAccounts = async () => { })) } + if (bedrockData.success) { + localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ + ...account, + isDedicated: account.accountType === 'dedicated' + })) + } + // 处理分组数据 if (groupsData.success) { const allGroups = groupsData.data || [] @@ -778,6 +808,7 @@ onMounted(async () => { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], openai: props.accounts.openai || [], + bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], openaiGroups: props.accounts.openaiGroups || [] @@ -799,6 +830,7 @@ onMounted(async () => { } form.geminiAccountId = props.apiKey.geminiAccountId || '' form.openaiAccountId = props.apiKey.openaiAccountId || '' + form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化 form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] form.tags = props.apiKey.tags || [] diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index d05e796a..5a751f44 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -261,7 +261,7 @@ Gemini - {{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }} + {{ getGeminiAuthType() }}
OpenAi - Oauth + {{ getOpenAIAuthType() }}
- {{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }} + {{ getClaudeAuthType(account) }}
@@ -491,21 +493,6 @@
-
- -