mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
添加限流恢复的 webhook 通知功能,当账户从限流状态自动恢复时发送通知。 主要改进: 1. **新增通知类型** (webhookConfigService.js) - 添加 `rateLimitRecovery` 通知类型 - 在配置获取和保存时自动合并默认通知类型 - 确保新增的通知类型有默认值 2. **增强限流清理服务** (rateLimitCleanupService.js) - 改进自动停止账户的检测逻辑 - 在 `finally` 块中确保 `clearedAccounts` 列表被重置,避免重复通知 - 对自动停止的账户显式调用 `removeAccountRateLimit` - 为 Claude 和 Claude Console 账户添加 `autoStopped` 和 `needsAutoStopRecovery` 检测 3. **改进 Claude Console 限流移除** (claudeConsoleAccountService.js) - 检测并恢复因自动停止而禁用调度的账户 - 清理过期的 `rateLimitAutoStopped` 标志 - 增加详细的日志记录 4. **前端 UI 支持** (SettingsView.vue) - 在 Webhook 设置中添加"限流恢复"通知类型选项 - 更新默认通知类型配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
/**
|
||
* 限流状态自动清理服务
|
||
* 定期检查并清理所有类型账号的过期限流状态
|
||
*/
|
||
|
||
const logger = require('../utils/logger')
|
||
const openaiAccountService = require('./openaiAccountService')
|
||
const claudeAccountService = require('./claudeAccountService')
|
||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||
const webhookService = require('./webhookService')
|
||
|
||
class RateLimitCleanupService {
|
||
constructor() {
|
||
this.cleanupInterval = null
|
||
this.isRunning = false
|
||
// 默认每5分钟检查一次
|
||
this.intervalMs = 5 * 60 * 1000
|
||
// 存储已清理的账户信息,用于发送恢复通知
|
||
this.clearedAccounts = []
|
||
}
|
||
|
||
/**
|
||
* 启动自动清理服务
|
||
* @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟
|
||
*/
|
||
start(intervalMinutes = 5) {
|
||
if (this.cleanupInterval) {
|
||
logger.warn('⚠️ Rate limit cleanup service is already running')
|
||
return
|
||
}
|
||
|
||
this.intervalMs = intervalMinutes * 60 * 1000
|
||
|
||
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
|
||
|
||
// 立即执行一次清理
|
||
this.performCleanup()
|
||
|
||
// 设置定期执行
|
||
this.cleanupInterval = setInterval(() => {
|
||
this.performCleanup()
|
||
}, this.intervalMs)
|
||
}
|
||
|
||
/**
|
||
* 停止自动清理服务
|
||
*/
|
||
stop() {
|
||
if (this.cleanupInterval) {
|
||
clearInterval(this.cleanupInterval)
|
||
this.cleanupInterval = null
|
||
logger.info('🛑 Rate limit cleanup service stopped')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行一次清理检查
|
||
*/
|
||
async performCleanup() {
|
||
if (this.isRunning) {
|
||
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
|
||
return
|
||
}
|
||
|
||
this.isRunning = true
|
||
const startTime = Date.now()
|
||
|
||
try {
|
||
logger.debug('🔍 Starting rate limit cleanup check...')
|
||
|
||
const results = {
|
||
openai: { checked: 0, cleared: 0, errors: [] },
|
||
claude: { checked: 0, cleared: 0, errors: [] },
|
||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||
}
|
||
|
||
// 清理 OpenAI 账号
|
||
await this.cleanupOpenAIAccounts(results.openai)
|
||
|
||
// 清理 Claude 账号
|
||
await this.cleanupClaudeAccounts(results.claude)
|
||
|
||
// 清理 Claude Console 账号
|
||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||
|
||
const totalChecked =
|
||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||
const totalCleared =
|
||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||
const duration = Date.now() - startTime
|
||
|
||
if (totalCleared > 0) {
|
||
logger.info(
|
||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||
)
|
||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||
logger.info(
|
||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||
)
|
||
|
||
// 发送 webhook 恢复通知
|
||
if (this.clearedAccounts.length > 0) {
|
||
await this.sendRecoveryNotifications()
|
||
}
|
||
} else {
|
||
logger.debug(
|
||
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
|
||
)
|
||
}
|
||
|
||
// 记录错误
|
||
const allErrors = [
|
||
...results.openai.errors,
|
||
...results.claude.errors,
|
||
...results.claudeConsole.errors
|
||
]
|
||
if (allErrors.length > 0) {
|
||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Rate limit cleanup failed:', error)
|
||
} finally {
|
||
// 确保无论成功或失败都重置列表,避免重复通知
|
||
this.clearedAccounts = []
|
||
this.isRunning = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理 OpenAI 账号的过期限流
|
||
*/
|
||
async cleanupOpenAIAccounts(result) {
|
||
try {
|
||
// 使用服务层获取账户数据
|
||
const accounts = await openaiAccountService.getAllAccounts()
|
||
|
||
for (const account of accounts) {
|
||
const { rateLimitStatus } = account
|
||
const isRateLimited =
|
||
rateLimitStatus === 'limited' ||
|
||
(rateLimitStatus &&
|
||
typeof rateLimitStatus === 'object' &&
|
||
(rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true))
|
||
|
||
if (isRateLimited) {
|
||
result.checked++
|
||
|
||
try {
|
||
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
|
||
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
|
||
|
||
if (!isStillLimited) {
|
||
result.cleared++
|
||
logger.info(
|
||
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
|
||
)
|
||
|
||
// 记录已清理的账户信息
|
||
this.clearedAccounts.push({
|
||
platform: 'OpenAI',
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
previousStatus: 'rate_limited',
|
||
currentStatus: 'active'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
result.errors.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
error: error.message
|
||
})
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to cleanup OpenAI accounts:', error)
|
||
result.errors.push({ error: error.message })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理 Claude 账号的过期限流
|
||
*/
|
||
async cleanupClaudeAccounts(result) {
|
||
try {
|
||
// 使用 Redis 获取账户数据
|
||
const redis = require('../models/redis')
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
|
||
for (const account of accounts) {
|
||
// 检查是否处于限流状态(兼容对象和字符串格式)
|
||
const isRateLimited =
|
||
account.rateLimitStatus === 'limited' ||
|
||
(account.rateLimitStatus &&
|
||
typeof account.rateLimitStatus === 'object' &&
|
||
account.rateLimitStatus.status === 'limited')
|
||
|
||
const autoStopped = account.rateLimitAutoStopped === 'true'
|
||
const needsAutoStopRecovery =
|
||
autoStopped && (account.rateLimitEndAt || account.schedulable === 'false')
|
||
|
||
// 检查所有可能处于限流状态的账号,包括自动停止的账号
|
||
if (isRateLimited || account.rateLimitedAt || needsAutoStopRecovery) {
|
||
result.checked++
|
||
|
||
try {
|
||
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
|
||
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||
|
||
if (!isStillLimited) {
|
||
if (!isRateLimited && autoStopped) {
|
||
await claudeAccountService.removeAccountRateLimit(account.id)
|
||
}
|
||
result.cleared++
|
||
logger.info(
|
||
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
|
||
)
|
||
|
||
// 记录已清理的账户信息
|
||
this.clearedAccounts.push({
|
||
platform: 'Claude',
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
previousStatus: 'rate_limited',
|
||
currentStatus: 'active'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
result.errors.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
error: error.message
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查并恢复因5小时限制被自动停止的账号
|
||
try {
|
||
const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts()
|
||
|
||
if (fiveHourResult.recovered > 0) {
|
||
// 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知
|
||
for (const account of fiveHourResult.accounts) {
|
||
this.clearedAccounts.push({
|
||
platform: 'Claude',
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
previousStatus: '5hour_limited',
|
||
currentStatus: 'active',
|
||
windowInfo: account.newWindow
|
||
})
|
||
}
|
||
|
||
// 更新统计数据
|
||
result.checked += fiveHourResult.checked
|
||
result.cleared += fiveHourResult.recovered
|
||
|
||
logger.info(
|
||
`🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered`
|
||
)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error)
|
||
result.errors.push({
|
||
type: '5hour_recovery',
|
||
error: error.message
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to cleanup Claude accounts:', error)
|
||
result.errors.push({ error: error.message })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理 Claude Console 账号的过期限流
|
||
*/
|
||
async cleanupClaudeConsoleAccounts(result) {
|
||
try {
|
||
// 使用服务层获取账户数据
|
||
const accounts = await claudeConsoleAccountService.getAllAccounts()
|
||
|
||
for (const account of accounts) {
|
||
// 检查是否处于限流状态(兼容对象和字符串格式)
|
||
const isRateLimited =
|
||
account.rateLimitStatus === 'limited' ||
|
||
(account.rateLimitStatus &&
|
||
typeof account.rateLimitStatus === 'object' &&
|
||
account.rateLimitStatus.status === 'limited')
|
||
|
||
const autoStopped = account.rateLimitAutoStopped === 'true'
|
||
const needsAutoStopRecovery = autoStopped && account.schedulable === 'false'
|
||
|
||
// 检查两种状态字段:rateLimitStatus 和 status
|
||
const hasStatusRateLimited = account.status === 'rate_limited'
|
||
|
||
if (isRateLimited || hasStatusRateLimited || needsAutoStopRecovery) {
|
||
result.checked++
|
||
|
||
try {
|
||
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
|
||
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||
account.id
|
||
)
|
||
|
||
if (!isStillLimited) {
|
||
if (!isRateLimited && autoStopped) {
|
||
await claudeConsoleAccountService.removeAccountRateLimit(account.id)
|
||
}
|
||
result.cleared++
|
||
|
||
// 如果 status 字段是 rate_limited,需要额外清理
|
||
if (hasStatusRateLimited && !isRateLimited) {
|
||
await claudeConsoleAccountService.updateAccount(account.id, {
|
||
status: 'active'
|
||
})
|
||
}
|
||
|
||
logger.info(
|
||
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
|
||
)
|
||
|
||
// 记录已清理的账户信息
|
||
this.clearedAccounts.push({
|
||
platform: 'Claude Console',
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
previousStatus: 'rate_limited',
|
||
currentStatus: 'active'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
result.errors.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
error: error.message
|
||
})
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to cleanup Claude Console accounts:', error)
|
||
result.errors.push({ error: error.message })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 手动触发一次清理(供 API 或 CLI 调用)
|
||
*/
|
||
async manualCleanup() {
|
||
logger.info('🧹 Manual rate limit cleanup triggered')
|
||
await this.performCleanup()
|
||
}
|
||
|
||
/**
|
||
* 发送限流恢复通知
|
||
*/
|
||
async sendRecoveryNotifications() {
|
||
try {
|
||
// 按平台分组账户
|
||
const groupedAccounts = {}
|
||
for (const account of this.clearedAccounts) {
|
||
if (!groupedAccounts[account.platform]) {
|
||
groupedAccounts[account.platform] = []
|
||
}
|
||
groupedAccounts[account.platform].push(account)
|
||
}
|
||
|
||
// 构建通知消息
|
||
const platforms = Object.keys(groupedAccounts)
|
||
const totalAccounts = this.clearedAccounts.length
|
||
|
||
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
|
||
|
||
for (const platform of platforms) {
|
||
const accounts = groupedAccounts[platform]
|
||
message += `**${platform}** (${accounts.length} 个):\n`
|
||
for (const account of accounts) {
|
||
message += `• ${account.accountName} (ID: ${account.accountId})\n`
|
||
}
|
||
message += '\n'
|
||
}
|
||
|
||
// 发送 webhook 通知
|
||
await webhookService.sendNotification('rateLimitRecovery', {
|
||
title: '限流恢复通知',
|
||
message,
|
||
totalAccounts,
|
||
platforms: Object.keys(groupedAccounts),
|
||
accounts: this.clearedAccounts,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
|
||
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
|
||
} catch (error) {
|
||
logger.error('❌ 发送限流恢复通知失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取服务状态
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
running: !!this.cleanupInterval,
|
||
intervalMinutes: this.intervalMs / (60 * 1000),
|
||
isProcessing: this.isRunning
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建单例实例
|
||
const rateLimitCleanupService = new RateLimitCleanupService()
|
||
|
||
module.exports = rateLimitCleanupService
|