From fddd5ee3e9cb7c97eae65f2736baf8ff170392ee Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Wed, 30 Jul 2025 08:19:44 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=A0=87?= =?UTF-8?q?=E5=87=86Claude=20Console=20API=E8=B4=A6=E5=8F=B7=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test-claude-console-url.js | 28 + src/routes/admin.js | 165 +++++- src/routes/api.js | 68 ++- src/services/claudeAccountService.js | 13 +- src/services/claudeConsoleAccountService.js | 457 +++++++++++++++ src/services/claudeConsoleRelayService.js | 527 ++++++++++++++++++ src/services/unifiedClaudeScheduler.js | 294 ++++++++++ web/admin-spa/dist/index.html | 4 +- .../src/components/accounts/AccountForm.vue | 312 ++++++++++- web/admin-spa/src/stores/accounts.js | 93 +++- web/admin-spa/src/views/AccountsView.vue | 62 ++- 11 files changed, 1992 insertions(+), 31 deletions(-) create mode 100755 scripts/test-claude-console-url.js create mode 100644 src/services/claudeConsoleAccountService.js create mode 100644 src/services/claudeConsoleRelayService.js create mode 100644 src/services/unifiedClaudeScheduler.js diff --git a/scripts/test-claude-console-url.js b/scripts/test-claude-console-url.js new file mode 100755 index 00000000..868315b6 --- /dev/null +++ b/scripts/test-claude-console-url.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +// 测试Claude Console账号URL处理 + +const testUrls = [ + 'https://api.example.com', + 'https://api.example.com/', + 'https://api.example.com/v1/messages', + 'https://api.example.com/v1/messages/', + 'https://api.example.com:8080', + 'https://api.example.com:8080/v1/messages' +]; + +console.log('🧪 Testing Claude Console URL handling:\n'); + +testUrls.forEach(url => { + // 模拟账号服务的URL处理逻辑 + const cleanUrl = url.replace(/\/$/, ''); // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') + ? cleanUrl + : `${cleanUrl}/v1/messages`; + + console.log(`Input: ${url}`); + console.log(`Output: ${apiEndpoint}`); + console.log('---'); +}); + +console.log('\n✅ URL normalization logic test completed'); \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 1273370d..92c0ec18 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,6 +1,7 @@ const express = require('express'); const apiKeyService = require('../services/apiKeyService'); const claudeAccountService = require('../services/claudeAccountService'); +const claudeConsoleAccountService = require('../services/claudeConsoleAccountService'); const geminiAccountService = require('../services/geminiAccountService'); const redis = require('../models/redis'); const { authenticateAdmin } = require('../middleware/auth'); @@ -703,7 +704,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { refreshToken, claudeAiOauth, proxy, - accountType + accountType, + priority } = req.body; if (!name) { @@ -715,6 +717,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }); } + // 验证priority的有效性 + if (priority !== undefined && (typeof priority !== 'number' || priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }); + } + const newAccount = await claudeAccountService.createAccount({ name, description, @@ -723,7 +730,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { refreshToken, claudeAiOauth, proxy, - accountType: accountType || 'shared' // 默认为共享类型 + accountType: accountType || 'shared', // 默认为共享类型 + priority: priority || 50 // 默认优先级为50 }); logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`); @@ -740,6 +748,11 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => const { accountId } = req.params; const updates = req.body; + // 验证priority的有效性 + if (updates.priority !== undefined && (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100)) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }); + } + await claudeAccountService.updateAccount(accountId, updates); logger.success(`📝 Admin updated Claude account: ${accountId}`); @@ -780,6 +793,154 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req } }); +// 🎮 Claude Console 账户管理 + +// 获取所有Claude Console账户 +router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await claudeConsoleAccountService.getAllAccounts(); + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all(accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id); + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + }; + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, statsError.message); + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + }; + } + })); + + res.json({ success: true, data: accountsWithStats }); + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error); + res.status(500).json({ error: 'Failed to get Claude Console accounts', message: error.message }); + } +}); + +// 创建新的Claude Console账户 +router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + apiUrl, + apiKey, + priority, + supportedModels, + userAgent, + rateLimitDuration, + proxy, + accountType + } = req.body; + + if (!name || !apiUrl || !apiKey) { + return res.status(400).json({ error: 'Name, API URL and API Key are required' }); + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }); + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated'].includes(accountType)) { + return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }); + } + + const newAccount = await claudeConsoleAccountService.createAccount({ + name, + description, + apiUrl, + apiKey, + priority: priority || 50, + supportedModels: supportedModels || [], + userAgent, + rateLimitDuration: rateLimitDuration || 60, + proxy, + accountType: accountType || 'shared' + }); + + logger.success(`🎮 Admin created Claude Console account: ${name}`); + res.json({ success: true, data: newAccount }); + } catch (error) { + logger.error('❌ Failed to create Claude Console account:', error); + res.status(500).json({ error: 'Failed to create Claude Console account', message: error.message }); + } +}); + +// 更新Claude Console账户 +router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + const updates = req.body; + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }); + } + + await claudeConsoleAccountService.updateAccount(accountId, updates); + + logger.success(`📝 Admin updated Claude Console account: ${accountId}`); + res.json({ success: true, message: 'Claude Console account updated successfully' }); + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error); + res.status(500).json({ error: 'Failed to update Claude Console account', message: error.message }); + } +}); + +// 删除Claude Console账户 +router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + + await claudeConsoleAccountService.deleteAccount(accountId); + + logger.success(`🗑️ Admin deleted Claude Console account: ${accountId}`); + res.json({ success: true, message: 'Claude Console account deleted successfully' }); + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error); + res.status(500).json({ error: 'Failed to delete Claude Console account', message: error.message }); + } +}); + + +// 切换Claude Console账户状态 +router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + + const account = await claudeConsoleAccountService.getAccount(accountId); + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + const newStatus = !account.isActive; + await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus }); + + logger.success(`🔄 Admin toggled Claude Console account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`); + res.json({ success: true, isActive: newStatus }); + } catch (error) { + logger.error('❌ Failed to toggle Claude Console account status:', error); + res.status(500).json({ error: 'Failed to toggle account status', message: error.message }); + } +}); + // 🤖 Gemini 账户管理 // 生成 Gemini OAuth 授权 URL diff --git a/src/routes/api.js b/src/routes/api.js index 3027de5e..74d975b7 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1,9 +1,12 @@ const express = require('express'); const claudeRelayService = require('../services/claudeRelayService'); +const claudeConsoleRelayService = require('../services/claudeConsoleRelayService'); +const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler'); const apiKeyService = require('../services/apiKeyService'); const { authenticateApiKey } = require('../middleware/auth'); const logger = require('../utils/logger'); const redis = require('../models/redis'); +const sessionHelper = require('../utils/sessionHelper'); const router = express.Router(); @@ -56,8 +59,16 @@ async function handleMessagesRequest(req, res) { let usageDataCaptured = false; - // 使用自定义流处理器来捕获usage数据 - await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => { + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body); + + // 使用统一调度选择账号 + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash); + + // 根据账号类型选择对应的转发服务并调用 + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务(会自己选择账号) + await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => { // 回调函数:当检测到完整usage数据时记录真实token使用量 logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2)); @@ -88,7 +99,42 @@ async function handleMessagesRequest(req, res) { } else { logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData)); } - }); + }); + } else { + // Claude Console账号使用Console转发服务(需要传递accountId) + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2)); + + if (usageData && usageData.input_tokens !== undefined && usageData.output_tokens !== undefined) { + const inputTokens = usageData.input_tokens || 0; + const outputTokens = usageData.output_tokens || 0; + const cacheCreateTokens = usageData.cache_creation_input_tokens || 0; + const cacheReadTokens = usageData.cache_read_input_tokens || 0; + const model = usageData.model || 'unknown'; + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId; + apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, usageAccountId).catch(error => { + logger.error('❌ Failed to record stream usage:', error); + }); + + // 更新时间窗口内的token计数 + if (req.rateLimitInfo) { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => { + logger.error('❌ Failed to update rate limit token count:', error); + }); + logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`); + } + + usageDataCaptured = true; + logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`); + } else { + logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData)); + } + }, accountId); + } // 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算 setTimeout(() => { @@ -103,7 +149,21 @@ async function handleMessagesRequest(req, res) { apiKeyName: req.apiKey.name }); - const response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers); + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body); + + // 使用统一调度选择账号 + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash); + + // 根据账号类型选择对应的转发服务 + let response; + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务 + response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers); + } else { + // Claude Console账号使用Console转发服务 + response = await claudeConsoleRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers, accountId); + } logger.info('📡 Claude API response received', { statusCode: response.statusCode, diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 247236be..886a69ca 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -37,7 +37,8 @@ class ClaudeAccountService { claudeAiOauth = null, // Claude标准格式的OAuth数据 proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } isActive = true, - accountType = 'shared' // 'dedicated' or 'shared' + accountType = 'shared', // 'dedicated' or 'shared' + priority = 50 // 调度优先级 (1-100,数字越小优先级越高) } = options; const accountId = uuidv4(); @@ -60,6 +61,7 @@ class ClaudeAccountService { proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), accountType: accountType, // 账号类型:'dedicated' 或 'shared' + priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', @@ -81,6 +83,7 @@ class ClaudeAccountService { proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), accountType: accountType, // 账号类型:'dedicated' 或 'shared' + priority: priority.toString(), // 调度优先级 createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', @@ -101,6 +104,7 @@ class ClaudeAccountService { isActive, proxy, accountType, + priority, status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, @@ -305,6 +309,7 @@ class ClaudeAccountService { status: account.status, errorMessage: account.errorMessage, accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 createdAt: account.createdAt, lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, @@ -343,7 +348,7 @@ class ClaudeAccountService { throw new Error('Account not found'); } - const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType']; + const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType', 'priority']; const updatedData = { ...accountData }; // 检查是否新增了 refresh token @@ -355,6 +360,8 @@ class ClaudeAccountService { updatedData[field] = this._encryptSensitiveData(value); } else if (field === 'proxy') { updatedData[field] = value ? JSON.stringify(value) : ''; + } else if (field === 'priority') { + updatedData[field] = value.toString(); } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { @@ -1008,7 +1015,7 @@ class ClaudeAccountService { } } - logger.success(`✅ Session window initialization completed:`); + logger.success('✅ Session window initialization completed:'); logger.success(` 📊 Total accounts: ${accounts.length}`); logger.success(` ✅ Initialized: ${initializedCount}`); logger.success(` ⏭️ Skipped (existing): ${skippedCount}`); diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js new file mode 100644 index 00000000..8f25d82f --- /dev/null +++ b/src/services/claudeConsoleAccountService.js @@ -0,0 +1,457 @@ +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); +const { SocksProxyAgent } = require('socks-proxy-agent'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const redis = require('../models/redis'); +const logger = require('../utils/logger'); +const config = require('../../config/config'); + +class ClaudeConsoleAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'; + this.ENCRYPTION_SALT = 'claude-console-salt'; + + // Redis键前缀 + this.ACCOUNT_KEY_PREFIX = 'claude_console_account:'; + this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts'; + } + + // 🏢 创建Claude Console账户 + async createAccount(options = {}) { + const { + name = 'Claude Console Account', + description = '', + apiUrl = '', + apiKey = '', + priority = 50, // 默认优先级50(1-100) + supportedModels = [], // 支持的模型列表,空数组表示支持所有 + userAgent = 'claude-cli/1.0.61 (console, cli)', + rateLimitDuration = 60, // 限流时间(分钟) + proxy = null, + isActive = true, + accountType = 'shared' // 'dedicated' or 'shared' + } = options; + + // 验证必填字段 + if (!apiUrl || !apiKey) { + throw new Error('API URL and API Key are required for Claude Console account'); + } + + const accountId = uuidv4(); + + const accountData = { + id: accountId, + platform: 'claude-console', + name, + description, + apiUrl: this._encryptSensitiveData(apiUrl), + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + supportedModels: JSON.stringify(supportedModels), + userAgent, + rateLimitDuration: rateLimitDuration.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '' + }; + + const client = redis.getClientSafe(); + await client.hset( + `${this.ACCOUNT_KEY_PREFIX}${accountId}`, + accountData + ); + + // 如果是共享账户,添加到共享账户集合 + if (accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId); + } + + logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`); + + return { + id: accountId, + name, + description, + apiUrl, + priority, + supportedModels, + userAgent, + rateLimitDuration, + isActive, + proxy, + accountType, + status: 'active', + createdAt: accountData.createdAt + }; + } + + // 📋 获取所有Claude Console账户 + async getAllAccounts() { + try { + const client = redis.getClientSafe(); + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`); + const accounts = []; + + for (const key of keys) { + const accountData = await client.hgetall(key); + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData); + + accounts.push({ + id: accountData.id, + platform: accountData.platform, + name: accountData.name, + description: accountData.description, + apiUrl: this._maskApiUrl(this._decryptSensitiveData(accountData.apiUrl)), + priority: parseInt(accountData.priority) || 50, + supportedModels: JSON.parse(accountData.supportedModels || '[]'), + userAgent: accountData.userAgent, + rateLimitDuration: parseInt(accountData.rateLimitDuration) || 60, + isActive: accountData.isActive === 'true', + proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, + accountType: accountData.accountType || 'shared', + status: accountData.status, + errorMessage: accountData.errorMessage, + createdAt: accountData.createdAt, + lastUsedAt: accountData.lastUsedAt, + rateLimitStatus: rateLimitInfo + }); + } + } + + return accounts; + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error); + throw error; + } + } + + // 🔍 获取单个账户(内部使用,包含敏感信息) + async getAccount(accountId) { + const client = redis.getClientSafe(); + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`); + + if (!accountData || Object.keys(accountData).length === 0) { + return null; + } + + // 解密敏感字段 + accountData.apiUrl = this._decryptSensitiveData(accountData.apiUrl); + accountData.apiKey = this._decryptSensitiveData(accountData.apiKey); + + // 解析JSON字段 + accountData.supportedModels = JSON.parse(accountData.supportedModels || '[]'); + accountData.priority = parseInt(accountData.priority) || 50; + accountData.rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60; + accountData.isActive = accountData.isActive === 'true'; + + if (accountData.proxy) { + accountData.proxy = JSON.parse(accountData.proxy); + } + + return accountData; + } + + // 📝 更新账户 + async updateAccount(accountId, updates) { + try { + const existingAccount = await this.getAccount(accountId); + if (!existingAccount) { + throw new Error('Account not found'); + } + + const client = redis.getClientSafe(); + const updatedData = {}; + + // 处理各个字段的更新 + if (updates.name !== undefined) updatedData.name = updates.name; + if (updates.description !== undefined) updatedData.description = updates.description; + if (updates.apiUrl !== undefined) updatedData.apiUrl = this._encryptSensitiveData(updates.apiUrl); + if (updates.apiKey !== undefined) updatedData.apiKey = this._encryptSensitiveData(updates.apiKey); + if (updates.priority !== undefined) updatedData.priority = updates.priority.toString(); + if (updates.supportedModels !== undefined) updatedData.supportedModels = JSON.stringify(updates.supportedModels); + if (updates.userAgent !== undefined) updatedData.userAgent = updates.userAgent; + if (updates.rateLimitDuration !== undefined) updatedData.rateLimitDuration = updates.rateLimitDuration.toString(); + if (updates.proxy !== undefined) updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''; + if (updates.isActive !== undefined) updatedData.isActive = updates.isActive.toString(); + + // 处理账户类型变更 + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + updatedData.accountType = updates.accountType; + + if (updates.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId); + } else { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId); + } + } + + updatedData.updatedAt = new Date().toISOString(); + + await client.hset( + `${this.ACCOUNT_KEY_PREFIX}${accountId}`, + updatedData + ); + + logger.success(`📝 Updated Claude Console account: ${accountId}`); + + return { success: true }; + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error); + throw error; + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const client = redis.getClientSafe(); + const account = await this.getAccount(accountId); + + if (!account) { + throw new Error('Account not found'); + } + + // 从Redis删除 + await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`); + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId); + } + + logger.success(`🗑️ Deleted Claude Console account: ${accountId}`); + + return { success: true }; + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error); + throw error; + } + } + + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe(); + const account = await this.getAccount(accountId); + + if (!account) { + throw new Error('Account not found'); + } + + const updates = { + rateLimitedAt: new Date().toISOString(), + rateLimitStatus: 'limited' + }; + + await client.hset( + `${this.ACCOUNT_KEY_PREFIX}${accountId}`, + updates + ); + + logger.warn(`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error); + throw error; + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const client = redis.getClientSafe(); + + await client.hdel( + `${this.ACCOUNT_KEY_PREFIX}${accountId}`, + 'rateLimitedAt', + 'rateLimitStatus' + ); + + logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error); + throw error; + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await this.getAccount(accountId); + if (!account) { + return false; + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const rateLimitedAt = new Date(account.rateLimitedAt); + const now = new Date(); + const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60); + + // 使用账户配置的限流时间 + const rateLimitDuration = account.rateLimitDuration || 60; + + if (minutesSinceRateLimit >= rateLimitDuration) { + await this.removeAccountRateLimit(accountId); + return false; + } + + return true; + } + + return false; + } catch (error) { + logger.error(`❌ Failed to check rate limit status for Claude Console account: ${accountId}`, error); + return false; + } + } + + // 🚫 标记账号为封锁状态(模型不支持等原因) + async blockAccount(accountId, reason) { + try { + const client = redis.getClientSafe(); + + const updates = { + status: 'blocked', + errorMessage: reason, + blockedAt: new Date().toISOString() + }; + + await client.hset( + `${this.ACCOUNT_KEY_PREFIX}${accountId}`, + updates + ); + + logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error); + throw error; + } + } + + // 🌐 创建代理agent + _createProxyAgent(proxyConfig) { + if (!proxyConfig) { + return null; + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig; + + if (proxy.type === 'socks5') { + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; + const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`; + return new SocksProxyAgent(socksUrl); + } else if (proxy.type === 'http' || proxy.type === 'https') { + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''; + const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; + return new HttpsProxyAgent(httpUrl); + } + } catch (error) { + logger.warn('⚠️ Invalid proxy configuration:', error); + } + + return null; + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) return ''; + + try { + const key = this._generateEncryptionKey(); + const iv = crypto.randomBytes(16); + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return iv.toString('hex') + ':' + encrypted; + } catch (error) { + logger.error('❌ Encryption error:', error); + return data; + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) return ''; + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':'); + if (parts.length === 2) { + const key = this._generateEncryptionKey(); + const iv = Buffer.from(parts[0], 'hex'); + const encrypted = parts[1]; + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + } + + return encryptedData; + } catch (error) { + logger.error('❌ Decryption error:', error); + return encryptedData; + } + } + + // 🔑 生成加密密钥 + _generateEncryptionKey() { + return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32); + } + + // 🎭 掩码API URL + _maskApiUrl(apiUrl) { + if (!apiUrl) return ''; + + try { + const url = new URL(apiUrl); + return `${url.protocol}//${url.hostname}/***`; + } catch { + return '***'; + } + } + + // 📊 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt); + const now = new Date(); + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)); + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60; + const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit); + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining + }; + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0 + }; + } +} + +module.exports = new ClaudeConsoleAccountService(); \ No newline at end of file diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js new file mode 100644 index 00000000..8754d349 --- /dev/null +++ b/src/services/claudeConsoleRelayService.js @@ -0,0 +1,527 @@ +const axios = require('axios'); +const claudeConsoleAccountService = require('./claudeConsoleAccountService'); +const logger = require('../utils/logger'); +const config = require('../../config/config'); + +class ClaudeConsoleRelayService { + constructor() { + this.defaultUserAgent = 'claude-cli/1.0.61 (console, cli)'; + } + + // 🚀 转发请求到Claude Console API + async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders, accountId, options = {}) { + let abortController = null; + + try { + // 获取账户信息 + const account = await claudeConsoleAccountService.getAccount(accountId); + if (!account) { + throw new Error('Claude Console Claude account not found'); + } + + logger.info(`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`); + logger.debug(`🌐 Account API URL: ${account.apiUrl}`); + + // 检查模型支持 + if (account.supportedModels && account.supportedModels.length > 0) { + const requestedModel = requestBody.model; + if (requestedModel && !account.supportedModels.includes(requestedModel)) { + logger.warn(`🚫 Model not supported by Claude Console account ${account.name}: ${requestedModel}`); + + // 标记账户为blocked + await claudeConsoleAccountService.blockAccount(accountId, `Model ${requestedModel} not supported`); + + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: { + type: 'invalid_request_error', + message: `Model ${requestedModel} is not supported by this account` + } + }) + }; + } + } + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy); + + // 创建AbortController用于取消请求 + abortController = new AbortController(); + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting Claude Console Claude request'); + if (abortController && !abortController.signal.aborted) { + abortController.abort(); + } + }; + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect); + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect); + } + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') + ? cleanUrl + : `${cleanUrl}/v1/messages`; + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`); + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: requestBody, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': account.apiKey, + 'anthropic-version': '2023-06-01', + 'User-Agent': account.userAgent || this.defaultUserAgent, + ...this._filterClientHeaders(clientHeaders) + }, + httpsAgent: proxyAgent, + timeout: config.proxy.timeout || 60000, + signal: abortController.signal, + validateStatus: () => true // 接受所有状态码 + }; + + // 添加beta header如果需要 + if (options.betaHeader) { + requestConfig.headers['anthropic-beta'] = options.betaHeader; + } + + // 发送请求 + const response = await axios(requestConfig); + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect); + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect); + } + + logger.debug(`🔗 Claude Console API response: ${response.status}`); + + // 检查是否为限流错误 + if (response.status === 429) { + logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`); + await claudeConsoleAccountService.markAccountRateLimited(accountId); + } else if (response.status === 200 || response.status === 201) { + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId); + if (isRateLimited) { + await claudeConsoleAccountService.removeAccountRateLimit(accountId); + } + } + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId); + + return { + statusCode: response.status, + headers: response.headers, + body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data), + accountId + }; + + } catch (error) { + // 处理特定错误 + if (error.name === 'AbortError' || error.code === 'ECONNABORTED') { + logger.info('Request aborted due to client disconnect'); + throw new Error('Client disconnected'); + } + + logger.error('❌ Claude Console Claude relay request failed:', error.message); + + // 检查是否是模型不支持导致的错误 + if (error.response && error.response.data && error.response.data.error) { + const errorMessage = error.response.data.error.message || ''; + if (errorMessage.includes('model') && errorMessage.includes('not supported')) { + await claudeConsoleAccountService.blockAccount(accountId, errorMessage); + } + } + + throw error; + } + } + + // 🌊 处理流式响应 + async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, accountId, streamTransformer = null, options = {}) { + try { + // 获取账户信息 + const account = await claudeConsoleAccountService.getAccount(accountId); + if (!account) { + throw new Error('Claude Console Claude account not found'); + } + + logger.info(`📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`); + logger.debug(`🌐 Account API URL: ${account.apiUrl}`); + + // 检查模型支持 + if (account.supportedModels && account.supportedModels.length > 0) { + const requestedModel = requestBody.model; + if (requestedModel && !account.supportedModels.includes(requestedModel)) { + logger.warn(`🚫 Model not supported by Claude Console account ${account.name}: ${requestedModel}`); + + // 标记账户为blocked + await claudeConsoleAccountService.blockAccount(accountId, `Model ${requestedModel} not supported`); + + // 对于流式响应,需要写入错误并结束流 + const errorResponse = JSON.stringify({ + error: { + type: 'invalid_request_error', + message: `Model ${requestedModel} is not supported by this account` + } + }); + + responseStream.writeHead(400, { 'Content-Type': 'application/json' }); + responseStream.end(errorResponse); + return; + } + } + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy); + + // 发送流式请求 + await this._makeClaudeConsoleStreamRequest( + requestBody, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer, + options + ); + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId); + + } catch (error) { + logger.error('❌ Claude Console Claude stream relay failed:', error); + throw error; + } + } + + // 🌊 发送流式请求到Claude Console API + async _makeClaudeConsoleStreamRequest(body, account, proxyAgent, clientHeaders, responseStream, accountId, usageCallback, streamTransformer = null, requestOptions = {}) { + return new Promise((resolve, reject) => { + let aborted = false; + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') + ? cleanUrl + : `${cleanUrl}/v1/messages`; + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`); + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: body, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': account.apiKey, + 'anthropic-version': '2023-06-01', + 'User-Agent': account.userAgent || this.defaultUserAgent, + ...this._filterClientHeaders(clientHeaders) + }, + httpsAgent: proxyAgent, + timeout: config.proxy.timeout || 60000, + responseType: 'stream' + }; + + // 添加beta header如果需要 + if (requestOptions.betaHeader) { + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader; + } + + // 发送请求 + const request = axios(requestConfig); + + request.then(response => { + logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`); + + // 错误响应处理 + if (response.status !== 200) { + logger.error(`❌ Claude Console API returned error status: ${response.status}`); + + if (response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId); + } + + // 收集错误数据 + let errorData = ''; + response.data.on('data', chunk => { + errorData += chunk.toString(); + }); + + response.data.on('end', () => { + if (!responseStream.destroyed) { + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: 'Claude Console API error', + status: response.status, + details: errorData, + timestamp: new Date().toISOString() + })}\n\n`); + responseStream.end(); + } + reject(new Error(`Claude Console API error: ${response.status}`)); + }); + return; + } + + // 成功响应,检查并移除限流状态 + claudeConsoleAccountService.isAccountRateLimited(accountId).then(isRateLimited => { + if (isRateLimited) { + claudeConsoleAccountService.removeAccountRateLimit(accountId); + } + }); + + // 设置响应头 + if (!responseStream.headersSent) { + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + } + + let buffer = ''; + let finalUsageReported = false; + let collectedUsageData = {}; + + // 处理流数据 + response.data.on('data', chunk => { + try { + if (aborted) return; + + const chunkStr = chunk.toString(); + buffer += chunkStr; + + // 处理完整的SSE行 + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + // 转发数据并解析usage + if (lines.length > 0 && !responseStream.destroyed) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + + // 应用流转换器如果有 + if (streamTransformer) { + const transformed = streamTransformer(linesToForward); + if (transformed) { + responseStream.write(transformed); + } + } else { + responseStream.write(linesToForward); + } + + // 解析SSE数据寻找usage信息 + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6); + const data = JSON.parse(jsonStr); + + // 收集usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + collectedUsageData.input_tokens = data.message.usage.input_tokens || 0; + collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0; + collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0; + collectedUsageData.model = data.message.model; + } + + if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0; + + if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { + usageCallback({ ...collectedUsageData, accountId }); + finalUsageReported = true; + } + } + + // 检查错误 + if (data.type === 'error' && data.error) { + const errorMessage = data.error.message || ''; + if (errorMessage.includes('model') && errorMessage.includes('not supported')) { + claudeConsoleAccountService.blockAccount(accountId, errorMessage); + } + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + } catch (error) { + logger.error('❌ Error processing Claude Console stream data:', error); + if (!responseStream.destroyed) { + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n`); + } + } + }); + + response.data.on('end', () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && !responseStream.destroyed) { + if (streamTransformer) { + const transformed = streamTransformer(buffer); + if (transformed) { + responseStream.write(transformed); + } + } else { + responseStream.write(buffer); + } + } + + // 确保流正确结束 + if (!responseStream.destroyed) { + responseStream.end(); + } + + logger.debug('🌊 Claude Console Claude stream response completed'); + resolve(); + } catch (error) { + logger.error('❌ Error processing stream end:', error); + reject(error); + } + }); + + response.data.on('error', error => { + logger.error('❌ Claude Console stream error:', error); + if (!responseStream.destroyed) { + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: 'Stream error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n`); + responseStream.end(); + } + reject(error); + }); + + }).catch(error => { + if (aborted) return; + + logger.error('❌ Claude Console Claude stream request error:', error.message); + + // 检查是否是429错误 + if (error.response && error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId); + } + + // 发送错误响应 + if (!responseStream.headersSent) { + responseStream.writeHead(error.response?.status || 500, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + } + + if (!responseStream.destroyed) { + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: error.message, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n`); + responseStream.end(); + } + + reject(error); + }); + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up Claude Console stream'); + aborted = true; + }); + }); + } + + // 🔧 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + const sensitiveHeaders = [ + 'x-api-key', + 'authorization', + 'host', + 'content-length', + 'connection', + 'proxy-authorization', + 'content-encoding', + 'transfer-encoding' + ]; + + const filteredHeaders = {}; + + Object.keys(clientHeaders || {}).forEach(key => { + const lowerKey = key.toLowerCase(); + if (!sensitiveHeaders.includes(lowerKey)) { + filteredHeaders[key] = clientHeaders[key]; + } + }); + + return filteredHeaders; + } + + // 🕐 更新最后使用时间 + async _updateLastUsedTime(accountId) { + try { + const client = require('../models/redis').getClientSafe(); + await client.hset( + `claude_console_account:${accountId}`, + 'lastUsedAt', + new Date().toISOString() + ); + } catch (error) { + logger.warn(`⚠️ Failed to update last used time for Claude Console account ${accountId}:`, error.message); + } + } + + // 🎯 健康检查 + async healthCheck() { + try { + const accounts = await claudeConsoleAccountService.getAllAccounts(); + const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active'); + + return { + healthy: activeAccounts.length > 0, + activeAccounts: activeAccounts.length, + totalAccounts: accounts.length, + timestamp: new Date().toISOString() + }; + } catch (error) { + logger.error('❌ Claude Console Claude health check failed:', error); + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + }; + } + } +} + +module.exports = new ClaudeConsoleRelayService(); \ No newline at end of file diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js new file mode 100644 index 00000000..af92acd3 --- /dev/null +++ b/src/services/unifiedClaudeScheduler.js @@ -0,0 +1,294 @@ +const claudeAccountService = require('./claudeAccountService'); +const claudeConsoleAccountService = require('./claudeConsoleAccountService'); +const redis = require('../models/redis'); +const logger = require('../utils/logger'); + +class UnifiedClaudeScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:'; + } + + // 🎯 统一调度Claude账号(官方和Console) + async selectAccountForApiKey(apiKeyData, sessionHash = null) { + try { + // 如果API Key绑定了专属账户,优先使用 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`); + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + }; + } else { + logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available, falling back to pool`); + } + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash); + if (mappedAccount) { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType); + if (isAvailable) { + logger.info(`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`); + return mappedAccount; + } else { + logger.warn(`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`); + await this._deleteSessionMapping(sessionHash); + } + } + } + + // 获取所有可用账户 + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData); + + if (availableAccounts.length === 0) { + throw new Error('No available Claude accounts (neither official nor console)'); + } + + // 按优先级和最后使用时间排序 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts); + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0]; + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType); + logger.info(`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`); + } + + logger.info(`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`); + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + }; + } catch (error) { + logger.error('❌ Failed to select account for API key:', error); + throw error; + } + } + + // 📋 获取所有可用账户(合并官方和Console) + async _getAllAvailableAccounts(apiKeyData) { + const availableAccounts = []; + + // 如果API Key绑定了专属Claude账户,优先返回 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked') { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id); + if (!isRateLimited) { + logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`); + return [{ + ...boundAccount, + accountId: boundAccount.id, + accountType: 'claude-official', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + }]; + } + } else { + logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available`); + } + } + + // 获取官方Claude账户(共享池) + const claudeAccounts = await redis.getAllClaudeAccounts(); + for (const account of claudeAccounts) { + if (account.isActive === 'true' && + account.status !== 'error' && + account.status !== 'blocked' && + (account.accountType === 'shared' || !account.accountType)) { // 兼容旧数据 + + // 检查是否被限流 + const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id); + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-official', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }); + } + } + } + + // 获取Claude Console账户 + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts(); + logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`); + + for (const account of consoleAccounts) { + logger.info(`🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}`); + + // 注意:getAllAccounts返回的isActive是布尔值 + if (account.isActive === true && + account.status === 'active' && + account.accountType === 'shared') { + + // 检查是否被限流 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id); + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-console', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }); + logger.info(`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`); + } else { + logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`); + } + } else { + logger.info(`❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}`); + } + } + + logger.info(`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter(a => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter(a => a.accountType === 'claude-console').length})`); + return availableAccounts; + } + + // 🔢 按优先级和最后使用时间排序账户 + _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; + }); + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType) { + try { + if (accountType === 'claude-official') { + const account = await redis.getClaudeAccount(accountId); + if (!account || account.isActive !== 'true' || account.status === 'error') { + return false; + } + return !(await claudeAccountService.isAccountRateLimited(accountId)); + } else if (accountType === 'claude-console') { + const account = await claudeConsoleAccountService.getAccount(accountId); + if (!account || !account.isActive || account.status !== 'active') { + return false; + } + return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)); + } + return false; + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error); + return false; + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe(); + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); + + if (mappingData) { + try { + return JSON.parse(mappingData); + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error); + return null; + } + } + + return null; + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + const client = redis.getClientSafe(); + const mappingData = JSON.stringify({ accountId, accountType }); + + // 设置1小时过期 + await client.setex( + `${this.SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, + mappingData + ); + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe(); + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`); + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId, accountType, sessionHash = null) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.markAccountRateLimited(accountId, sessionHash); + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.markAccountRateLimited(accountId); + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash); + } + + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, error); + throw error; + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.removeAccountRateLimit(accountId); + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.removeAccountRateLimit(accountId); + } + + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, error); + throw error; + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId, accountType) { + try { + if (accountType === 'claude-official') { + return await claudeAccountService.isAccountRateLimited(accountId); + } else if (accountType === 'claude-console') { + return await claudeConsoleAccountService.isAccountRateLimited(accountId); + } + return false; + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error); + return false; + } + } + + // 🚫 标记Claude Console账户为封锁状态(模型不支持) + async blockConsoleAccount(accountId, reason) { + try { + await claudeConsoleAccountService.blockAccount(accountId, reason); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to block console account: ${accountId}`, error); + throw error; + } + } +} + +module.exports = new UnifiedClaudeScheduler(); \ No newline at end of file diff --git a/web/admin-spa/dist/index.html b/web/admin-spa/dist/index.html index bee0f376..cff50e9e 100644 --- a/web/admin-spa/dist/index.html +++ b/web/admin-spa/dist/index.html @@ -18,12 +18,12 @@ - + - +
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index d1db80d8..c8da44b0 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -53,6 +53,15 @@ > Claude +