From ea0f818251dae4894579faa40daa8d8886825fb1 Mon Sep 17 00:00:00 2001 From: wfunc <114468522+wfunc@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:31:52 +0800 Subject: [PATCH] feat(claude): limit 5-hour warning notifications to prevent spam ## Problem - Original implementation sends webhook notification on EVERY request when account reaches 5-hour limit warning status - Users receive hundreds of duplicate notifications within same 5-hour window ## Solution - Add `maxFiveHourWarningsPerWindow` config (default: 1, max: 10) - Track warning count per session window with metadata: - fiveHourWarningWindow: identifies current window - fiveHourWarningCount: tracks notifications sent - fiveHourWarningLastSentAt: last notification timestamp - Only send notification if count < max limit - Auto-reset counters when entering new 5-hour window ## Changes - Add warning limit control in constructor - Add `_clearFiveHourWarningMetadata()` helper method - Update `updateSessionWindowStatus()` with notification throttling - Clear warning metadata on window refresh and manual schedule recovery ## Configuration - Environment: CLAUDE_5H_WARNING_MAX_NOTIFICATIONS (1-10) - Config: config.claude.fiveHourWarning.maxNotificationsPerWindow - Default: 1 notification per window ## Testing - Tested with accounts reaching 5h limit - Verified single notification per window - Confirmed counter reset on new window --- src/services/claudeAccountService.js | 144 ++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 8f572333..8150c0fd 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -21,6 +21,17 @@ class ClaudeAccountService { constructor() { this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token' this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + let maxWarnings = parseInt(process.env.CLAUDE_5H_WARNING_MAX_NOTIFICATIONS || '', 10) + + if (Number.isNaN(maxWarnings) && config.claude?.fiveHourWarning) { + maxWarnings = parseInt(config.claude.fiveHourWarning.maxNotificationsPerWindow, 10) + } + + if (Number.isNaN(maxWarnings) || maxWarnings < 1) { + maxWarnings = 1 + } + + this.maxFiveHourWarningsPerWindow = Math.min(maxWarnings, 10) // 加密相关常量 this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' @@ -683,6 +694,8 @@ class ClaudeAccountService { delete updatedData.stoppedReason shouldClearAutoStopFields = true + await this._clearFiveHourWarningMetadata(accountId, updatedData) + // 如果是手动启用调度,记录日志 if (updates.schedulable === true || updates.schedulable === 'true') { logger.info(`✅ Manually enabled scheduling for account ${accountId}`) @@ -1284,14 +1297,17 @@ class ClaudeAccountService { const accountKey = `claude:account:${accountId}` // 清除限流状态 + const redisKey = `claude:account:${accountId}` + await redis.client.hdel(redisKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitEndAt') delete accountData.rateLimitedAt delete accountData.rateLimitStatus delete accountData.rateLimitEndAt // 清除限流结束时间 + const hadAutoStop = accountData.rateLimitAutoStopped === 'true' + // 只恢复因限流而自动停止的账户 - if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') { + if (hadAutoStop && accountData.schedulable === 'false') { accountData.schedulable = 'true' - delete accountData.rateLimitAutoStopped logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`) logger.info( `📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}` @@ -1301,6 +1317,11 @@ class ClaudeAccountService { `ℹ️ Account ${accountId} did not need auto-resume: autoStopped=${accountData.rateLimitAutoStopped}, schedulable=${accountData.schedulable}` ) } + + if (hadAutoStop) { + await redis.client.hdel(redisKey, 'rateLimitAutoStopped') + delete accountData.rateLimitAutoStopped + } await redis.setClaudeAccount(accountId, accountData) // 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度 @@ -1467,6 +1488,7 @@ class ClaudeAccountService { if (accountData.sessionWindowStatus) { delete accountData.sessionWindowStatus delete accountData.sessionWindowStatusUpdatedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) shouldClearSessionStatus = true } @@ -1478,6 +1500,7 @@ class ClaudeAccountService { accountData.schedulable = 'true' delete accountData.fiveHourAutoStopped delete accountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) shouldClearFiveHourFlags = true // 发送Webhook通知 @@ -1537,6 +1560,29 @@ class ClaudeAccountService { return endTime } + async _clearFiveHourWarningMetadata(accountId, accountData = null) { + if (accountData) { + delete accountData.fiveHourWarningWindow + delete accountData.fiveHourWarningCount + delete accountData.fiveHourWarningLastSentAt + } + + try { + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel( + `claude:account:${accountId}`, + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt' + ) + } + } catch (error) { + logger.warn( + `⚠️ Failed to clear five-hour warning metadata for account ${accountId}: ${error.message}` + ) + } + } + // 📊 获取会话窗口信息 async getSessionWindowInfo(accountId) { try { @@ -2145,6 +2191,9 @@ class ClaudeAccountService { delete updatedAccountData.fiveHourAutoStopped delete updatedAccountData.fiveHourStoppedAt delete updatedAccountData.tempErrorAutoStopped + delete updatedAccountData.fiveHourWarningWindow + delete updatedAccountData.fiveHourWarningCount + delete updatedAccountData.fiveHourWarningLastSentAt // 兼容旧的标记 delete updatedAccountData.autoStoppedAt delete updatedAccountData.stoppedReason @@ -2178,6 +2227,9 @@ class ClaudeAccountService { 'rateLimitAutoStopped', 'fiveHourAutoStopped', 'fiveHourStoppedAt', + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt', 'tempErrorAutoStopped', // 兼容旧的标记 'autoStoppedAt', @@ -2445,36 +2497,75 @@ class ClaudeAccountService { return } + const now = new Date() + const nowIso = now.toISOString() + // 更新会话窗口状态 accountData.sessionWindowStatus = status - accountData.sessionWindowStatusUpdatedAt = new Date().toISOString() + accountData.sessionWindowStatusUpdatedAt = nowIso // 如果状态是 allowed_warning 且账户设置了自动停止调度 if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { - logger.warn( - `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` - ) - accountData.schedulable = 'false' - // 使用独立的5小时限制自动停止标记 - accountData.fiveHourAutoStopped = 'true' - accountData.fiveHourStoppedAt = new Date().toISOString() - // 设置停止原因,供前端显示 - accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度' + const alreadyAutoStopped = + accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true' - // 发送Webhook通知 - try { - const webhookNotifier = require('../utils/webhookNotifier') - await webhookNotifier.sendAccountAnomalyNotification({ - accountId, - accountName: accountData.name || 'Claude Account', - platform: 'claude', - status: 'warning', - errorCode: 'CLAUDE_5H_LIMIT_WARNING', - reason: '5小时使用量接近限制,已自动停止调度', - timestamp: getISOStringWithTimezone(new Date()) - }) - } catch (webhookError) { - logger.error('Failed to send webhook notification:', webhookError) + if (!alreadyAutoStopped) { + const windowIdentifier = + accountData.sessionWindowEnd || accountData.sessionWindowStart || 'unknown' + + let warningCount = 0 + if (accountData.fiveHourWarningWindow === windowIdentifier) { + const parsedCount = parseInt(accountData.fiveHourWarningCount || '0', 10) + warningCount = Number.isNaN(parsedCount) ? 0 : parsedCount + } + + const maxWarningsPerWindow = this.maxFiveHourWarningsPerWindow + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` + ) + accountData.schedulable = 'false' + // 使用独立的5小时限制自动停止标记 + accountData.fiveHourAutoStopped = 'true' + accountData.fiveHourStoppedAt = nowIso + // 设置停止原因,供前端显示 + accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度' + + const canSendWarning = warningCount < maxWarningsPerWindow + let updatedWarningCount = warningCount + + accountData.fiveHourWarningWindow = windowIdentifier + if (canSendWarning) { + updatedWarningCount += 1 + accountData.fiveHourWarningLastSentAt = nowIso + } + accountData.fiveHourWarningCount = updatedWarningCount.toString() + + if (canSendWarning) { + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'warning', + errorCode: 'CLAUDE_5H_LIMIT_WARNING', + reason: '5小时使用量接近限制,已自动停止调度', + timestamp: getISOStringWithTimezone(now) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) reached max ${maxWarningsPerWindow} warning notifications for current 5h window, skipping webhook` + ) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) already auto-stopped for 5h limit, skipping duplicate warning` + ) } } @@ -2692,6 +2783,7 @@ class ClaudeAccountService { updatedAccountData.schedulable = 'true' delete updatedAccountData.fiveHourAutoStopped delete updatedAccountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(account.id, updatedAccountData) delete updatedAccountData.stoppedReason // 更新会话窗口(如果有新窗口)