mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:10:52 +00:00
Merge branch 'dev' into main
This commit is contained in:
@@ -33,6 +33,10 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
|||||||
CLAUDE_API_VERSION=2023-06-01
|
CLAUDE_API_VERSION=2023-06-01
|
||||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||||
|
|
||||||
|
# 🚫 529错误处理配置
|
||||||
|
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||||
|
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||||
|
|
||||||
# 🌐 代理配置
|
# 🌐 代理配置
|
||||||
DEFAULT_PROXY_TIMEOUT=600000
|
DEFAULT_PROXY_TIMEOUT=600000
|
||||||
MAX_PROXY_RETRIES=3
|
MAX_PROXY_RETRIES=3
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ const config = {
|
|||||||
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
|
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
|
||||||
betaHeader:
|
betaHeader:
|
||||||
process.env.CLAUDE_BETA_HEADER ||
|
process.env.CLAUDE_BETA_HEADER ||
|
||||||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
|
||||||
|
overloadHandling: {
|
||||||
|
enabled: (() => {
|
||||||
|
const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
|
||||||
|
// 验证配置值:限制在0-1440分钟(24小时)内
|
||||||
|
return Math.max(0, Math.min(minutes, 1440))
|
||||||
|
})()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ☁️ Bedrock API配置
|
// ☁️ Bedrock API配置
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
"socks-proxy-agent": "^8.0.2",
|
"socks-proxy-agent": "^8.0.2",
|
||||||
@@ -7077,6 +7078,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
"socks-proxy-agent": "^8.0.2",
|
"socks-proxy-agent": "^8.0.2",
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
|
|||||||
expiresAt: expiresAt || null,
|
expiresAt: expiresAt || null,
|
||||||
dailyCostLimit: dailyCostLimit || null,
|
dailyCostLimit: dailyCostLimit || null,
|
||||||
createdBy: 'user',
|
createdBy: 'user',
|
||||||
permissions: ['messages'] // 用户创建的API Key默认只有messages权限
|
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
|
||||||
|
permissions: 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
const newApiKey = await apiKeyService.createApiKey(apiKeyData)
|
const newApiKey = await apiKeyService.createApiKey(apiKeyData)
|
||||||
|
|||||||
@@ -124,7 +124,16 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
level,
|
level,
|
||||||
sound,
|
sound,
|
||||||
group
|
group,
|
||||||
|
// SMTP 相关字段
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
ignoreTLS
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// Bark平台特殊处理
|
// Bark平台特殊处理
|
||||||
@@ -149,6 +158,34 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
|
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
|
||||||
|
} else if (type === 'smtp') {
|
||||||
|
// SMTP平台验证
|
||||||
|
if (!host) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing SMTP host',
|
||||||
|
message: '请提供SMTP服务器地址'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing SMTP user',
|
||||||
|
message: '请提供SMTP用户名'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!pass) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing SMTP password',
|
||||||
|
message: '请提供SMTP密码'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!to) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing recipient email',
|
||||||
|
message: '请提供收件人邮箱'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
|
||||||
} else {
|
} else {
|
||||||
// 其他平台验证URL
|
// 其他平台验证URL
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -188,6 +225,16 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
|||||||
platform.level = level
|
platform.level = level
|
||||||
platform.sound = sound
|
platform.sound = sound
|
||||||
platform.group = group
|
platform.group = group
|
||||||
|
} else if (type === 'smtp') {
|
||||||
|
// 添加SMTP特有字段
|
||||||
|
platform.host = host
|
||||||
|
platform.port = port || 587
|
||||||
|
platform.secure = secure || false
|
||||||
|
platform.user = user
|
||||||
|
platform.pass = pass
|
||||||
|
platform.from = from
|
||||||
|
platform.to = to
|
||||||
|
platform.ignoreTLS = ignoreTLS || false
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await webhookService.testWebhook(platform)
|
const result = await webhookService.testWebhook(platform)
|
||||||
|
|||||||
@@ -2137,6 +2137,108 @@ class ClaudeAccountService {
|
|||||||
logger.error(`❌ Failed to update session window status for account ${accountId}:`, error)
|
logger.error(`❌ Failed to update session window status for account ${accountId}:`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账号为过载状态(529错误)
|
||||||
|
async markAccountOverloaded(accountId) {
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置的过载处理时间(分钟)
|
||||||
|
const overloadMinutes = config.overloadHandling?.enabled || 0
|
||||||
|
|
||||||
|
if (overloadMinutes === 0) {
|
||||||
|
logger.info('⏭️ 529 error handling is disabled')
|
||||||
|
return { success: false, error: '529 error handling is disabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const overloadKey = `account:overload:${accountId}`
|
||||||
|
const ttl = overloadMinutes * 60 // 转换为秒
|
||||||
|
|
||||||
|
await redis.setex(
|
||||||
|
overloadKey,
|
||||||
|
ttl,
|
||||||
|
JSON.stringify({
|
||||||
|
accountId,
|
||||||
|
accountName: accountData.name,
|
||||||
|
markedAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在账号上记录最后一次529错误
|
||||||
|
const updates = {
|
||||||
|
lastOverloadAt: new Date().toISOString(),
|
||||||
|
errorMessage: `529错误 - 过载${overloadMinutes}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAccountData = { ...accountData, ...updates }
|
||||||
|
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||||
|
|
||||||
|
return { success: true, accountName: accountData.name, duration: overloadMinutes }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error)
|
||||||
|
// 不抛出错误,避免影响主请求流程
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 检查账号是否过载
|
||||||
|
async isAccountOverloaded(accountId) {
|
||||||
|
try {
|
||||||
|
// 如果529处理未启用,直接返回false
|
||||||
|
const overloadMinutes = config.overloadHandling?.enabled || 0
|
||||||
|
if (overloadMinutes === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const overloadKey = `account:overload:${accountId}`
|
||||||
|
const overloadData = await redis.get(overloadKey)
|
||||||
|
|
||||||
|
if (overloadData) {
|
||||||
|
// 账号处于过载状态
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账号未过载
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 移除账号的过载状态
|
||||||
|
async removeAccountOverload(accountId) {
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const overloadKey = `account:overload:${accountId}`
|
||||||
|
await redis.del(overloadKey)
|
||||||
|
|
||||||
|
logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`)
|
||||||
|
|
||||||
|
// 清理账号上的错误信息
|
||||||
|
if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) {
|
||||||
|
const updatedAccountData = { ...accountData }
|
||||||
|
delete updatedAccountData.errorMessage
|
||||||
|
delete updatedAccountData.lastOverloadAt
|
||||||
|
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error)
|
||||||
|
// 不抛出错误,移除过载状态失败不应该影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeAccountService()
|
module.exports = new ClaudeAccountService()
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ class ClaudeConsoleRelayService {
|
|||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
let abortController = null
|
let abortController = null
|
||||||
|
let account = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Claude Console Claude account not found')
|
throw new Error('Claude Console Claude account not found')
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,10 @@ class ClaudeConsoleRelayService {
|
|||||||
throw new Error('Client disconnected')
|
throw new Error('Client disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('❌ Claude Console Claude relay request failed:', error.message)
|
logger.error(
|
||||||
|
`❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`,
|
||||||
|
error.message
|
||||||
|
)
|
||||||
|
|
||||||
// 不再因为模型不支持而block账号
|
// 不再因为模型不支持而block账号
|
||||||
|
|
||||||
@@ -241,9 +245,10 @@ class ClaudeConsoleRelayService {
|
|||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
|
let account = null
|
||||||
try {
|
try {
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Claude Console Claude account not found')
|
throw new Error('Claude Console Claude account not found')
|
||||||
}
|
}
|
||||||
@@ -297,7 +302,10 @@ class ClaudeConsoleRelayService {
|
|||||||
// 更新最后使用时间
|
// 更新最后使用时间
|
||||||
await this._updateLastUsedTime(accountId)
|
await this._updateLastUsedTime(accountId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Claude Console Claude stream relay failed:', error)
|
logger.error(
|
||||||
|
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||||
|
error
|
||||||
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,7 +384,9 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
logger.error(`❌ Claude Console API returned error status: ${response.status}`)
|
logger.error(
|
||||||
|
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
@@ -528,7 +538,10 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Error processing Claude Console stream data:', error)
|
logger.error(
|
||||||
|
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
||||||
|
error
|
||||||
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
responseStream.write('event: error\n')
|
responseStream.write('event: error\n')
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -570,7 +583,10 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('error', (error) => {
|
response.data.on('error', (error) => {
|
||||||
logger.error('❌ Claude Console stream error:', error)
|
logger.error(
|
||||||
|
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
||||||
|
error
|
||||||
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
responseStream.write('event: error\n')
|
responseStream.write('event: error\n')
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -590,7 +606,10 @@ class ClaudeConsoleRelayService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('❌ Claude Console Claude stream request error:', error.message)
|
logger.error(
|
||||||
|
`❌ Claude Console stream request error (Account: ${account?.name || accountId}):`,
|
||||||
|
error.message
|
||||||
|
)
|
||||||
|
|
||||||
// 检查错误状态
|
// 检查错误状态
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|||||||
@@ -208,6 +208,24 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
}
|
}
|
||||||
|
// 检查是否为529状态码(服务过载)
|
||||||
|
else if (response.statusCode === 529) {
|
||||||
|
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
|
||||||
|
|
||||||
|
// 检查是否启用了529错误处理
|
||||||
|
if (config.claude.overloadHandling.enabled > 0) {
|
||||||
|
try {
|
||||||
|
await claudeAccountService.markAccountOverloaded(accountId)
|
||||||
|
logger.info(
|
||||||
|
`🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
|
||||||
|
)
|
||||||
|
} catch (overloadError) {
|
||||||
|
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 检查是否为5xx状态码
|
// 检查是否为5xx状态码
|
||||||
else if (response.statusCode >= 500 && response.statusCode < 600) {
|
else if (response.statusCode >= 500 && response.statusCode < 600) {
|
||||||
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
|
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
|
||||||
@@ -296,6 +314,19 @@ class ClaudeRelayService {
|
|||||||
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果请求成功,检查并移除过载状态
|
||||||
|
try {
|
||||||
|
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||||
|
if (isOverloaded) {
|
||||||
|
await claudeAccountService.removeAccountOverload(accountId)
|
||||||
|
}
|
||||||
|
} catch (overloadError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to check/remove overload status for account ${accountId}:`,
|
||||||
|
overloadError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers
|
// 只有真实的 Claude Code 请求才更新 headers
|
||||||
if (
|
if (
|
||||||
clientHeaders &&
|
clientHeaders &&
|
||||||
@@ -741,7 +772,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
resolve(response)
|
resolve(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to parse Claude API response:', error)
|
logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error)
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -754,7 +785,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
req.on('error', async (error) => {
|
req.on('error', async (error) => {
|
||||||
console.error(': ❌ ', error)
|
console.error(': ❌ ', error)
|
||||||
logger.error('❌ Claude API request error:', error.message, {
|
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
errno: error.errno,
|
errno: error.errno,
|
||||||
syscall: error.syscall,
|
syscall: error.syscall,
|
||||||
@@ -781,7 +812,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
req.on('timeout', async () => {
|
req.on('timeout', async () => {
|
||||||
req.destroy()
|
req.destroy()
|
||||||
logger.error('❌ Claude API request timeout')
|
logger.error(`❌ Claude API request timeout (Account: ${accountId})`)
|
||||||
|
|
||||||
await this._handleServerError(accountId, 504, null, 'Request')
|
await this._handleServerError(accountId, 504, null, 'Request')
|
||||||
|
|
||||||
@@ -889,7 +920,7 @@ class ClaudeRelayService {
|
|||||||
options
|
options
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Claude stream relay with usage capture failed:', error)
|
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1033,27 @@ class ClaudeRelayService {
|
|||||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||||
)
|
)
|
||||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
|
} else if (res.statusCode === 529) {
|
||||||
|
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
|
||||||
|
|
||||||
|
// 检查是否启用了529错误处理
|
||||||
|
if (config.claude.overloadHandling.enabled > 0) {
|
||||||
|
try {
|
||||||
|
await claudeAccountService.markAccountOverloaded(accountId)
|
||||||
|
logger.info(
|
||||||
|
`🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
|
||||||
|
)
|
||||||
|
} catch (overloadError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ [Stream] Failed to mark account as overloaded: ${accountId}`,
|
||||||
|
overloadError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`🚫 [Stream] 529 error handling is disabled, skipping account overload marking`
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (res.statusCode >= 500 && res.statusCode < 600) {
|
} else if (res.statusCode >= 500 && res.statusCode < 600) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
|
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
|
||||||
@@ -1015,7 +1067,9 @@ class ClaudeRelayService {
|
|||||||
logger.error('❌ Error in stream error handler:', err)
|
logger.error('❌ Error in stream error handler:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.error(`❌ Claude API returned error status: ${res.statusCode}`)
|
logger.error(
|
||||||
|
`❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
let errorData = ''
|
let errorData = ''
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
res.on('data', (chunk) => {
|
||||||
@@ -1024,7 +1078,10 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
console.error(': ❌ ', errorData)
|
console.error(': ❌ ', errorData)
|
||||||
logger.error('❌ Claude API error response:', errorData)
|
logger.error(
|
||||||
|
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||||
|
errorData
|
||||||
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
// 发送错误事件
|
// 发送错误事件
|
||||||
responseStream.write('event: error\n')
|
responseStream.write('event: error\n')
|
||||||
@@ -1327,6 +1384,19 @@ class ClaudeRelayService {
|
|||||||
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果流式请求成功,检查并移除过载状态
|
||||||
|
try {
|
||||||
|
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||||
|
if (isOverloaded) {
|
||||||
|
await claudeAccountService.removeAccountOverload(accountId)
|
||||||
|
}
|
||||||
|
} catch (overloadError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ [Stream] Failed to check/remove overload status for account ${accountId}:`,
|
||||||
|
overloadError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||||
if (
|
if (
|
||||||
clientHeaders &&
|
clientHeaders &&
|
||||||
@@ -1343,11 +1413,15 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
req.on('error', async (error) => {
|
req.on('error', async (error) => {
|
||||||
logger.error('❌ Claude stream request error:', error.message, {
|
logger.error(
|
||||||
code: error.code,
|
`❌ Claude stream request error (Account: ${account?.name || accountId}):`,
|
||||||
errno: error.errno,
|
error.message,
|
||||||
syscall: error.syscall
|
{
|
||||||
})
|
code: error.code,
|
||||||
|
errno: error.errno,
|
||||||
|
syscall: error.syscall
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 根据错误类型提供更具体的错误信息
|
// 根据错误类型提供更具体的错误信息
|
||||||
let errorMessage = 'Upstream request failed'
|
let errorMessage = 'Upstream request failed'
|
||||||
@@ -1391,7 +1465,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
req.on('timeout', async () => {
|
req.on('timeout', async () => {
|
||||||
req.destroy()
|
req.destroy()
|
||||||
logger.error('❌ Claude stream request timeout')
|
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`)
|
||||||
|
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
responseStream.writeHead(504, {
|
responseStream.writeHead(504, {
|
||||||
@@ -1493,7 +1567,7 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
req.on('error', async (error) => {
|
req.on('error', async (error) => {
|
||||||
logger.error('❌ Claude stream request error:', error.message, {
|
logger.error(`❌ Claude stream request error:`, error.message, {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
errno: error.errno,
|
errno: error.errno,
|
||||||
syscall: error.syscall
|
syscall: error.syscall
|
||||||
@@ -1541,7 +1615,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
req.on('timeout', async () => {
|
req.on('timeout', async () => {
|
||||||
req.destroy()
|
req.destroy()
|
||||||
logger.error('❌ Claude stream request timeout')
|
logger.error(`❌ Claude stream request timeout`)
|
||||||
|
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
responseStream.writeHead(504, {
|
responseStream.writeHead(504, {
|
||||||
|
|||||||
@@ -107,7 +107,12 @@ class UnifiedClaudeScheduler {
|
|||||||
|
|
||||||
// 普通专属账户
|
// 普通专属账户
|
||||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
if (
|
||||||
|
boundAccount &&
|
||||||
|
boundAccount.isActive === 'true' &&
|
||||||
|
boundAccount.status !== 'error' &&
|
||||||
|
this._isSchedulable(boundAccount.schedulable)
|
||||||
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -117,7 +122,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`
|
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +135,8 @@ class UnifiedClaudeScheduler {
|
|||||||
if (
|
if (
|
||||||
boundConsoleAccount &&
|
boundConsoleAccount &&
|
||||||
boundConsoleAccount.isActive === true &&
|
boundConsoleAccount.isActive === true &&
|
||||||
boundConsoleAccount.status === 'active'
|
boundConsoleAccount.status === 'active' &&
|
||||||
|
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||||
) {
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||||
@@ -141,7 +147,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`
|
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +157,11 @@ class UnifiedClaudeScheduler {
|
|||||||
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
||||||
apiKeyData.bedrockAccountId
|
apiKeyData.bedrockAccountId
|
||||||
)
|
)
|
||||||
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
|
if (
|
||||||
|
boundBedrockAccountResult.success &&
|
||||||
|
boundBedrockAccountResult.data.isActive === true &&
|
||||||
|
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||||
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -161,7 +171,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool`
|
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +261,8 @@ class UnifiedClaudeScheduler {
|
|||||||
boundAccount.isActive === 'true' &&
|
boundAccount.isActive === 'true' &&
|
||||||
boundAccount.status !== 'error' &&
|
boundAccount.status !== 'error' &&
|
||||||
boundAccount.status !== 'blocked' &&
|
boundAccount.status !== 'blocked' &&
|
||||||
boundAccount.status !== 'temp_error'
|
boundAccount.status !== 'temp_error' &&
|
||||||
|
this._isSchedulable(boundAccount.schedulable)
|
||||||
) {
|
) {
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||||
if (!isRateLimited) {
|
if (!isRateLimited) {
|
||||||
@@ -269,7 +280,9 @@ class UnifiedClaudeScheduler {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`)
|
logger.warn(
|
||||||
|
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +294,8 @@ class UnifiedClaudeScheduler {
|
|||||||
if (
|
if (
|
||||||
boundConsoleAccount &&
|
boundConsoleAccount &&
|
||||||
boundConsoleAccount.isActive === true &&
|
boundConsoleAccount.isActive === true &&
|
||||||
boundConsoleAccount.status === 'active'
|
boundConsoleAccount.status === 'active' &&
|
||||||
|
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||||
) {
|
) {
|
||||||
// 主动触发一次额度检查
|
// 主动触发一次额度检查
|
||||||
try {
|
try {
|
||||||
@@ -317,7 +331,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`
|
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +341,11 @@ class UnifiedClaudeScheduler {
|
|||||||
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
|
||||||
apiKeyData.bedrockAccountId
|
apiKeyData.bedrockAccountId
|
||||||
)
|
)
|
||||||
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
|
if (
|
||||||
|
boundBedrockAccountResult.success &&
|
||||||
|
boundBedrockAccountResult.data.isActive === true &&
|
||||||
|
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||||
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
||||||
)
|
)
|
||||||
@@ -341,7 +359,9 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`)
|
logger.warn(
|
||||||
|
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +548,10 @@ class UnifiedClaudeScheduler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return !(await claudeAccountService.isAccountRateLimited(accountId))
|
// 检查是否限流或过载
|
||||||
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
|
||||||
|
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
|
||||||
|
return !isRateLimited && !isOverloaded
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account || !account.isActive) {
|
if (!account || !account.isActive) {
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ class WebhookConfigService {
|
|||||||
'slack',
|
'slack',
|
||||||
'discord',
|
'discord',
|
||||||
'custom',
|
'custom',
|
||||||
'bark'
|
'bark',
|
||||||
|
'smtp'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const platform of config.platforms) {
|
for (const platform of config.platforms) {
|
||||||
@@ -71,8 +72,8 @@ class WebhookConfigService {
|
|||||||
throw new Error(`不支持的平台类型: ${platform.type}`)
|
throw new Error(`不支持的平台类型: ${platform.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bark平台使用deviceKey而不是url
|
// Bark和SMTP平台不使用标准URL
|
||||||
if (platform.type !== 'bark') {
|
if (platform.type !== 'bark' && platform.type !== 'smtp') {
|
||||||
if (!platform.url || !this.isValidUrl(platform.url)) {
|
if (!platform.url || !this.isValidUrl(platform.url)) {
|
||||||
throw new Error(`无效的webhook URL: ${platform.url}`)
|
throw new Error(`无效的webhook URL: ${platform.url}`)
|
||||||
}
|
}
|
||||||
@@ -201,6 +202,51 @@ class WebhookConfigService {
|
|||||||
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
|
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'smtp': {
|
||||||
|
// 验证SMTP必需配置
|
||||||
|
if (!platform.host) {
|
||||||
|
throw new Error('SMTP平台必须提供主机地址')
|
||||||
|
}
|
||||||
|
if (!platform.user) {
|
||||||
|
throw new Error('SMTP平台必须提供用户名')
|
||||||
|
}
|
||||||
|
if (!platform.pass) {
|
||||||
|
throw new Error('SMTP平台必须提供密码')
|
||||||
|
}
|
||||||
|
if (!platform.to) {
|
||||||
|
throw new Error('SMTP平台必须提供接收邮箱')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证端口
|
||||||
|
if (platform.port && (platform.port < 1 || platform.port > 65535)) {
|
||||||
|
throw new Error('SMTP端口必须在1-65535之间')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邮箱格式
|
||||||
|
// 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name <user@domain.com>
|
||||||
|
const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
// 验证接收邮箱
|
||||||
|
const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to]
|
||||||
|
for (const email of toEmails) {
|
||||||
|
// 提取实际邮箱地址(如果是 Name <email> 格式)
|
||||||
|
const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email
|
||||||
|
if (!actualEmail || !simpleEmailRegex.test(actualEmail)) {
|
||||||
|
throw new Error(`无效的接收邮箱格式: ${email}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证发送邮箱(支持 Name <email> 格式)
|
||||||
|
if (platform.from) {
|
||||||
|
const actualFromEmail = platform.from.includes('<')
|
||||||
|
? platform.from.match(/<([^>]+)>/)?.[1]
|
||||||
|
: platform.from
|
||||||
|
if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) {
|
||||||
|
throw new Error(`无效的发送邮箱格式: ${platform.from}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const nodemailer = require('nodemailer')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const webhookConfigService = require('./webhookConfigService')
|
const webhookConfigService = require('./webhookConfigService')
|
||||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
@@ -14,7 +15,8 @@ class WebhookService {
|
|||||||
slack: this.sendToSlack.bind(this),
|
slack: this.sendToSlack.bind(this),
|
||||||
discord: this.sendToDiscord.bind(this),
|
discord: this.sendToDiscord.bind(this),
|
||||||
custom: this.sendToCustom.bind(this),
|
custom: this.sendToCustom.bind(this),
|
||||||
bark: this.sendToBark.bind(this)
|
bark: this.sendToBark.bind(this),
|
||||||
|
smtp: this.sendToSMTP.bind(this)
|
||||||
}
|
}
|
||||||
this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
|
this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
|
||||||
}
|
}
|
||||||
@@ -243,6 +245,51 @@ class WebhookService {
|
|||||||
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
|
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP邮件通知
|
||||||
|
*/
|
||||||
|
async sendToSMTP(platform, type, data) {
|
||||||
|
try {
|
||||||
|
// 创建SMTP传输器
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: platform.host,
|
||||||
|
port: platform.port || 587,
|
||||||
|
secure: platform.secure || false, // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: platform.user,
|
||||||
|
pass: platform.pass
|
||||||
|
},
|
||||||
|
// 可选的TLS配置
|
||||||
|
tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined,
|
||||||
|
// 连接超时
|
||||||
|
connectionTimeout: platform.timeout || 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构造邮件内容
|
||||||
|
const subject = this.getNotificationTitle(type)
|
||||||
|
const htmlContent = this.formatMessageForEmail(type, data)
|
||||||
|
const textContent = this.formatMessageForEmailText(type, data)
|
||||||
|
|
||||||
|
// 邮件选项
|
||||||
|
const mailOptions = {
|
||||||
|
from: platform.from || platform.user, // 发送者
|
||||||
|
to: platform.to, // 接收者(必填)
|
||||||
|
subject: `[Claude Relay Service] ${subject}`,
|
||||||
|
text: textContent,
|
||||||
|
html: htmlContent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邮件
|
||||||
|
const info = await transporter.sendMail(mailOptions)
|
||||||
|
logger.info(`✅ 邮件发送成功: ${info.messageId}`)
|
||||||
|
|
||||||
|
return info
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SMTP邮件发送失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送HTTP请求
|
* 发送HTTP请求
|
||||||
*/
|
*/
|
||||||
@@ -459,6 +506,121 @@ class WebhookService {
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建通知详情数据
|
||||||
|
*/
|
||||||
|
buildNotificationDetails(data) {
|
||||||
|
const details = []
|
||||||
|
|
||||||
|
if (data.accountName) {
|
||||||
|
details.push({ label: '账号', value: data.accountName })
|
||||||
|
}
|
||||||
|
if (data.platform) {
|
||||||
|
details.push({ label: '平台', value: data.platform })
|
||||||
|
}
|
||||||
|
if (data.status) {
|
||||||
|
details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) })
|
||||||
|
}
|
||||||
|
if (data.errorCode) {
|
||||||
|
details.push({ label: '错误代码', value: data.errorCode, isCode: true })
|
||||||
|
}
|
||||||
|
if (data.reason) {
|
||||||
|
details.push({ label: '原因', value: data.reason })
|
||||||
|
}
|
||||||
|
if (data.message) {
|
||||||
|
details.push({ label: '消息', value: data.message })
|
||||||
|
}
|
||||||
|
if (data.quota) {
|
||||||
|
details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` })
|
||||||
|
}
|
||||||
|
if (data.usage) {
|
||||||
|
details.push({ label: '使用率', value: `${data.usage}%` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化邮件HTML内容
|
||||||
|
*/
|
||||||
|
formatMessageForEmail(type, data) {
|
||||||
|
const title = this.getNotificationTitle(type)
|
||||||
|
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
|
||||||
|
const details = this.buildNotificationDetails(data)
|
||||||
|
|
||||||
|
let content = `
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||||
|
<h1 style="margin: 0; font-size: 24px;">${title}</h1>
|
||||||
|
<p style="margin: 10px 0 0 0; opacity: 0.9;">Claude Relay Service</p>
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f9fa; padding: 20px; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px;">
|
||||||
|
<div style="background: white; padding: 16px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
`
|
||||||
|
|
||||||
|
// 使用统一的详情数据渲染
|
||||||
|
details.forEach((detail) => {
|
||||||
|
if (detail.isCode) {
|
||||||
|
content += `<p><strong>${detail.label}:</strong> <code style="background: #f1f3f4; padding: 2px 6px; border-radius: 4px;">${detail.value}</code></p>`
|
||||||
|
} else if (detail.color) {
|
||||||
|
content += `<p><strong>${detail.label}:</strong> <span style="color: ${detail.color};">${detail.value}</span></p>`
|
||||||
|
} else {
|
||||||
|
content += `<p><strong>${detail.label}:</strong> ${detail.value}</p>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
content += `
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 14px; color: #6c757d; text-align: center;">
|
||||||
|
<p>发送时间: ${timestamp}</p>
|
||||||
|
<p style="margin: 0;">此邮件由 Claude Relay Service 自动发送</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化邮件纯文本内容
|
||||||
|
*/
|
||||||
|
formatMessageForEmailText(type, data) {
|
||||||
|
const title = this.getNotificationTitle(type)
|
||||||
|
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
|
||||||
|
const details = this.buildNotificationDetails(data)
|
||||||
|
|
||||||
|
let content = `${title}\n`
|
||||||
|
content += `=====================================\n\n`
|
||||||
|
|
||||||
|
// 使用统一的详情数据渲染
|
||||||
|
details.forEach((detail) => {
|
||||||
|
content += `${detail.label}: ${detail.value}\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
content += `\n发送时间: ${timestamp}\n`
|
||||||
|
content += `服务: Claude Relay Service\n`
|
||||||
|
content += `=====================================\n`
|
||||||
|
content += `此邮件由系统自动发送,请勿回复。`
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取状态颜色
|
||||||
|
*/
|
||||||
|
getStatusColor(status) {
|
||||||
|
const colors = {
|
||||||
|
error: '#dc3545',
|
||||||
|
unauthorized: '#fd7e14',
|
||||||
|
blocked: '#6f42c1',
|
||||||
|
disabled: '#6c757d',
|
||||||
|
active: '#28a745',
|
||||||
|
warning: '#ffc107'
|
||||||
|
}
|
||||||
|
return colors[status] || '#007bff'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化通知详情
|
* 格式化通知详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -276,12 +276,12 @@
|
|||||||
<!-- 显示所有分组 - 换行显示 -->
|
<!-- 显示所有分组 - 换行显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="account.groupInfos && account.groupInfos.length > 0"
|
v-if="account.groupInfos && account.groupInfos.length > 0"
|
||||||
class="flex items-center gap-2"
|
class="my-2 flex flex-wrap items-center gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="group in account.groupInfos"
|
v-for="group in account.groupInfos"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||||
:title="`所属分组: ${group.name}`"
|
:title="`所属分组: ${group.name}`"
|
||||||
>
|
>
|
||||||
<i class="fas fa-folder mr-1" />{{ group.name }}
|
<i class="fas fa-folder mr-1" />{{ group.name }}
|
||||||
|
|||||||
@@ -377,9 +377,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">启用通知</h2>
|
||||||
启用 Webhook 通知
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
开启后,系统将按配置发送通知到指定平台
|
开启后,系统将按配置发送通知到指定平台
|
||||||
</p>
|
</p>
|
||||||
@@ -471,10 +469,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-1 text-sm">
|
<div class="mt-3 space-y-1 text-sm">
|
||||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
<div
|
||||||
|
v-if="platform.type !== 'smtp'"
|
||||||
|
class="flex items-center text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
<i class="fas fa-link mr-2"></i>
|
<i class="fas fa-link mr-2"></i>
|
||||||
<span class="truncate">{{ platform.url }}</span>
|
<span class="truncate">{{ platform.url }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="platform.type === 'smtp' && platform.to"
|
||||||
|
class="flex items-center text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-envelope mr-2"></i>
|
||||||
|
<span class="truncate">{{
|
||||||
|
Array.isArray(platform.to) ? platform.to.join(', ') : platform.to
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="platform.enableSign"
|
v-if="platform.enableSign"
|
||||||
class="flex items-center text-gray-600 dark:text-gray-400"
|
class="flex items-center text-gray-600 dark:text-gray-400"
|
||||||
@@ -655,6 +665,7 @@
|
|||||||
<option value="slack">🟣 Slack</option>
|
<option value="slack">🟣 Slack</option>
|
||||||
<option value="discord">🟪 Discord</option>
|
<option value="discord">🟪 Discord</option>
|
||||||
<option value="bark">🔔 Bark</option>
|
<option value="bark">🔔 Bark</option>
|
||||||
|
<option value="smtp">📧 邮件通知</option>
|
||||||
<option value="custom">⚙️ 自定义</option>
|
<option value="custom">⚙️ 自定义</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
@@ -684,8 +695,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Webhook URL (非Bark平台) -->
|
<!-- Webhook URL (非Bark和SMTP平台) -->
|
||||||
<div v-if="platformForm.type !== 'bark'">
|
<div v-if="platformForm.type !== 'bark' && platformForm.type !== 'smtp'">
|
||||||
<label
|
<label
|
||||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
@@ -836,6 +847,141 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP 平台特有字段 -->
|
||||||
|
<div v-if="platformForm.type === 'smtp'" class="space-y-5">
|
||||||
|
<!-- SMTP 主机 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-server mr-2 text-gray-400"></i>
|
||||||
|
SMTP 服务器
|
||||||
|
<span class="ml-1 text-xs text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.host"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="例如: smtp.gmail.com"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP 端口和安全设置 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plug mr-2 text-gray-400"></i>
|
||||||
|
端口
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="platformForm.port"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
max="65535"
|
||||||
|
min="1"
|
||||||
|
placeholder="587"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
默认: 587 (TLS) 或 465 (SSL)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-gray-400"></i>
|
||||||
|
加密方式
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="platformForm.secure"
|
||||||
|
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option :value="false">STARTTLS (端口587)</option>
|
||||||
|
<option :value="true">SSL/TLS (端口465)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户名 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user mr-2 text-gray-400"></i>
|
||||||
|
用户名
|
||||||
|
<span class="ml-1 text-xs text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.user"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-lock mr-2 text-gray-400"></i>
|
||||||
|
密码 / 应用密码
|
||||||
|
<span class="ml-1 text-xs text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.pass"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="邮箱密码或应用专用密码"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
建议使用应用专用密码,而非邮箱登录密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发件人邮箱 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-2 text-gray-400"></i>
|
||||||
|
发件人邮箱
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.from"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="默认使用用户名邮箱"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收件人邮箱 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-envelope mr-2 text-gray-400"></i>
|
||||||
|
收件人邮箱
|
||||||
|
<span class="ml-1 text-xs text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.to"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">接收通知的邮箱地址</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 签名设置(钉钉/飞书) -->
|
<!-- 签名设置(钉钉/飞书) -->
|
||||||
<div
|
<div
|
||||||
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
|
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
|
||||||
@@ -1008,7 +1154,23 @@ const platformForm = ref({
|
|||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
enableSign: false,
|
enableSign: false,
|
||||||
secret: ''
|
secret: '',
|
||||||
|
// Bark特有字段
|
||||||
|
deviceKey: '',
|
||||||
|
serverUrl: '',
|
||||||
|
level: '',
|
||||||
|
sound: '',
|
||||||
|
group: '',
|
||||||
|
// SMTP特有字段
|
||||||
|
host: '',
|
||||||
|
port: null,
|
||||||
|
secure: false,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
timeout: null,
|
||||||
|
ignoreTLS: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听activeSection变化,加载对应配置
|
// 监听activeSection变化,加载对应配置
|
||||||
@@ -1030,17 +1192,48 @@ const platformTypeWatcher = watch(
|
|||||||
// 如果不是编辑模式,清空相关字段
|
// 如果不是编辑模式,清空相关字段
|
||||||
if (!editingPlatform.value) {
|
if (!editingPlatform.value) {
|
||||||
if (newType === 'bark') {
|
if (newType === 'bark') {
|
||||||
// 切换到Bark时,清空URL相关字段
|
// 切换到Bark时,清空URL和SMTP相关字段
|
||||||
platformForm.value.url = ''
|
platformForm.value.url = ''
|
||||||
platformForm.value.enableSign = false
|
platformForm.value.enableSign = false
|
||||||
platformForm.value.secret = ''
|
platformForm.value.secret = ''
|
||||||
} else {
|
// 清空SMTP字段
|
||||||
// 切换到其他平台时,清空Bark相关字段
|
platformForm.value.host = ''
|
||||||
|
platformForm.value.port = null
|
||||||
|
platformForm.value.secure = false
|
||||||
|
platformForm.value.user = ''
|
||||||
|
platformForm.value.pass = ''
|
||||||
|
platformForm.value.from = ''
|
||||||
|
platformForm.value.to = ''
|
||||||
|
platformForm.value.timeout = null
|
||||||
|
platformForm.value.ignoreTLS = false
|
||||||
|
} else if (newType === 'smtp') {
|
||||||
|
// 切换到SMTP时,清空URL和Bark相关字段
|
||||||
|
platformForm.value.url = ''
|
||||||
|
platformForm.value.enableSign = false
|
||||||
|
platformForm.value.secret = ''
|
||||||
|
// 清空Bark字段
|
||||||
platformForm.value.deviceKey = ''
|
platformForm.value.deviceKey = ''
|
||||||
platformForm.value.serverUrl = ''
|
platformForm.value.serverUrl = ''
|
||||||
platformForm.value.level = ''
|
platformForm.value.level = ''
|
||||||
platformForm.value.sound = ''
|
platformForm.value.sound = ''
|
||||||
platformForm.value.group = ''
|
platformForm.value.group = ''
|
||||||
|
} else {
|
||||||
|
// 切换到其他平台时,清空Bark和SMTP相关字段
|
||||||
|
platformForm.value.deviceKey = ''
|
||||||
|
platformForm.value.serverUrl = ''
|
||||||
|
platformForm.value.level = ''
|
||||||
|
platformForm.value.sound = ''
|
||||||
|
platformForm.value.group = ''
|
||||||
|
// SMTP 字段
|
||||||
|
platformForm.value.host = ''
|
||||||
|
platformForm.value.port = null
|
||||||
|
platformForm.value.secure = false
|
||||||
|
platformForm.value.user = ''
|
||||||
|
platformForm.value.pass = ''
|
||||||
|
platformForm.value.from = ''
|
||||||
|
platformForm.value.to = ''
|
||||||
|
platformForm.value.timeout = null
|
||||||
|
platformForm.value.ignoreTLS = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1244,14 @@ const isPlatformFormValid = computed(() => {
|
|||||||
if (platformForm.value.type === 'bark') {
|
if (platformForm.value.type === 'bark') {
|
||||||
// Bark平台需要deviceKey
|
// Bark平台需要deviceKey
|
||||||
return !!platformForm.value.deviceKey
|
return !!platformForm.value.deviceKey
|
||||||
|
} else if (platformForm.value.type === 'smtp') {
|
||||||
|
// SMTP平台需要必要的配置
|
||||||
|
return !!(
|
||||||
|
platformForm.value.host &&
|
||||||
|
platformForm.value.user &&
|
||||||
|
platformForm.value.pass &&
|
||||||
|
platformForm.value.to
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// 其他平台需要URL且URL格式正确
|
// 其他平台需要URL且URL格式正确
|
||||||
return !!platformForm.value.url && !urlError.value
|
return !!platformForm.value.url && !urlError.value
|
||||||
@@ -1134,8 +1335,8 @@ const saveWebhookConfig = async () => {
|
|||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
// Bark平台不需要验证URL
|
// Bark和SMTP平台不需要验证URL
|
||||||
if (platformForm.value.type === 'bark') {
|
if (platformForm.value.type === 'bark' || platformForm.value.type === 'smtp') {
|
||||||
urlError.value = false
|
urlError.value = false
|
||||||
urlValid.value = false
|
urlValid.value = false
|
||||||
return
|
return
|
||||||
@@ -1163,27 +1364,46 @@ const validateUrl = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加/更新平台
|
// 验证平台配置
|
||||||
const savePlatform = async () => {
|
const validatePlatformForm = () => {
|
||||||
if (!isMounted.value) return
|
|
||||||
|
|
||||||
// Bark平台只需要deviceKey,其他平台需要URL
|
|
||||||
if (platformForm.value.type === 'bark') {
|
if (platformForm.value.type === 'bark') {
|
||||||
if (!platformForm.value.deviceKey) {
|
if (!platformForm.value.deviceKey) {
|
||||||
showToast('请输入Bark设备密钥', 'error')
|
showToast('请输入Bark设备密钥', 'error')
|
||||||
return
|
return false
|
||||||
|
}
|
||||||
|
} else if (platformForm.value.type === 'smtp') {
|
||||||
|
const requiredFields = [
|
||||||
|
{ field: 'host', message: 'SMTP服务器' },
|
||||||
|
{ field: 'user', message: '用户名' },
|
||||||
|
{ field: 'pass', message: '密码' },
|
||||||
|
{ field: 'to', message: '收件人邮箱' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { field, message } of requiredFields) {
|
||||||
|
if (!platformForm.value[field]) {
|
||||||
|
showToast(`请输入${message}`, 'error')
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!platformForm.value.url) {
|
if (!platformForm.value.url) {
|
||||||
showToast('请输入Webhook URL', 'error')
|
showToast('请输入Webhook URL', 'error')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlError.value) {
|
if (urlError.value) {
|
||||||
showToast('请输入有效的Webhook URL', 'error')
|
showToast('请输入有效的Webhook URL', 'error')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加/更新平台
|
||||||
|
const savePlatform = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
|
||||||
|
// 验证表单
|
||||||
|
if (!validatePlatformForm()) return
|
||||||
|
|
||||||
savingPlatform.value = true
|
savingPlatform.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1292,6 +1512,15 @@ const testPlatform = async (platform) => {
|
|||||||
testData.level = platform.level
|
testData.level = platform.level
|
||||||
testData.sound = platform.sound
|
testData.sound = platform.sound
|
||||||
testData.group = platform.group
|
testData.group = platform.group
|
||||||
|
} else if (platform.type === 'smtp') {
|
||||||
|
testData.host = platform.host
|
||||||
|
testData.port = platform.port
|
||||||
|
testData.secure = platform.secure
|
||||||
|
testData.user = platform.user
|
||||||
|
testData.pass = platform.pass
|
||||||
|
testData.from = platform.from
|
||||||
|
testData.to = platform.to
|
||||||
|
testData.ignoreTLS = platform.ignoreTLS
|
||||||
} else {
|
} else {
|
||||||
testData.url = platform.url
|
testData.url = platform.url
|
||||||
}
|
}
|
||||||
@@ -1300,7 +1529,7 @@ const testPlatform = async (platform) => {
|
|||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
showToast('测试成功,webhook连接正常', 'success')
|
showToast('测试成功', 'success')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return
|
if (error.name === 'AbortError') return
|
||||||
@@ -1314,24 +1543,8 @@ const testPlatform = async (platform) => {
|
|||||||
const testPlatformForm = async () => {
|
const testPlatformForm = async () => {
|
||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
// Bark平台验证
|
// 验证表单
|
||||||
if (platformForm.value.type === 'bark') {
|
if (!validatePlatformForm()) return
|
||||||
if (!platformForm.value.deviceKey) {
|
|
||||||
showToast('请先输入Bark设备密钥', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 其他平台验证URL
|
|
||||||
if (!platformForm.value.url) {
|
|
||||||
showToast('请先输入Webhook URL', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urlError.value) {
|
|
||||||
showToast('请输入有效的Webhook URL', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testingConnection.value = true
|
testingConnection.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1339,7 +1552,7 @@ const testPlatformForm = async () => {
|
|||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
showToast('测试成功,webhook连接正常', 'success')
|
showToast('测试成功', 'success')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return
|
if (error.name === 'AbortError') return
|
||||||
@@ -1397,7 +1610,17 @@ const closePlatformModal = () => {
|
|||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
level: '',
|
level: '',
|
||||||
sound: '',
|
sound: '',
|
||||||
group: ''
|
group: '',
|
||||||
|
// SMTP特有字段
|
||||||
|
host: '',
|
||||||
|
port: null,
|
||||||
|
secure: false,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
timeout: null,
|
||||||
|
ignoreTLS: false
|
||||||
}
|
}
|
||||||
urlError.value = false
|
urlError.value = false
|
||||||
urlValid.value = false
|
urlValid.value = false
|
||||||
@@ -1415,6 +1638,7 @@ const getPlatformName = (type) => {
|
|||||||
slack: 'Slack',
|
slack: 'Slack',
|
||||||
discord: 'Discord',
|
discord: 'Discord',
|
||||||
bark: 'Bark',
|
bark: 'Bark',
|
||||||
|
smtp: '邮件通知',
|
||||||
custom: '自定义'
|
custom: '自定义'
|
||||||
}
|
}
|
||||||
return names[type] || type
|
return names[type] || type
|
||||||
@@ -1428,6 +1652,7 @@ const getPlatformIcon = (type) => {
|
|||||||
slack: 'fab fa-slack text-purple-600',
|
slack: 'fab fa-slack text-purple-600',
|
||||||
discord: 'fab fa-discord text-indigo-600',
|
discord: 'fab fa-discord text-indigo-600',
|
||||||
bark: 'fas fa-bell text-orange-500',
|
bark: 'fas fa-bell text-orange-500',
|
||||||
|
smtp: 'fas fa-envelope text-blue-600',
|
||||||
custom: 'fas fa-webhook text-gray-600'
|
custom: 'fas fa-webhook text-gray-600'
|
||||||
}
|
}
|
||||||
return icons[type] || 'fas fa-bell'
|
return icons[type] || 'fas fa-bell'
|
||||||
@@ -1441,6 +1666,7 @@ const getWebhookHint = (type) => {
|
|||||||
slack: '请在Slack应用的Incoming Webhooks中获取地址',
|
slack: '请在Slack应用的Incoming Webhooks中获取地址',
|
||||||
discord: '请在Discord服务器的集成设置中创建Webhook',
|
discord: '请在Discord服务器的集成设置中创建Webhook',
|
||||||
bark: '请在Bark App中查看您的设备密钥',
|
bark: '请在Bark App中查看您的设备密钥',
|
||||||
|
smtp: '请配置SMTP服务器信息,支持Gmail、QQ邮箱等',
|
||||||
custom: '请输入完整的Webhook接收地址'
|
custom: '请输入完整的Webhook接收地址'
|
||||||
}
|
}
|
||||||
return hints[type] || ''
|
return hints[type] || ''
|
||||||
@@ -1451,7 +1677,8 @@ const getNotificationTypeName = (type) => {
|
|||||||
accountAnomaly: '账号异常',
|
accountAnomaly: '账号异常',
|
||||||
quotaWarning: '配额警告',
|
quotaWarning: '配额警告',
|
||||||
systemError: '系统错误',
|
systemError: '系统错误',
|
||||||
securityAlert: '安全警报'
|
securityAlert: '安全警报',
|
||||||
|
test: '测试通知'
|
||||||
}
|
}
|
||||||
return names[type] || type
|
return names[type] || type
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1688,8 @@ const getNotificationTypeDescription = (type) => {
|
|||||||
accountAnomaly: '账号状态异常、认证失败等',
|
accountAnomaly: '账号状态异常、认证失败等',
|
||||||
quotaWarning: 'API调用配额不足警告',
|
quotaWarning: 'API调用配额不足警告',
|
||||||
systemError: '系统运行错误和故障',
|
systemError: '系统运行错误和故障',
|
||||||
securityAlert: '安全相关的警报通知'
|
securityAlert: '安全相关的警报通知',
|
||||||
|
test: '用于测试Webhook连接是否正常'
|
||||||
}
|
}
|
||||||
return descriptions[type] || ''
|
return descriptions[type] || ''
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user