Merge branch 'dev' into main

This commit is contained in:
Wesley Liddick
2025-09-10 14:04:27 +08:00
committed by GitHub
15 changed files with 814 additions and 90 deletions

View File

@@ -2137,6 +2137,108 @@ class ClaudeAccountService {
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()

View File

@@ -19,10 +19,11 @@ class ClaudeConsoleRelayService {
options = {}
) {
let abortController = null
let account = null
try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId)
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Claude Console Claude account not found')
}
@@ -222,7 +223,10 @@ class ClaudeConsoleRelayService {
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账号
@@ -241,9 +245,10 @@ class ClaudeConsoleRelayService {
streamTransformer = null,
options = {}
) {
let account = null
try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId)
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Claude Console Claude account not found')
}
@@ -297,7 +302,10 @@ class ClaudeConsoleRelayService {
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} 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
}
}
@@ -376,7 +384,9 @@ class ClaudeConsoleRelayService {
// 错误响应处理
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) {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
@@ -528,7 +538,10 @@ class ClaudeConsoleRelayService {
}
}
} 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) {
responseStream.write('event: error\n')
responseStream.write(
@@ -570,7 +583,10 @@ class ClaudeConsoleRelayService {
})
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) {
responseStream.write('event: error\n')
responseStream.write(
@@ -590,7 +606,10 @@ class ClaudeConsoleRelayService {
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) {

View File

@@ -208,6 +208,24 @@ class ClaudeRelayService {
)
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状态码
else if (response.statusCode >= 500 && response.statusCode < 600) {
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
@@ -296,6 +314,19 @@ class ClaudeRelayService {
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
if (
clientHeaders &&
@@ -741,7 +772,7 @@ class ClaudeRelayService {
resolve(response)
} catch (error) {
logger.error('❌ Failed to parse Claude API response:', error)
logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error)
reject(error)
}
})
@@ -754,7 +785,7 @@ class ClaudeRelayService {
req.on('error', async (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,
errno: error.errno,
syscall: error.syscall,
@@ -781,7 +812,7 @@ class ClaudeRelayService {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude API request timeout')
logger.error(`❌ Claude API request timeout (Account: ${accountId})`)
await this._handleServerError(accountId, 504, null, 'Request')
@@ -889,7 +920,7 @@ class ClaudeRelayService {
options
)
} catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error)
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
throw error
}
}
@@ -1002,6 +1033,27 @@ class ClaudeRelayService {
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
)
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) {
logger.warn(
`🔥 [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(`❌ Claude API returned error status: ${res.statusCode}`)
logger.error(
`❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}`
)
let errorData = ''
res.on('data', (chunk) => {
@@ -1024,7 +1078,10 @@ class ClaudeRelayService {
res.on('end', () => {
console.error(': ❌ ', errorData)
logger.error('❌ Claude API error response:', errorData)
logger.error(
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData
)
if (!responseStream.destroyed) {
// 发送错误事件
responseStream.write('event: error\n')
@@ -1327,6 +1384,19 @@ class ClaudeRelayService {
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流式请求
if (
clientHeaders &&
@@ -1343,11 +1413,15 @@ class ClaudeRelayService {
})
req.on('error', async (error) => {
logger.error('❌ Claude stream request error:', error.message, {
code: error.code,
errno: error.errno,
syscall: error.syscall
})
logger.error(
`❌ Claude stream request error (Account: ${account?.name || accountId}):`,
error.message,
{
code: error.code,
errno: error.errno,
syscall: error.syscall
}
)
// 根据错误类型提供更具体的错误信息
let errorMessage = 'Upstream request failed'
@@ -1391,7 +1465,7 @@ class ClaudeRelayService {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`)
if (!responseStream.headersSent) {
responseStream.writeHead(504, {
@@ -1493,7 +1567,7 @@ class ClaudeRelayService {
})
req.on('error', async (error) => {
logger.error('❌ Claude stream request error:', error.message, {
logger.error(`❌ Claude stream request error:`, error.message, {
code: error.code,
errno: error.errno,
syscall: error.syscall
@@ -1541,7 +1615,7 @@ class ClaudeRelayService {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
logger.error(`❌ Claude stream request timeout`)
if (!responseStream.headersSent) {
responseStream.writeHead(504, {

View File

@@ -107,7 +107,12 @@ class UnifiedClaudeScheduler {
// 普通专属账户
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(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
@@ -117,7 +122,7 @@ class UnifiedClaudeScheduler {
}
} else {
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 (
boundConsoleAccount &&
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
@@ -141,7 +147,7 @@ class UnifiedClaudeScheduler {
}
} else {
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(
apiKeyData.bedrockAccountId
)
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
if (
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
)
@@ -161,7 +171,7 @@ class UnifiedClaudeScheduler {
}
} else {
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.status !== 'error' &&
boundAccount.status !== 'blocked' &&
boundAccount.status !== 'temp_error'
boundAccount.status !== 'temp_error' &&
this._isSchedulable(boundAccount.schedulable)
) {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
@@ -269,7 +280,9 @@ class UnifiedClaudeScheduler {
]
}
} 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 (
boundConsoleAccount &&
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
// 主动触发一次额度检查
try {
@@ -317,7 +331,7 @@ class UnifiedClaudeScheduler {
}
} else {
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(
apiKeyData.bedrockAccountId
)
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
if (
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
)
@@ -341,7 +359,9 @@ class UnifiedClaudeScheduler {
}
]
} 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 !(await claudeAccountService.isAccountRateLimited(accountId))
// 检查是否限流或过载
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
return !isRateLimited && !isOverloaded
} else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account || !account.isActive) {

View File

@@ -63,7 +63,8 @@ class WebhookConfigService {
'slack',
'discord',
'custom',
'bark'
'bark',
'smtp'
]
for (const platform of config.platforms) {
@@ -71,8 +72,8 @@ class WebhookConfigService {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
// Bark平台使用deviceKey而不是url
if (platform.type !== 'bark') {
// Bark和SMTP平台使用标准URL
if (platform.type !== 'bark' && platform.type !== 'smtp') {
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
@@ -201,6 +202,51 @@ class WebhookConfigService {
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
}
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
}
}
}

View File

@@ -1,5 +1,6 @@
const axios = require('axios')
const crypto = require('crypto')
const nodemailer = require('nodemailer')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
@@ -14,7 +15,8 @@ class WebhookService {
slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.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'
}
@@ -243,6 +245,51 @@ class WebhookService {
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请求
*/
@@ -459,6 +506,121 @@ class WebhookService {
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'
}
/**
* 格式化通知详情
*/