mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 支持后台配置webhook
This commit is contained in:
@@ -14,6 +14,7 @@ const oauthHelper = require('../utils/oauthHelper')
|
|||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
const pricingService = require('../services/pricingService')
|
const pricingService = require('../services/pricingService')
|
||||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
@@ -1736,6 +1737,19 @@ router.put(
|
|||||||
const newSchedulable = !account.schedulable
|
const newSchedulable = !account.schedulable
|
||||||
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
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(
|
logger.success(
|
||||||
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||||
)
|
)
|
||||||
@@ -2006,6 +2020,19 @@ router.put(
|
|||||||
const newSchedulable = !account.schedulable
|
const newSchedulable = !account.schedulable
|
||||||
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
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(
|
logger.success(
|
||||||
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
`🔄 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 })
|
.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(
|
logger.success(
|
||||||
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||||
)
|
)
|
||||||
@@ -2651,6 +2691,19 @@ router.put(
|
|||||||
const updatedAccount = await geminiAccountService.getAccount(accountId)
|
const updatedAccount = await geminiAccountService.getAccount(accountId)
|
||||||
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
|
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(
|
logger.success(
|
||||||
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}`
|
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||||
)
|
)
|
||||||
@@ -5212,6 +5265,23 @@ router.put(
|
|||||||
|
|
||||||
const result = await openaiAccountService.toggleSchedulable(accountId)
|
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({
|
return res.json({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
schedulable: result.schedulable,
|
schedulable: result.schedulable,
|
||||||
@@ -5441,6 +5511,23 @@ router.put(
|
|||||||
|
|
||||||
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
schedulable: result.schedulable,
|
schedulable: result.schedulable,
|
||||||
|
|||||||
@@ -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
|
module.exports = router
|
||||||
|
|||||||
@@ -1,18 +1,125 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const logger = require('../utils/logger')
|
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')
|
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连通性
|
// 测试Webhook连通性
|
||||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { url } = req.body
|
const { url, type = 'custom', secret, enableSign } = req.body
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Missing webhook URL',
|
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) {
|
} catch (urlError) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid URL format',
|
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) {
|
if (result.success) {
|
||||||
logger.info(`✅ Webhook test successful for: ${url}`)
|
logger.info(`✅ Webhook测试成功: ${url}`)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Webhook test successful',
|
message: 'Webhook测试成功',
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
|
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Webhook test failed',
|
message: 'Webhook测试失败',
|
||||||
url,
|
url,
|
||||||
error: result.error
|
error: result.error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Webhook test error:', error)
|
logger.error('❌ Webhook测试错误:', error)
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to test webhook'
|
message: '测试webhook失败'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 手动触发账号异常通知(用于测试)
|
// 手动触发测试通知
|
||||||
router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
|
type = 'test',
|
||||||
accountId = 'test-account-id',
|
accountId = 'test-account-id',
|
||||||
accountName = 'Test Account',
|
accountName = '测试账号',
|
||||||
platform = 'claude-oauth',
|
platform = 'claude-oauth',
|
||||||
status = 'error',
|
status = 'test',
|
||||||
errorCode = 'TEST_ERROR',
|
errorCode = 'TEST_NOTIFICATION',
|
||||||
reason = 'Manual test notification'
|
reason = '手动测试通知',
|
||||||
|
message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作'
|
||||||
} = req.body
|
} = 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,
|
accountId,
|
||||||
accountName,
|
accountName,
|
||||||
platform,
|
platform,
|
||||||
status,
|
status,
|
||||||
errorCode,
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Test notification sent successfully',
|
message: `测试通知已成功发送到 ${result.succeeded} 个平台`,
|
||||||
data: {
|
data: testData,
|
||||||
accountId,
|
result
|
||||||
accountName,
|
|
||||||
platform,
|
|
||||||
status,
|
|
||||||
errorCode,
|
|
||||||
reason
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to send test notification:', error)
|
logger.error('❌ 发送测试通知失败:', error)
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
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
|
module.exports = router
|
||||||
|
|||||||
@@ -1087,6 +1087,22 @@ class ClaudeAccountService {
|
|||||||
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`)
|
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 }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)
|
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)
|
||||||
|
|||||||
@@ -366,6 +366,22 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
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(
|
logger.warn(
|
||||||
`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`
|
`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,7 +84,16 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 构建完整的API URL
|
// 构建完整的API URL
|
||||||
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
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(`🎯 Final API endpoint: ${apiEndpoint}`)
|
||||||
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
|
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
|
||||||
|
|||||||
@@ -591,10 +591,18 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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 = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port || 443,
|
port: url.port || 443,
|
||||||
path: url.pathname,
|
path: requestPath,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
272
src/services/webhookConfigService.js
Normal file
272
src/services/webhookConfigService.js
Normal 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()
|
||||||
495
src/services/webhookService.js
Normal file
495
src/services/webhookService.js
Normal 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}×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()
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
const axios = require('axios')
|
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
const config = require('../../config/config')
|
const webhookService = require('../services/webhookService')
|
||||||
|
|
||||||
class WebhookNotifier {
|
class WebhookNotifier {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.webhookUrls = config.webhook?.urls || []
|
// 保留此类用于兼容性,实际功能委托给webhookService
|
||||||
this.timeout = config.webhook?.timeout || 10000
|
|
||||||
this.retries = config.webhook?.retries || 3
|
|
||||||
this.enabled = config.webhook?.enabled !== false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,94 +18,40 @@ class WebhookNotifier {
|
|||||||
* @param {string} notification.timestamp - 时间戳
|
* @param {string} notification.timestamp - 时间戳
|
||||||
*/
|
*/
|
||||||
async sendAccountAnomalyNotification(notification) {
|
async sendAccountAnomalyNotification(notification) {
|
||||||
if (!this.enabled || this.webhookUrls.length === 0) {
|
try {
|
||||||
logger.debug('Webhook notification disabled or no URLs configured')
|
// 使用新的webhookService发送通知
|
||||||
return
|
await webhookService.sendNotification('accountAnomaly', {
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: 'account_anomaly',
|
|
||||||
data: {
|
|
||||||
accountId: notification.accountId,
|
accountId: notification.accountId,
|
||||||
accountName: notification.accountName,
|
accountName: notification.accountName,
|
||||||
platform: notification.platform,
|
platform: notification.platform,
|
||||||
status: notification.status,
|
status: notification.status,
|
||||||
errorCode: notification.errorCode,
|
errorCode:
|
||||||
|
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
|
||||||
reason: notification.reason,
|
reason: notification.reason,
|
||||||
timestamp: notification.timestamp || new Date().toISOString(),
|
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) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error('Failed to send account anomaly notification:', 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连通性
|
* 测试Webhook连通性(兼容旧接口)
|
||||||
* @param {string} url - Webhook URL
|
* @param {string} url - Webhook URL
|
||||||
|
* @param {string} type - 平台类型(可选)
|
||||||
*/
|
*/
|
||||||
async testWebhook(url) {
|
async testWebhook(url, type = 'custom') {
|
||||||
const testPayload = {
|
try {
|
||||||
type: 'test',
|
// 创建临时平台配置
|
||||||
data: {
|
const platform = {
|
||||||
message: 'Claude Relay Service webhook test',
|
type,
|
||||||
timestamp: new Date().toISOString(),
|
url,
|
||||||
service: 'claude-relay-service'
|
enabled: true,
|
||||||
}
|
timeout: 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = await webhookService.testWebhook(platform)
|
||||||
await this._sendWebhook(url, testPayload)
|
return result
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -962,7 +962,8 @@
|
|||||||
v-if="
|
v-if="
|
||||||
(form.addType === 'oauth' || form.addType === 'setup-token') &&
|
(form.addType === 'oauth' || form.addType === 'setup-token') &&
|
||||||
form.platform !== 'claude-console' &&
|
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"
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
|||||||
@@ -13,20 +13,14 @@
|
|||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view />
|
||||||
<transition mode="out-in" name="slide-up">
|
|
||||||
<keep-alive :include="['DashboardView', 'ApiKeysView']">
|
|
||||||
<component :is="Component" />
|
|
||||||
</keep-alive>
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import TabBar from './TabBar.vue'
|
import TabBar from './TabBar.vue'
|
||||||
@@ -45,6 +39,35 @@ const tabRouteMap = {
|
|||||||
settings: '/settings'
|
settings: '/settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化当前激活的标签
|
||||||
|
const initActiveTab = () => {
|
||||||
|
const currentPath = route.path
|
||||||
|
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
|
||||||
|
|
||||||
|
if (tabKey) {
|
||||||
|
activeTab.value = tabKey
|
||||||
|
} else {
|
||||||
|
// 如果路径不匹配任何标签,尝试从路由名称获取
|
||||||
|
const routeName = route.name
|
||||||
|
const nameToTabMap = {
|
||||||
|
Dashboard: 'dashboard',
|
||||||
|
ApiKeys: 'apiKeys',
|
||||||
|
Accounts: 'accounts',
|
||||||
|
Tutorial: 'tutorial',
|
||||||
|
Settings: 'settings'
|
||||||
|
}
|
||||||
|
if (routeName && nameToTabMap[routeName]) {
|
||||||
|
activeTab.value = nameToTabMap[routeName]
|
||||||
|
} else {
|
||||||
|
// 默认选中仪表板
|
||||||
|
activeTab.value = 'dashboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
initActiveTab()
|
||||||
|
|
||||||
// 监听路由变化,更新激活的标签
|
// 监听路由变化,更新激活的标签
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
@@ -52,15 +75,46 @@ watch(
|
|||||||
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
|
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
|
||||||
if (tabKey) {
|
if (tabKey) {
|
||||||
activeTab.value = tabKey
|
activeTab.value = tabKey
|
||||||
|
} else {
|
||||||
|
// 如果路径不匹配任何标签,尝试从路由名称获取
|
||||||
|
const routeName = route.name
|
||||||
|
const nameToTabMap = {
|
||||||
|
Dashboard: 'dashboard',
|
||||||
|
ApiKeys: 'apiKeys',
|
||||||
|
Accounts: 'accounts',
|
||||||
|
Tutorial: 'tutorial',
|
||||||
|
Settings: 'settings'
|
||||||
|
}
|
||||||
|
if (routeName && nameToTabMap[routeName]) {
|
||||||
|
activeTab.value = nameToTabMap[routeName]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理标签切换
|
// 处理标签切换
|
||||||
const handleTabChange = (tabKey) => {
|
const handleTabChange = async (tabKey) => {
|
||||||
|
// 如果已经在目标路由,不需要做任何事
|
||||||
|
if (tabRouteMap[tabKey] === route.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先更新activeTab状态
|
||||||
activeTab.value = tabKey
|
activeTab.value = tabKey
|
||||||
router.push(tabRouteMap[tabKey])
|
|
||||||
|
// 使用 await 确保路由切换完成
|
||||||
|
try {
|
||||||
|
await router.push(tabRouteMap[tabKey])
|
||||||
|
// 等待下一个DOM更新周期,确保组件正确渲染
|
||||||
|
await nextTick()
|
||||||
|
} catch (err) {
|
||||||
|
// 如果路由切换失败,恢复activeTab状态
|
||||||
|
if (err.name !== 'NavigationDuplicated') {
|
||||||
|
console.error('路由切换失败:', err)
|
||||||
|
// 恢复到当前路由对应的tab
|
||||||
|
initActiveTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OEM设置已在App.vue中加载,无需重复加载
|
// OEM设置已在App.vue中加载,无需重复加载
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const tabs = [
|
|||||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
||||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
||||||
{ key: 'settings', name: '其他设置', shortName: '设置', icon: 'fas fa-cogs' }
|
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user