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()