This commit is contained in:
KevinLiao
2025-08-14 16:44:20 +08:00
9 changed files with 365 additions and 12 deletions

View File

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

120
src/routes/webhook.js Normal file
View File

@@ -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
})
} else {
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
res.status(400).json({
success: false,
message: 'Webhook test failed',
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

View File

@@ -302,6 +302,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)
@@ -1554,6 +1569,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)

View File

@@ -403,6 +403,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,
@@ -412,6 +415,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
})
} 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)

View File

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

View File

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