diff --git a/.env.example b/.env.example index f3791b06..fa640f15 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_VERSION=2023-06-01 CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14 +# 🚫 529错误处理配置 +# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) +CLAUDE_OVERLOAD_HANDLING_MINUTES=0 + # 🌐 代理配置 DEFAULT_PROXY_TIMEOUT=600000 MAX_PROXY_RETRIES=3 diff --git a/config/config.example.js b/config/config.example.js index 5fae5fde..3f8d71d7 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -46,7 +46,14 @@ const config = { 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' + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', + overloadHandling: { + enabled: (() => { + const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0 + // 验证配置值:限制在0-1440分钟(24小时)内 + return Math.max(0, Math.min(minutes, 1440)) + })() + } }, // ☁️ Bedrock API配置 diff --git a/package-lock.json b/package-lock.json index cb50813f..10462d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", @@ -7077,6 +7078,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz", diff --git a/package.json b/package.json index 86424cea..7e0c9e98 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", diff --git a/src/app.js b/src/app.js index 67f26bfe..a1f06b36 100644 --- a/src/app.js +++ b/src/app.js @@ -537,7 +537,7 @@ class Application { logger.info( `🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes` ) - + // 🚨 启动限流状态自动清理服务 // 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度 const rateLimitCleanupService = require('./services/rateLimitCleanupService') @@ -563,7 +563,7 @@ class Application { } catch (error) { logger.error('❌ Error cleaning up pricing service:', error) } - + // 停止限流清理服务 try { const rateLimitCleanupService = require('./services/rateLimitCleanupService') diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index f4f995c1..4952dd5e 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -315,7 +315,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => { expiresAt: expiresAt || null, dailyCostLimit: dailyCostLimit || null, createdBy: 'user', - permissions: ['messages'] // 用户创建的API Key默认只有messages权限 + // 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限 + permissions: 'all' } const newApiKey = await apiKeyService.createApiKey(apiKeyData) diff --git a/src/routes/webhook.js b/src/routes/webhook.js index f4c70b41..4af05fb6 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -124,7 +124,16 @@ router.post('/test', authenticateAdmin, async (req, res) => { serverUrl, level, sound, - group + group, + // SMTP 相关字段 + host, + port, + secure, + user, + pass, + from, + to, + ignoreTLS } = req.body // Bark平台特殊处理 @@ -149,6 +158,34 @@ router.post('/test', authenticateAdmin, async (req, res) => { } logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`) + } else if (type === 'smtp') { + // SMTP平台验证 + if (!host) { + return res.status(400).json({ + error: 'Missing SMTP host', + message: '请提供SMTP服务器地址' + }) + } + if (!user) { + return res.status(400).json({ + error: 'Missing SMTP user', + message: '请提供SMTP用户名' + }) + } + if (!pass) { + return res.status(400).json({ + error: 'Missing SMTP password', + message: '请提供SMTP密码' + }) + } + if (!to) { + return res.status(400).json({ + error: 'Missing recipient email', + message: '请提供收件人邮箱' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`) } else { // 其他平台验证URL if (!url) { @@ -188,6 +225,16 @@ router.post('/test', authenticateAdmin, async (req, res) => { platform.level = level platform.sound = sound platform.group = group + } else if (type === 'smtp') { + // 添加SMTP特有字段 + platform.host = host + platform.port = port || 587 + platform.secure = secure || false + platform.user = user + platform.pass = pass + platform.from = from + platform.to = to + platform.ignoreTLS = ignoreTLS || false } const result = await webhookService.testWebhook(platform) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 336e8828..9219a7f5 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -2137,6 +2137,108 @@ class ClaudeAccountService { logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) } } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + // 获取配置的过载处理时间(分钟) + const overloadMinutes = config.overloadHandling?.enabled || 0 + + if (overloadMinutes === 0) { + logger.info('⏭️ 529 error handling is disabled') + return { success: false, error: '529 error handling is disabled' } + } + + const overloadKey = `account:overload:${accountId}` + const ttl = overloadMinutes * 60 // 转换为秒 + + await redis.setex( + overloadKey, + ttl, + JSON.stringify({ + accountId, + accountName: accountData.name, + markedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttl * 1000).toISOString() + }) + ) + + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes` + ) + + // 在账号上记录最后一次529错误 + const updates = { + lastOverloadAt: new Date().toISOString(), + errorMessage: `529错误 - 过载${overloadMinutes}分钟` + } + + const updatedAccountData = { ...accountData, ...updates } + await redis.setClaudeAccount(accountId, updatedAccountData) + + return { success: true, accountName: accountData.name, duration: overloadMinutes } + } catch (error) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error) + // 不抛出错误,避免影响主请求流程 + return { success: false, error: error.message } + } + } + + // ✅ 检查账号是否过载 + async isAccountOverloaded(accountId) { + try { + // 如果529处理未启用,直接返回false + const overloadMinutes = config.overloadHandling?.enabled || 0 + if (overloadMinutes === 0) { + return false + } + + const overloadKey = `account:overload:${accountId}` + const overloadData = await redis.get(overloadKey) + + if (overloadData) { + // 账号处于过载状态 + return true + } + + // 账号未过载 + return false + } catch (error) { + logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error) + return false + } + } + + // 🔄 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const overloadKey = `account:overload:${accountId}` + await redis.del(overloadKey) + + logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`) + + // 清理账号上的错误信息 + if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) { + const updatedAccountData = { ...accountData } + delete updatedAccountData.errorMessage + delete updatedAccountData.lastOverloadAt + await redis.setClaudeAccount(accountId, updatedAccountData) + } + } catch (error) { + logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error) + // 不抛出错误,移除过载状态失败不应该影响主流程 + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 61e4f2f3..6b97c881 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -19,10 +19,11 @@ class ClaudeConsoleRelayService { options = {} ) { let abortController = null + let account = null try { // 获取账户信息 - const account = await claudeConsoleAccountService.getAccount(accountId) + account = await claudeConsoleAccountService.getAccount(accountId) if (!account) { throw new Error('Claude Console Claude account not found') } @@ -222,7 +223,10 @@ class ClaudeConsoleRelayService { throw new Error('Client disconnected') } - logger.error('❌ Claude Console Claude relay request failed:', error.message) + logger.error( + `❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`, + error.message + ) // 不再因为模型不支持而block账号 @@ -241,9 +245,10 @@ class ClaudeConsoleRelayService { streamTransformer = null, options = {} ) { + let account = null try { // 获取账户信息 - const account = await claudeConsoleAccountService.getAccount(accountId) + account = await claudeConsoleAccountService.getAccount(accountId) if (!account) { throw new Error('Claude Console Claude account not found') } @@ -297,7 +302,10 @@ class ClaudeConsoleRelayService { // 更新最后使用时间 await this._updateLastUsedTime(accountId) } catch (error) { - logger.error('❌ Claude Console Claude stream relay failed:', error) + logger.error( + `❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`, + error + ) throw error } } @@ -376,7 +384,9 @@ class ClaudeConsoleRelayService { // 错误响应处理 if (response.status !== 200) { - logger.error(`❌ Claude Console API returned error status: ${response.status}`) + logger.error( + `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}` + ) if (response.status === 401) { claudeConsoleAccountService.markAccountUnauthorized(accountId) @@ -528,7 +538,10 @@ class ClaudeConsoleRelayService { } } } catch (error) { - logger.error('❌ Error processing Claude Console stream data:', error) + logger.error( + `❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`, + error + ) if (!responseStream.destroyed) { responseStream.write('event: error\n') responseStream.write( @@ -570,7 +583,10 @@ class ClaudeConsoleRelayService { }) response.data.on('error', (error) => { - logger.error('❌ Claude Console stream error:', error) + logger.error( + `❌ Claude Console stream error (Account: ${account?.name || accountId}):`, + error + ) if (!responseStream.destroyed) { responseStream.write('event: error\n') responseStream.write( @@ -590,7 +606,10 @@ class ClaudeConsoleRelayService { return } - logger.error('❌ Claude Console Claude stream request error:', error.message) + logger.error( + `❌ Claude Console stream request error (Account: ${account?.name || accountId}):`, + error.message + ) // 检查错误状态 if (error.response) { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index f4e03334..a0f8e4b1 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -208,6 +208,24 @@ class ClaudeRelayService { ) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) } + // 检查是否为529状态码(服务过载) + else if (response.statusCode === 529) { + logger.warn(`🚫 Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError) + } + } else { + logger.info(`🚫 529 error handling is disabled, skipping account overload marking`) + } + } // 检查是否为5xx状态码 else if (response.statusCode >= 500 && response.statusCode < 600) { logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) @@ -296,6 +314,19 @@ class ClaudeRelayService { await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) } + // 如果请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + // 只有真实的 Claude Code 请求才更新 headers if ( clientHeaders && @@ -741,7 +772,7 @@ class ClaudeRelayService { resolve(response) } catch (error) { - logger.error('❌ Failed to parse Claude API response:', error) + logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error) reject(error) } }) @@ -754,7 +785,7 @@ class ClaudeRelayService { req.on('error', async (error) => { console.error(': ❌ ', error) - logger.error('❌ Claude API request error:', error.message, { + logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, { code: error.code, errno: error.errno, syscall: error.syscall, @@ -781,7 +812,7 @@ class ClaudeRelayService { req.on('timeout', async () => { req.destroy() - logger.error('❌ Claude API request timeout') + logger.error(`❌ Claude API request timeout (Account: ${accountId})`) await this._handleServerError(accountId, 504, null, 'Request') @@ -889,7 +920,7 @@ class ClaudeRelayService { options ) } catch (error) { - logger.error('❌ Claude stream relay with usage capture failed:', error) + logger.error(`❌ Claude stream relay with usage capture failed:`, error) throw error } } @@ -1002,6 +1033,27 @@ class ClaudeRelayService { `🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` ) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } else if (res.statusCode === 529) { + logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to mark account as overloaded: ${accountId}`, + overloadError + ) + } + } else { + logger.info( + `🚫 [Stream] 529 error handling is disabled, skipping account overload marking` + ) + } } else if (res.statusCode >= 500 && res.statusCode < 600) { logger.warn( `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` @@ -1015,7 +1067,9 @@ class ClaudeRelayService { logger.error('❌ Error in stream error handler:', err) }) - logger.error(`❌ Claude API returned error status: ${res.statusCode}`) + logger.error( + `❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}` + ) let errorData = '' res.on('data', (chunk) => { @@ -1024,7 +1078,10 @@ class ClaudeRelayService { res.on('end', () => { console.error(': ❌ ', errorData) - logger.error('❌ Claude API error response:', errorData) + logger.error( + `❌ Claude API error response (Account: ${account?.name || accountId}):`, + errorData + ) if (!responseStream.destroyed) { // 发送错误事件 responseStream.write('event: error\n') @@ -1327,6 +1384,19 @@ class ClaudeRelayService { await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) } + // 如果流式请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + // 只有真实的 Claude Code 请求才更新 headers(流式请求) if ( clientHeaders && @@ -1343,11 +1413,15 @@ class ClaudeRelayService { }) req.on('error', async (error) => { - logger.error('❌ Claude stream request error:', error.message, { - code: error.code, - errno: error.errno, - syscall: error.syscall - }) + logger.error( + `❌ Claude stream request error (Account: ${account?.name || accountId}):`, + error.message, + { + code: error.code, + errno: error.errno, + syscall: error.syscall + } + ) // 根据错误类型提供更具体的错误信息 let errorMessage = 'Upstream request failed' @@ -1391,7 +1465,7 @@ class ClaudeRelayService { req.on('timeout', async () => { req.destroy() - logger.error('❌ Claude stream request timeout') + logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`) if (!responseStream.headersSent) { responseStream.writeHead(504, { @@ -1493,7 +1567,7 @@ class ClaudeRelayService { }) req.on('error', async (error) => { - logger.error('❌ Claude stream request error:', error.message, { + logger.error(`❌ Claude stream request error:`, error.message, { code: error.code, errno: error.errno, syscall: error.syscall @@ -1541,7 +1615,7 @@ class ClaudeRelayService { req.on('timeout', async () => { req.destroy() - logger.error('❌ Claude stream request timeout') + logger.error(`❌ Claude stream request timeout`) if (!responseStream.headersSent) { responseStream.writeHead(504, { diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 7bceb040..ae1d6942 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -107,7 +107,12 @@ class UnifiedClaudeScheduler { // 普通专属账户 const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + this._isSchedulable(boundAccount.schedulable) + ) { logger.info( `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` ) @@ -117,7 +122,7 @@ class UnifiedClaudeScheduler { } } else { logger.warn( - `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool` + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool` ) } } @@ -130,7 +135,8 @@ class UnifiedClaudeScheduler { if ( boundConsoleAccount && boundConsoleAccount.isActive === true && - boundConsoleAccount.status === 'active' + boundConsoleAccount.status === 'active' && + this._isSchedulable(boundConsoleAccount.schedulable) ) { logger.info( `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` @@ -141,7 +147,7 @@ class UnifiedClaudeScheduler { } } else { logger.warn( - `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool` + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool` ) } } @@ -151,7 +157,11 @@ class UnifiedClaudeScheduler { const boundBedrockAccountResult = await bedrockAccountService.getAccount( apiKeyData.bedrockAccountId ) - if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) { + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + this._isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { logger.info( `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` ) @@ -161,7 +171,7 @@ class UnifiedClaudeScheduler { } } else { logger.warn( - `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool` + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool` ) } } @@ -251,7 +261,8 @@ class UnifiedClaudeScheduler { boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked' && - boundAccount.status !== 'temp_error' + boundAccount.status !== 'temp_error' && + this._isSchedulable(boundAccount.schedulable) ) { const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { @@ -269,7 +280,9 @@ class UnifiedClaudeScheduler { ] } } else { - logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`) + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})` + ) } } @@ -281,7 +294,8 @@ class UnifiedClaudeScheduler { if ( boundConsoleAccount && boundConsoleAccount.isActive === true && - boundConsoleAccount.status === 'active' + boundConsoleAccount.status === 'active' && + this._isSchedulable(boundConsoleAccount.schedulable) ) { // 主动触发一次额度检查 try { @@ -317,7 +331,7 @@ class UnifiedClaudeScheduler { } } else { logger.warn( - `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available` + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})` ) } } @@ -327,7 +341,11 @@ class UnifiedClaudeScheduler { const boundBedrockAccountResult = await bedrockAccountService.getAccount( apiKeyData.bedrockAccountId ) - if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) { + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + this._isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { logger.info( `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` ) @@ -341,7 +359,9 @@ class UnifiedClaudeScheduler { } ] } else { - logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`) + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})` + ) } } @@ -528,7 +548,10 @@ class UnifiedClaudeScheduler { return false } - return !(await claudeAccountService.isAccountRateLimited(accountId)) + // 检查是否限流或过载 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId) + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + return !isRateLimited && !isOverloaded } else if (accountType === 'claude-console') { const account = await claudeConsoleAccountService.getAccount(accountId) if (!account || !account.isActive) { diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js index 59955cd1..df0f36fb 100644 --- a/src/services/webhookConfigService.js +++ b/src/services/webhookConfigService.js @@ -63,7 +63,8 @@ class WebhookConfigService { 'slack', 'discord', 'custom', - 'bark' + 'bark', + 'smtp' ] for (const platform of config.platforms) { @@ -71,8 +72,8 @@ class WebhookConfigService { throw new Error(`不支持的平台类型: ${platform.type}`) } - // Bark平台使用deviceKey而不是url - if (platform.type !== 'bark') { + // Bark和SMTP平台不使用标准URL + if (platform.type !== 'bark' && platform.type !== 'smtp') { if (!platform.url || !this.isValidUrl(platform.url)) { throw new Error(`无效的webhook URL: ${platform.url}`) } @@ -201,6 +202,51 @@ class WebhookConfigService { logger.warn('⚠️ Bark点击跳转URL格式可能不正确') } break + case 'smtp': { + // 验证SMTP必需配置 + if (!platform.host) { + throw new Error('SMTP平台必须提供主机地址') + } + if (!platform.user) { + throw new Error('SMTP平台必须提供用户名') + } + if (!platform.pass) { + throw new Error('SMTP平台必须提供密码') + } + if (!platform.to) { + throw new Error('SMTP平台必须提供接收邮箱') + } + + // 验证端口 + if (platform.port && (platform.port < 1 || platform.port > 65535)) { + throw new Error('SMTP端口必须在1-65535之间') + } + + // 验证邮箱格式 + // 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name + const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + // 验证接收邮箱 + const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to] + for (const email of toEmails) { + // 提取实际邮箱地址(如果是 Name 格式) + const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email + if (!actualEmail || !simpleEmailRegex.test(actualEmail)) { + throw new Error(`无效的接收邮箱格式: ${email}`) + } + } + + // 验证发送邮箱(支持 Name 格式) + if (platform.from) { + const actualFromEmail = platform.from.includes('<') + ? platform.from.match(/<([^>]+)>/)?.[1] + : platform.from + if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) { + throw new Error(`无效的发送邮箱格式: ${platform.from}`) + } + } + break + } } } diff --git a/src/services/webhookService.js b/src/services/webhookService.js index c0d049c5..4aa66cd9 100755 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -1,5 +1,6 @@ const axios = require('axios') const crypto = require('crypto') +const nodemailer = require('nodemailer') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') const { getISOStringWithTimezone } = require('../utils/dateHelper') @@ -14,7 +15,8 @@ class WebhookService { slack: this.sendToSlack.bind(this), discord: this.sendToDiscord.bind(this), custom: this.sendToCustom.bind(this), - bark: this.sendToBark.bind(this) + bark: this.sendToBark.bind(this), + smtp: this.sendToSMTP.bind(this) } this.timezone = appConfig.system.timezone || 'Asia/Shanghai' } @@ -243,6 +245,51 @@ class WebhookService { await this.sendHttpRequest(url, payload, platform.timeout || 10000) } + /** + * SMTP邮件通知 + */ + async sendToSMTP(platform, type, data) { + try { + // 创建SMTP传输器 + const transporter = nodemailer.createTransport({ + host: platform.host, + port: platform.port || 587, + secure: platform.secure || false, // true for 465, false for other ports + auth: { + user: platform.user, + pass: platform.pass + }, + // 可选的TLS配置 + tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined, + // 连接超时 + connectionTimeout: platform.timeout || 10000 + }) + + // 构造邮件内容 + const subject = this.getNotificationTitle(type) + const htmlContent = this.formatMessageForEmail(type, data) + const textContent = this.formatMessageForEmailText(type, data) + + // 邮件选项 + const mailOptions = { + from: platform.from || platform.user, // 发送者 + to: platform.to, // 接收者(必填) + subject: `[Claude Relay Service] ${subject}`, + text: textContent, + html: htmlContent + } + + // 发送邮件 + const info = await transporter.sendMail(mailOptions) + logger.info(`✅ 邮件发送成功: ${info.messageId}`) + + return info + } catch (error) { + logger.error('SMTP邮件发送失败:', error) + throw error + } + } + /** * 发送HTTP请求 */ @@ -459,6 +506,121 @@ class WebhookService { return lines.join('\n') } + /** + * 构建通知详情数据 + */ + buildNotificationDetails(data) { + const details = [] + + if (data.accountName) { + details.push({ label: '账号', value: data.accountName }) + } + if (data.platform) { + details.push({ label: '平台', value: data.platform }) + } + if (data.status) { + details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) }) + } + if (data.errorCode) { + details.push({ label: '错误代码', value: data.errorCode, isCode: true }) + } + if (data.reason) { + details.push({ label: '原因', value: data.reason }) + } + if (data.message) { + details.push({ label: '消息', value: data.message }) + } + if (data.quota) { + details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` }) + } + if (data.usage) { + details.push({ label: '使用率', value: `${data.usage}%` }) + } + + return details + } + + /** + * 格式化邮件HTML内容 + */ + formatMessageForEmail(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = ` +
+
+

