feat: 支持后台配置webhook

This commit is contained in:
shaw
2025-08-23 20:20:32 +08:00
parent 74bcb99142
commit b426a759a8
14 changed files with 2319 additions and 377 deletions

View File

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

View File

@@ -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})`
)

View File

@@ -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)}`)

View File

@@ -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',

View File

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

View File

@@ -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}&timestamp=${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()