From b426a759a8b815224771e4cd4b54de32ea6ca63d Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 23 Aug 2025 20:20:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AEwebhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 87 ++ src/routes/api.js | 99 ++ src/routes/webhook.js | 250 +++- src/services/claudeAccountService.js | 16 + src/services/claudeConsoleAccountService.js | 16 + src/services/claudeConsoleRelayService.js | 11 +- src/services/claudeRelayService.js | 10 +- src/services/webhookConfigService.js | 272 ++++ src/services/webhookService.js | 495 +++++++ src/utils/webhookNotifier.js | 102 +- .../src/components/accounts/AccountForm.vue | 3 +- .../src/components/layout/MainLayout.vue | 78 +- .../src/components/layout/TabBar.vue | 2 +- web/admin-spa/src/views/SettingsView.vue | 1255 ++++++++++++++--- 14 files changed, 2319 insertions(+), 377 deletions(-) create mode 100644 src/services/webhookConfigService.js create mode 100644 src/services/webhookService.js diff --git a/src/routes/admin.js b/src/routes/admin.js index 90e014a4..86556904 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -14,6 +14,7 @@ const oauthHelper = require('../utils/oauthHelper') const CostCalculator = require('../utils/costCalculator') const pricingService = require('../services/pricingService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const webhookNotifier = require('../utils/webhookNotifier') const axios = require('axios') const crypto = require('crypto') const fs = require('fs') @@ -1736,6 +1737,19 @@ router.put( const newSchedulable = !account.schedulable await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || account.claudeAiOauth?.email || 'Claude Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -2006,6 +2020,19 @@ router.put( const newSchedulable = !account.schedulable await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -2280,6 +2307,19 @@ router.put( .json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) } + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: accountResult.data.id, + accountName: accountResult.data.name || 'Bedrock Account', + platform: 'bedrock', + status: 'disabled', + errorCode: 'BEDROCK_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -2651,6 +2691,19 @@ router.put( const updatedAccount = await geminiAccountService.getAccount(accountId) const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable + // 如果账号被禁用,发送webhook通知 + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.accountName || 'Gemini Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + logger.success( `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}` ) @@ -5212,6 +5265,23 @@ router.put( const result = await openaiAccountService.toggleSchedulable(accountId) + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await redis.getOpenAiAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'OpenAI Account', + platform: 'openai', + status: 'disabled', + errorCode: 'OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + return res.json({ success: result.success, schedulable: result.schedulable, @@ -5441,6 +5511,23 @@ router.put( const result = await azureOpenaiAccountService.toggleSchedulable(accountId) + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Azure OpenAI Account', + platform: 'azure-openai', + status: 'disabled', + errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + return res.json({ success: true, schedulable: result.schedulable, diff --git a/src/routes/api.js b/src/routes/api.js index 3b1c4160..bad90a41 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -671,4 +671,103 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re } }) +// 🔢 Token计数端点 - count_tokens beta API +router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + try { + // 检查权限 + if ( + req.apiKey.permissions && + req.apiKey.permissions !== 'all' && + req.apiKey.permissions !== 'claude' + ) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: 'This API key does not have permission to access Claude' + } + }) + } + + logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 选择可用的Claude账户 + const requestedModel = req.body.model + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + + let response + if (accountType === 'claude-official') { + // 使用官方Claude账号转发count_tokens请求 + response = await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else if (accountType === 'claude-console') { + // 使用Console Claude账号转发count_tokens请求 + response = await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else { + // Bedrock不支持count_tokens + return res.status(501).json({ + error: { + type: 'not_supported', + message: 'Token counting is not supported for Bedrock accounts' + } + }) + } + + // 直接返回响应,不记录token使用量 + res.status(response.statusCode) + + // 设置响应头 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + // 尝试解析并返回JSON响应 + try { + const jsonData = JSON.parse(response.body) + res.json(jsonData) + } catch (parseError) { + res.send(response.body) + } + + logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`) + } catch (error) { + logger.error('❌ Token count error:', error) + res.status(500).json({ + error: { + type: 'server_error', + message: 'Failed to count tokens' + } + }) + } +}) + module.exports = router diff --git a/src/routes/webhook.js b/src/routes/webhook.js index 5c3adcef..3f31802a 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -1,18 +1,125 @@ const express = require('express') const router = express.Router() const logger = require('../utils/logger') -const webhookNotifier = require('../utils/webhookNotifier') +const webhookService = require('../services/webhookService') +const webhookConfigService = require('../services/webhookConfigService') const { authenticateAdmin } = require('../middleware/auth') +// 获取webhook配置 +router.get('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('获取webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: '获取webhook配置失败' + }) + } +}) + +// 保存webhook配置 +router.post('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.saveConfig(req.body) + res.json({ + success: true, + message: 'Webhook配置已保存', + config + }) + } catch (error) { + logger.error('保存webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '保存webhook配置失败' + }) + } +}) + +// 添加webhook平台 +router.post('/platforms', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.addPlatform(req.body) + res.json({ + success: true, + message: 'Webhook平台已添加', + platform + }) + } catch (error) { + logger.error('添加webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '添加webhook平台失败' + }) + } +}) + +// 更新webhook平台 +router.put('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.updatePlatform(req.params.id, req.body) + res.json({ + success: true, + message: 'Webhook平台已更新', + platform + }) + } catch (error) { + logger.error('更新webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '更新webhook平台失败' + }) + } +}) + +// 删除webhook平台 +router.delete('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + await webhookConfigService.deletePlatform(req.params.id) + res.json({ + success: true, + message: 'Webhook平台已删除' + }) + } catch (error) { + logger.error('删除webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '删除webhook平台失败' + }) + } +}) + +// 切换webhook平台启用状态 +router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.togglePlatform(req.params.id) + res.json({ + success: true, + message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`, + platform + }) + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '切换webhook平台状态失败' + }) + } +}) + // 测试Webhook连通性 router.post('/test', authenticateAdmin, async (req, res) => { try { - const { url } = req.body + const { url, type = 'custom', secret, enableSign } = req.body if (!url) { return res.status(400).json({ error: 'Missing webhook URL', - message: 'Please provide a webhook URL to test' + message: '请提供webhook URL' }) } @@ -22,99 +129,144 @@ router.post('/test', authenticateAdmin, async (req, res) => { } catch (urlError) { return res.status(400).json({ error: 'Invalid URL format', - message: 'Please provide a valid webhook URL' + message: '请提供有效的webhook URL' }) } - logger.info(`🧪 Testing webhook URL: ${url}`) + logger.info(`🧪 测试webhook: ${type} - ${url}`) - const result = await webhookNotifier.testWebhook(url) + // 创建临时平台配置 + const platform = { + type, + url, + secret, + enableSign, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) if (result.success) { - logger.info(`✅ Webhook test successful for: ${url}`) + logger.info(`✅ Webhook测试成功: ${url}`) res.json({ success: true, - message: 'Webhook test successful', + message: 'Webhook测试成功', url }) } else { - logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`) + logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`) res.status(400).json({ success: false, - message: 'Webhook test failed', + message: 'Webhook测试失败', url, error: result.error }) } } catch (error) { - logger.error('❌ Webhook test error:', error) + logger.error('❌ Webhook测试错误:', error) res.status(500).json({ error: 'Internal server error', - message: 'Failed to test webhook' + message: '测试webhook失败' }) } }) -// 手动触发账号异常通知(用于测试) +// 手动触发测试通知 router.post('/test-notification', authenticateAdmin, async (req, res) => { try { const { + type = 'test', accountId = 'test-account-id', - accountName = 'Test Account', + accountName = '测试账号', platform = 'claude-oauth', - status = 'error', - errorCode = 'TEST_ERROR', - reason = 'Manual test notification' + status = 'test', + errorCode = 'TEST_NOTIFICATION', + reason = '手动测试通知', + message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作' } = req.body - logger.info(`🧪 Sending test notification for account: ${accountName}`) + logger.info(`🧪 发送测试通知: ${type}`) - await webhookNotifier.sendAccountAnomalyNotification({ + // 先检查webhook配置 + const config = await webhookConfigService.getConfig() + logger.debug( + `Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}` + ) + if (!config.enabled) { + return res.status(400).json({ + success: false, + message: 'Webhook通知未启用,请先在设置中启用通知功能' + }) + } + + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`) + + if (enabledPlatforms.length === 0) { + return res.status(400).json({ + success: false, + message: '没有启用的通知平台,请先添加并启用至少一个通知平台' + }) + } + + const testData = { accountId, accountName, platform, status, errorCode, - reason - }) + reason, + message, + timestamp: new Date().toISOString() + } - logger.info(`✅ Test notification sent successfully`) + const result = await webhookService.sendNotification(type, testData) + + // 如果没有返回结果,说明可能是配置问题 + if (!result) { + return res.status(400).json({ + success: false, + message: 'Webhook服务未返回结果,请检查配置和日志', + enabledPlatforms: enabledPlatforms.length + }) + } + + // 如果没有成功和失败的记录 + if (result.succeeded === 0 && result.failed === 0) { + return res.status(400).json({ + success: false, + message: '没有发送任何通知,请检查通知类型配置', + result, + enabledPlatforms: enabledPlatforms.length + }) + } + + if (result.failed > 0) { + logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`) + return res.json({ + success: true, + message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`, + data: testData, + result + }) + } + + logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`) res.json({ success: true, - message: 'Test notification sent successfully', - data: { - accountId, - accountName, - platform, - status, - errorCode, - reason - } + message: `测试通知已成功发送到 ${result.succeeded} 个平台`, + data: testData, + result }) } catch (error) { - logger.error('❌ Failed to send test notification:', error) + logger.error('❌ 发送测试通知失败:', error) res.status(500).json({ error: 'Internal server error', - message: 'Failed to send test notification' + message: `发送测试通知失败: ${error.message}` }) } }) -// 获取Webhook配置信息 -router.get('/config', authenticateAdmin, (req, res) => { - const config = require('../../config/config') - - res.json({ - success: true, - config: { - enabled: config.webhook?.enabled !== false, - urls: config.webhook?.urls || [], - timeout: config.webhook?.timeout || 10000, - retries: config.webhook?.retries || 3, - urlCount: (config.webhook?.urls || []).length - } - }) -}) - module.exports = router diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 7ef2c2d9..ffd390bd 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1087,6 +1087,22 @@ class ClaudeAccountService { logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) } + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', + reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + return { success: true } } catch (error) { logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 30b53fff..c2044895 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -366,6 +366,22 @@ class ClaudeConsoleAccountService { await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', + reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + logger.warn( `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` ) diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 99297787..dafb7f98 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -84,7 +84,16 @@ class ClaudeConsoleRelayService { // 构建完整的API URL const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 - const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index cb7949bd..49a9192a 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -591,10 +591,18 @@ class ClaudeRelayService { } return new Promise((resolve, reject) => { + // 支持自定义路径(如 count_tokens) + let requestPath = url.pathname + if (requestOptions.customPath) { + const baseUrl = new URL('https://api.anthropic.com') + const customUrl = new URL(requestOptions.customPath, baseUrl) + requestPath = customUrl.pathname + } + const options = { hostname: url.hostname, port: url.port || 443, - path: url.pathname, + path: requestPath, method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js new file mode 100644 index 00000000..18a460f6 --- /dev/null +++ b/src/services/webhookConfigService.js @@ -0,0 +1,272 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +class WebhookConfigService { + constructor() { + this.KEY_PREFIX = 'webhook_config' + this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default` + } + + /** + * 获取webhook配置 + */ + async getConfig() { + try { + const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY) + if (!configStr) { + // 返回默认配置 + return this.getDefaultConfig() + } + return JSON.parse(configStr) + } catch (error) { + logger.error('获取webhook配置失败:', error) + return this.getDefaultConfig() + } + } + + /** + * 保存webhook配置 + */ + async saveConfig(config) { + try { + // 验证配置 + this.validateConfig(config) + + // 添加更新时间 + config.updatedAt = new Date().toISOString() + + await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config)) + logger.info('✅ Webhook配置已保存') + + return config + } catch (error) { + logger.error('保存webhook配置失败:', error) + throw error + } + } + + /** + * 验证配置 + */ + validateConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + // 验证平台配置 + if (config.platforms) { + const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom'] + + for (const platform of config.platforms) { + if (!validPlatforms.includes(platform.type)) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + if (!platform.url || !this.isValidUrl(platform.url)) { + throw new Error(`无效的webhook URL: ${platform.url}`) + } + + // 验证平台特定的配置 + this.validatePlatformConfig(platform) + } + } + } + + /** + * 验证平台特定配置 + */ + validatePlatformConfig(platform) { + switch (platform.type) { + case 'wechat_work': + // 企业微信不需要额外配置 + break + case 'dingtalk': + // 钉钉可能需要secret用于签名 + if (platform.enableSign && !platform.secret) { + throw new Error('钉钉启用签名时必须提供secret') + } + break + case 'feishu': + // 飞书可能需要签名 + if (platform.enableSign && !platform.secret) { + throw new Error('飞书启用签名时必须提供secret') + } + break + case 'slack': + // Slack webhook URL通常包含token + if (!platform.url.includes('hooks.slack.com')) { + logger.warn('⚠️ Slack webhook URL格式可能不正确') + } + break + case 'discord': + // Discord webhook URL格式检查 + if (!platform.url.includes('discord.com/api/webhooks')) { + logger.warn('⚠️ Discord webhook URL格式可能不正确') + } + break + case 'custom': + // 自定义webhook,用户自行负责格式 + break + } + } + + /** + * 验证URL格式 + */ + isValidUrl(url) { + try { + new URL(url) + return true + } catch { + return false + } + } + + /** + * 获取默认配置 + */ + getDefaultConfig() { + return { + enabled: false, + platforms: [], + notificationTypes: { + accountAnomaly: true, // 账号异常 + quotaWarning: true, // 配额警告 + systemError: true, // 系统错误 + securityAlert: true, // 安全警报 + test: true // 测试通知 + }, + retrySettings: { + maxRetries: 3, + retryDelay: 1000, // 毫秒 + timeout: 10000 // 毫秒 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + } + + /** + * 添加webhook平台 + */ + async addPlatform(platform) { + try { + const config = await this.getConfig() + + // 生成唯一ID + platform.id = platform.id || uuidv4() + platform.enabled = platform.enabled !== false + platform.createdAt = new Date().toISOString() + + // 验证平台配置 + this.validatePlatformConfig(platform) + + // 添加到配置 + config.platforms = config.platforms || [] + config.platforms.push(platform) + + await this.saveConfig(config) + + return platform + } catch (error) { + logger.error('添加webhook平台失败:', error) + throw error + } + } + + /** + * 更新webhook平台 + */ + async updatePlatform(platformId, updates) { + try { + const config = await this.getConfig() + + const index = config.platforms.findIndex((p) => p.id === platformId) + if (index === -1) { + throw new Error('找不到指定的webhook平台') + } + + // 合并更新 + config.platforms[index] = { + ...config.platforms[index], + ...updates, + updatedAt: new Date().toISOString() + } + + // 验证更新后的配置 + this.validatePlatformConfig(config.platforms[index]) + + await this.saveConfig(config) + + return config.platforms[index] + } catch (error) { + logger.error('更新webhook平台失败:', error) + throw error + } + } + + /** + * 删除webhook平台 + */ + async deletePlatform(platformId) { + try { + const config = await this.getConfig() + + config.platforms = config.platforms.filter((p) => p.id !== platformId) + + await this.saveConfig(config) + + logger.info(`✅ 已删除webhook平台: ${platformId}`) + return true + } catch (error) { + logger.error('删除webhook平台失败:', error) + throw error + } + } + + /** + * 切换webhook平台启用状态 + */ + async togglePlatform(platformId) { + try { + const config = await this.getConfig() + + const platform = config.platforms.find((p) => p.id === platformId) + if (!platform) { + throw new Error('找不到指定的webhook平台') + } + + platform.enabled = !platform.enabled + platform.updatedAt = new Date().toISOString() + + await this.saveConfig(config) + + logger.info(`✅ Webhook平台 ${platformId} 已${platform.enabled ? '启用' : '禁用'}`) + return platform + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + throw error + } + } + + /** + * 获取启用的平台列表 + */ + async getEnabledPlatforms() { + try { + const config = await this.getConfig() + + if (!config.enabled || !config.platforms) { + return [] + } + + return config.platforms.filter((p) => p.enabled) + } catch (error) { + logger.error('获取启用的webhook平台失败:', error) + return [] + } + } +} + +module.exports = new WebhookConfigService() diff --git a/src/services/webhookService.js b/src/services/webhookService.js new file mode 100644 index 00000000..ad2778ff --- /dev/null +++ b/src/services/webhookService.js @@ -0,0 +1,495 @@ +const axios = require('axios') +const crypto = require('crypto') +const logger = require('../utils/logger') +const webhookConfigService = require('./webhookConfigService') + +class WebhookService { + constructor() { + this.platformHandlers = { + wechat_work: this.sendToWechatWork.bind(this), + dingtalk: this.sendToDingTalk.bind(this), + feishu: this.sendToFeishu.bind(this), + slack: this.sendToSlack.bind(this), + discord: this.sendToDiscord.bind(this), + custom: this.sendToCustom.bind(this) + } + } + + /** + * 发送通知到所有启用的平台 + */ + async sendNotification(type, data) { + try { + const config = await webhookConfigService.getConfig() + + // 检查是否启用webhook + if (!config.enabled) { + logger.debug('Webhook通知已禁用') + return + } + + // 检查通知类型是否启用(test类型始终允许发送) + if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) { + logger.debug(`通知类型 ${type} 已禁用`) + return + } + + // 获取启用的平台 + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + if (enabledPlatforms.length === 0) { + logger.debug('没有启用的webhook平台') + return + } + + logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`) + + // 并发发送到所有平台 + const promises = enabledPlatforms.map((platform) => + this.sendToPlatform(platform, type, data, config.retrySettings) + ) + + const results = await Promise.allSettled(promises) + + // 记录结果 + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed > 0) { + logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`) + } else { + logger.info(`✅ 所有webhook通知发送成功`) + } + + return { succeeded, failed } + } catch (error) { + logger.error('发送webhook通知失败:', error) + throw error + } + } + + /** + * 发送到特定平台 + */ + async sendToPlatform(platform, type, data, retrySettings) { + try { + const handler = this.platformHandlers[platform.type] + if (!handler) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // 使用平台特定的处理器 + await this.retryWithBackoff( + () => handler(platform, type, data), + retrySettings?.maxRetries || 3, + retrySettings?.retryDelay || 1000 + ) + + logger.info(`✅ 成功发送到 ${platform.name || platform.type}`) + } catch (error) { + logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message) + throw error + } + } + + /** + * 企业微信webhook + */ + async sendToWechatWork(platform, type, data) { + const content = this.formatMessageForWechatWork(type, data) + + const payload = { + msgtype: 'markdown', + markdown: { + content + } + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 钉钉webhook + */ + async sendToDingTalk(platform, type, data) { + const content = this.formatMessageForDingTalk(type, data) + + let { url } = platform + const payload = { + msgtype: 'markdown', + markdown: { + title: this.getNotificationTitle(type), + text: content + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Date.now() + const sign = this.generateDingTalkSign(platform.secret, timestamp) + url = `${url}×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` + } + + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * 飞书webhook + */ + async sendToFeishu(platform, type, data) { + const content = this.formatMessageForFeishu(type, data) + + const payload = { + msg_type: 'interactive', + card: { + elements: [ + { + tag: 'markdown', + content + } + ], + header: { + title: { + tag: 'plain_text', + content: this.getNotificationTitle(type) + }, + template: this.getFeishuCardColor(type) + } + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Math.floor(Date.now() / 1000) + const sign = this.generateFeishuSign(platform.secret, timestamp) + payload.timestamp = timestamp.toString() + payload.sign = sign + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Slack webhook + */ + async sendToSlack(platform, type, data) { + const text = this.formatMessageForSlack(type, data) + + const payload = { + text, + username: 'Claude Relay Service', + icon_emoji: this.getSlackEmoji(type) + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Discord webhook + */ + async sendToDiscord(platform, type, data) { + const embed = this.formatMessageForDiscord(type, data) + + const payload = { + username: 'Claude Relay Service', + embeds: [embed] + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 自定义webhook + */ + async sendToCustom(platform, type, data) { + // 使用通用格式 + const payload = { + type, + service: 'claude-relay-service', + timestamp: new Date().toISOString(), + data + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 发送HTTP请求 + */ + async sendHttpRequest(url, payload, timeout) { + const response = await axios.post(url, payload, { + timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-relay-service/2.0' + } + }) + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.data + } + + /** + * 重试机制 + */ + async retryWithBackoff(fn, maxRetries, baseDelay) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = baseDelay * Math.pow(2, i) // 指数退避 + logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + /** + * 生成钉钉签名 + */ + generateDingTalkSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', secret) + hmac.update(stringToSign) + return hmac.digest('base64') + } + + /** + * 生成飞书签名 + */ + generateFeishuSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + hmac.update('') + return hmac.digest('base64') + } + + /** + * 格式化企业微信消息 + */ + formatMessageForWechatWork(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return ( + `## ${title}\n\n` + + `> **服务**: Claude Relay Service\n` + + `> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + ) + } + + /** + * 格式化钉钉消息 + */ + formatMessageForDingTalk(type, data) { + const details = this.formatNotificationDetails(data) + + return ( + `#### 服务: Claude Relay Service\n` + + `#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + ) + } + + /** + * 格式化飞书消息 + */ + formatMessageForFeishu(type, data) { + return this.formatNotificationDetails(data) + } + + /** + * 格式化Slack消息 + */ + formatMessageForSlack(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return `*${title}*\n${details}` + } + + /** + * 格式化Discord消息 + */ + formatMessageForDiscord(type, data) { + const title = this.getNotificationTitle(type) + const color = this.getDiscordColor(type) + const fields = this.formatNotificationFields(data) + + return { + title, + color, + fields, + timestamp: new Date().toISOString(), + footer: { + text: 'Claude Relay Service' + } + } + } + + /** + * 获取通知标题 + */ + getNotificationTitle(type) { + const titles = { + accountAnomaly: '⚠️ 账号异常通知', + quotaWarning: '📊 配额警告', + systemError: '❌ 系统错误', + securityAlert: '🔒 安全警报', + test: '🧪 测试通知' + } + + return titles[type] || '📢 系统通知' + } + + /** + * 格式化通知详情 + */ + formatNotificationDetails(data) { + const lines = [] + + if (data.accountName) { + lines.push(`**账号**: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`**平台**: ${data.platform}`) + } + + if (data.status) { + lines.push(`**状态**: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`**错误代码**: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`**原因**: ${data.reason}`) + } + + if (data.message) { + lines.push(`**消息**: ${data.message}`) + } + + if (data.quota) { + lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`**使用率**: ${data.usage}%`) + } + + return lines.join('\n') + } + + /** + * 格式化Discord字段 + */ + formatNotificationFields(data) { + const fields = [] + + if (data.accountName) { + fields.push({ name: '账号', value: data.accountName, inline: true }) + } + + if (data.platform) { + fields.push({ name: '平台', value: data.platform, inline: true }) + } + + if (data.status) { + fields.push({ name: '状态', value: data.status, inline: true }) + } + + if (data.errorCode) { + fields.push({ name: '错误代码', value: data.errorCode, inline: false }) + } + + if (data.reason) { + fields.push({ name: '原因', value: data.reason, inline: false }) + } + + if (data.message) { + fields.push({ name: '消息', value: data.message, inline: false }) + } + + return fields + } + + /** + * 获取飞书卡片颜色 + */ + getFeishuCardColor(type) { + const colors = { + accountAnomaly: 'orange', + quotaWarning: 'yellow', + systemError: 'red', + securityAlert: 'red', + test: 'blue' + } + + return colors[type] || 'blue' + } + + /** + * 获取Slack emoji + */ + getSlackEmoji(type) { + const emojis = { + accountAnomaly: ':warning:', + quotaWarning: ':chart_with_downwards_trend:', + systemError: ':x:', + securityAlert: ':lock:', + test: ':test_tube:' + } + + return emojis[type] || ':bell:' + } + + /** + * 获取Discord颜色 + */ + getDiscordColor(type) { + const colors = { + accountAnomaly: 0xff9800, // 橙色 + quotaWarning: 0xffeb3b, // 黄色 + systemError: 0xf44336, // 红色 + securityAlert: 0xf44336, // 红色 + test: 0x2196f3 // 蓝色 + } + + return colors[type] || 0x9e9e9e // 灰色 + } + + /** + * 测试webhook连接 + */ + async testWebhook(platform) { + try { + const testData = { + message: 'Claude Relay Service webhook测试', + timestamp: new Date().toISOString() + } + + await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) + + return { success: true } + } catch (error) { + return { + success: false, + error: error.message + } + } + } +} + +module.exports = new WebhookService() diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index c95f3156..59c15147 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -1,13 +1,9 @@ -const axios = require('axios') const logger = require('./logger') -const config = require('../../config/config') +const webhookService = require('../services/webhookService') class WebhookNotifier { constructor() { - this.webhookUrls = config.webhook?.urls || [] - this.timeout = config.webhook?.timeout || 10000 - this.retries = config.webhook?.retries || 3 - this.enabled = config.webhook?.enabled !== false + // 保留此类用于兼容性,实际功能委托给webhookService } /** @@ -22,94 +18,40 @@ class WebhookNotifier { * @param {string} notification.timestamp - 时间戳 */ async sendAccountAnomalyNotification(notification) { - if (!this.enabled || this.webhookUrls.length === 0) { - logger.debug('Webhook notification disabled or no URLs configured') - return - } - - const payload = { - type: 'account_anomaly', - data: { + try { + // 使用新的webhookService发送通知 + await webhookService.sendNotification('accountAnomaly', { accountId: notification.accountId, accountName: notification.accountName, platform: notification.platform, status: notification.status, - errorCode: notification.errorCode, + errorCode: + notification.errorCode || this._getErrorCode(notification.platform, notification.status), reason: notification.reason, - timestamp: notification.timestamp || new Date().toISOString(), - service: 'claude-relay-service' - } - } - - logger.info( - `📢 Sending account anomaly webhook notification: ${notification.accountName} (${notification.accountId}) - ${notification.status}` - ) - - const promises = this.webhookUrls.map((url) => this._sendWebhook(url, payload)) - - try { - await Promise.allSettled(promises) - } catch (error) { - logger.error('Failed to send webhook notifications:', error) - } - } - - /** - * 发送Webhook请求 - * @param {string} url - Webhook URL - * @param {Object} payload - 请求载荷 - */ - async _sendWebhook(url, payload, attempt = 1) { - try { - const response = await axios.post(url, payload, { - timeout: this.timeout, - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'claude-relay-service/webhook-notifier' - } + timestamp: notification.timestamp || new Date().toISOString() }) - - if (response.status >= 200 && response.status < 300) { - logger.info(`✅ Webhook sent successfully to ${url}`) - } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } } catch (error) { - logger.error( - `❌ Failed to send webhook to ${url} (attempt ${attempt}/${this.retries}):`, - error.message - ) - - // 重试机制 - if (attempt < this.retries) { - const delay = Math.pow(2, attempt - 1) * 1000 // 指数退避 - logger.info(`🔄 Retrying webhook to ${url} in ${delay}ms...`) - - await new Promise((resolve) => setTimeout(resolve, delay)) - return this._sendWebhook(url, payload, attempt + 1) - } - - logger.error(`💥 All ${this.retries} webhook attempts failed for ${url}`) + logger.error('Failed to send account anomaly notification:', error) } } /** - * 测试Webhook连通性 + * 测试Webhook连通性(兼容旧接口) * @param {string} url - Webhook URL + * @param {string} type - 平台类型(可选) */ - async testWebhook(url) { - const testPayload = { - type: 'test', - data: { - message: 'Claude Relay Service webhook test', - timestamp: new Date().toISOString(), - service: 'claude-relay-service' - } - } - + async testWebhook(url, type = 'custom') { try { - await this._sendWebhook(url, testPayload) - return { success: true } + // 创建临时平台配置 + const platform = { + type, + url, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) + return result } catch (error) { return { success: false, error: error.message } } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index ad481a03..0e0c290c 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -962,7 +962,8 @@ v-if=" (form.addType === 'oauth' || form.addType === 'setup-token') && form.platform !== 'claude-console' && - form.platform !== 'bedrock' + form.platform !== 'bedrock' && + form.platform !== 'azure_openai' " class="btn btn-primary flex-1 px-6 py-3 font-semibold" :disabled="loading" diff --git a/web/admin-spa/src/components/layout/MainLayout.vue b/web/admin-spa/src/components/layout/MainLayout.vue index 2c27ecf3..df4d4d20 100644 --- a/web/admin-spa/src/components/layout/MainLayout.vue +++ b/web/admin-spa/src/components/layout/MainLayout.vue @@ -13,20 +13,14 @@
- - - - - - - +
diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index ccd23ecd..91b16fa1 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -1,271 +1,649 @@