${title}

+

Claude Relay Service

+
+
+
+ ` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + if (detail.isCode) { + content += `

${detail.label}: ${detail.value}

` + } else if (detail.color) { + content += `

${detail.label}: ${detail.value}

` + } else { + content += `

${detail.label}: ${detail.value}

` + } + }) + + content += ` +
+
+

发送时间: ${timestamp}

+

此邮件由 Claude Relay Service 自动发送

+
+
+
+ ` + + return content + } + + /** + * 格式化邮件纯文本内容 + */ + formatMessageForEmailText(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = `${title}\n` + content += `=====================================\n\n` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + content += `${detail.label}: ${detail.value}\n` + }) + + content += `\n发送时间: ${timestamp}\n` + content += `服务: Claude Relay Service\n` + content += `=====================================\n` + content += `此邮件由系统自动发送,请勿回复。` + + return content + } + + /** + * 获取状态颜色 + */ + getStatusColor(status) { + const colors = { + error: '#dc3545', + unauthorized: '#fd7e14', + blocked: '#6f42c1', + disabled: '#6c757d', + active: '#28a745', + warning: '#ffc107' + } + return colors[status] || '#007bff' + } + /** * 格式化通知详情 */ diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 7c1d4613..7a8719df 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -276,12 +276,12 @@
{{ group.name }} diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 3fd7b3ad..4c6355c5 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -377,9 +377,7 @@ >
-

- 启用 Webhook 通知 -

+

启用通知

开启后,系统将按配置发送通知到指定平台

@@ -471,10 +469,22 @@
-
+
{{ platform.url }}
+
+ + {{ + Array.isArray(platform.to) ? platform.to.join(', ') : platform.to + }} +
🟣 Slack +
@@ -684,8 +695,8 @@ />
- -
+ +
+ +
+ +
+ + +
+ + +
+
+ + +

+ 默认: 587 (TLS) 或 465 (SSL) +

+
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +

+ 建议使用应用专用密码,而非邮箱登录密码 +

+
+ + +
+ + +
+ + +
+ + +

接收通知的邮箱地址

+
+
+
{ if (platformForm.value.type === 'bark') { // Bark平台需要deviceKey return !!platformForm.value.deviceKey + } else if (platformForm.value.type === 'smtp') { + // SMTP平台需要必要的配置 + return !!( + platformForm.value.host && + platformForm.value.user && + platformForm.value.pass && + platformForm.value.to + ) } else { // 其他平台需要URL且URL格式正确 return !!platformForm.value.url && !urlError.value @@ -1134,8 +1335,8 @@ const saveWebhookConfig = async () => { // 验证 URL const validateUrl = () => { - // Bark平台不需要验证URL - if (platformForm.value.type === 'bark') { + // Bark和SMTP平台不需要验证URL + if (platformForm.value.type === 'bark' || platformForm.value.type === 'smtp') { urlError.value = false urlValid.value = false return @@ -1163,27 +1364,46 @@ const validateUrl = () => { } } -// 添加/更新平台 -const savePlatform = async () => { - if (!isMounted.value) return - - // Bark平台只需要deviceKey,其他平台需要URL +// 验证平台配置 +const validatePlatformForm = () => { if (platformForm.value.type === 'bark') { if (!platformForm.value.deviceKey) { showToast('请输入Bark设备密钥', 'error') - return + return false + } + } else if (platformForm.value.type === 'smtp') { + const requiredFields = [ + { field: 'host', message: 'SMTP服务器' }, + { field: 'user', message: '用户名' }, + { field: 'pass', message: '密码' }, + { field: 'to', message: '收件人邮箱' } + ] + + for (const { field, message } of requiredFields) { + if (!platformForm.value[field]) { + showToast(`请输入${message}`, 'error') + return false + } } } else { if (!platformForm.value.url) { showToast('请输入Webhook URL', 'error') - return + return false } - if (urlError.value) { showToast('请输入有效的Webhook URL', 'error') - return + return false } } + return true +} + +// 添加/更新平台 +const savePlatform = async () => { + if (!isMounted.value) return + + // 验证表单 + if (!validatePlatformForm()) return savingPlatform.value = true try { @@ -1292,6 +1512,15 @@ const testPlatform = async (platform) => { testData.level = platform.level testData.sound = platform.sound testData.group = platform.group + } else if (platform.type === 'smtp') { + testData.host = platform.host + testData.port = platform.port + testData.secure = platform.secure + testData.user = platform.user + testData.pass = platform.pass + testData.from = platform.from + testData.to = platform.to + testData.ignoreTLS = platform.ignoreTLS } else { testData.url = platform.url } @@ -1300,7 +1529,7 @@ const testPlatform = async (platform) => { signal: abortController.value.signal }) if (response.success && isMounted.value) { - showToast('测试成功,webhook连接正常', 'success') + showToast('测试成功', 'success') } } catch (error) { if (error.name === 'AbortError') return @@ -1314,24 +1543,8 @@ const testPlatform = async (platform) => { const testPlatformForm = async () => { if (!isMounted.value) return - // Bark平台验证 - if (platformForm.value.type === 'bark') { - if (!platformForm.value.deviceKey) { - showToast('请先输入Bark设备密钥', 'error') - return - } - } else { - // 其他平台验证URL - if (!platformForm.value.url) { - showToast('请先输入Webhook URL', 'error') - return - } - - if (urlError.value) { - showToast('请输入有效的Webhook URL', 'error') - return - } - } + // 验证表单 + if (!validatePlatformForm()) return testingConnection.value = true try { @@ -1339,7 +1552,7 @@ const testPlatformForm = async () => { signal: abortController.value.signal }) if (response.success && isMounted.value) { - showToast('测试成功,webhook连接正常', 'success') + showToast('测试成功', 'success') } } catch (error) { if (error.name === 'AbortError') return @@ -1397,7 +1610,17 @@ const closePlatformModal = () => { serverUrl: '', level: '', sound: '', - group: '' + group: '', + // SMTP特有字段 + host: '', + port: null, + secure: false, + user: '', + pass: '', + from: '', + to: '', + timeout: null, + ignoreTLS: false } urlError.value = false urlValid.value = false @@ -1415,6 +1638,7 @@ const getPlatformName = (type) => { slack: 'Slack', discord: 'Discord', bark: 'Bark', + smtp: '邮件通知', custom: '自定义' } return names[type] || type @@ -1428,6 +1652,7 @@ const getPlatformIcon = (type) => { slack: 'fab fa-slack text-purple-600', discord: 'fab fa-discord text-indigo-600', bark: 'fas fa-bell text-orange-500', + smtp: 'fas fa-envelope text-blue-600', custom: 'fas fa-webhook text-gray-600' } return icons[type] || 'fas fa-bell' @@ -1441,6 +1666,7 @@ const getWebhookHint = (type) => { slack: '请在Slack应用的Incoming Webhooks中获取地址', discord: '请在Discord服务器的集成设置中创建Webhook', bark: '请在Bark App中查看您的设备密钥', + smtp: '请配置SMTP服务器信息,支持Gmail、QQ邮箱等', custom: '请输入完整的Webhook接收地址' } return hints[type] || '' @@ -1451,7 +1677,8 @@ const getNotificationTypeName = (type) => { accountAnomaly: '账号异常', quotaWarning: '配额警告', systemError: '系统错误', - securityAlert: '安全警报' + securityAlert: '安全警报', + test: '测试通知' } return names[type] || type } @@ -1461,7 +1688,8 @@ const getNotificationTypeDescription = (type) => { accountAnomaly: '账号状态异常、认证失败等', quotaWarning: 'API调用配额不足警告', systemError: '系统运行错误和故障', - securityAlert: '安全相关的警报通知' + securityAlert: '安全相关的警报通知', + test: '用于测试Webhook连接是否正常' } return descriptions[type] || '' }