Merge PR #506: limit 5-hour warning notifications

This commit is contained in:
shaw
2025-10-03 22:25:40 +08:00

View File

@@ -21,6 +21,17 @@ class ClaudeAccountService {
constructor() { constructor() {
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token' this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token'
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' 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' this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
@@ -683,6 +694,8 @@ class ClaudeAccountService {
delete updatedData.stoppedReason delete updatedData.stoppedReason
shouldClearAutoStopFields = true shouldClearAutoStopFields = true
await this._clearFiveHourWarningMetadata(accountId, updatedData)
// 如果是手动启用调度,记录日志 // 如果是手动启用调度,记录日志
if (updates.schedulable === true || updates.schedulable === 'true') { if (updates.schedulable === true || updates.schedulable === 'true') {
logger.info(`✅ Manually enabled scheduling for account ${accountId}`) logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
@@ -1284,14 +1297,17 @@ class ClaudeAccountService {
const accountKey = `claude:account:${accountId}` const accountKey = `claude:account:${accountId}`
// 清除限流状态 // 清除限流状态
const redisKey = `claude:account:${accountId}`
await redis.client.hdel(redisKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitEndAt')
delete accountData.rateLimitedAt delete accountData.rateLimitedAt
delete accountData.rateLimitStatus delete accountData.rateLimitStatus
delete accountData.rateLimitEndAt // 清除限流结束时间 delete accountData.rateLimitEndAt // 清除限流结束时间
const hadAutoStop = accountData.rateLimitAutoStopped === 'true'
// 只恢复因限流而自动停止的账户 // 只恢复因限流而自动停止的账户
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') { if (hadAutoStop && accountData.schedulable === 'false') {
accountData.schedulable = 'true' accountData.schedulable = 'true'
delete accountData.rateLimitAutoStopped
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`) logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
logger.info( logger.info(
`📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}` `📊 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}` ` 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) await redis.setClaudeAccount(accountId, accountData)
// 显式删除Redis中的限流字段避免旧标记阻止账号恢复调度 // 显式删除Redis中的限流字段避免旧标记阻止账号恢复调度
@@ -1467,6 +1488,7 @@ class ClaudeAccountService {
if (accountData.sessionWindowStatus) { if (accountData.sessionWindowStatus) {
delete accountData.sessionWindowStatus delete accountData.sessionWindowStatus
delete accountData.sessionWindowStatusUpdatedAt delete accountData.sessionWindowStatusUpdatedAt
await this._clearFiveHourWarningMetadata(accountId, accountData)
shouldClearSessionStatus = true shouldClearSessionStatus = true
} }
@@ -1478,6 +1500,7 @@ class ClaudeAccountService {
accountData.schedulable = 'true' accountData.schedulable = 'true'
delete accountData.fiveHourAutoStopped delete accountData.fiveHourAutoStopped
delete accountData.fiveHourStoppedAt delete accountData.fiveHourStoppedAt
await this._clearFiveHourWarningMetadata(accountId, accountData)
shouldClearFiveHourFlags = true shouldClearFiveHourFlags = true
// 发送Webhook通知 // 发送Webhook通知
@@ -1537,6 +1560,29 @@ class ClaudeAccountService {
return endTime 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) { async getSessionWindowInfo(accountId) {
try { try {
@@ -2145,6 +2191,9 @@ class ClaudeAccountService {
delete updatedAccountData.fiveHourAutoStopped delete updatedAccountData.fiveHourAutoStopped
delete updatedAccountData.fiveHourStoppedAt delete updatedAccountData.fiveHourStoppedAt
delete updatedAccountData.tempErrorAutoStopped delete updatedAccountData.tempErrorAutoStopped
delete updatedAccountData.fiveHourWarningWindow
delete updatedAccountData.fiveHourWarningCount
delete updatedAccountData.fiveHourWarningLastSentAt
// 兼容旧的标记 // 兼容旧的标记
delete updatedAccountData.autoStoppedAt delete updatedAccountData.autoStoppedAt
delete updatedAccountData.stoppedReason delete updatedAccountData.stoppedReason
@@ -2178,6 +2227,9 @@ class ClaudeAccountService {
'rateLimitAutoStopped', 'rateLimitAutoStopped',
'fiveHourAutoStopped', 'fiveHourAutoStopped',
'fiveHourStoppedAt', 'fiveHourStoppedAt',
'fiveHourWarningWindow',
'fiveHourWarningCount',
'fiveHourWarningLastSentAt',
'tempErrorAutoStopped', 'tempErrorAutoStopped',
// 兼容旧的标记 // 兼容旧的标记
'autoStoppedAt', 'autoStoppedAt',
@@ -2445,36 +2497,75 @@ class ClaudeAccountService {
return return
} }
const now = new Date()
const nowIso = now.toISOString()
// 更新会话窗口状态 // 更新会话窗口状态
accountData.sessionWindowStatus = status accountData.sessionWindowStatus = status
accountData.sessionWindowStatusUpdatedAt = new Date().toISOString() accountData.sessionWindowStatusUpdatedAt = nowIso
// 如果状态是 allowed_warning 且账户设置了自动停止调度 // 如果状态是 allowed_warning 且账户设置了自动停止调度
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
logger.warn( const alreadyAutoStopped =
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true'
)
accountData.schedulable = 'false'
// 使用独立的5小时限制自动停止标记
accountData.fiveHourAutoStopped = 'true'
accountData.fiveHourStoppedAt = new Date().toISOString()
// 设置停止原因,供前端显示
accountData.stoppedReason = '5小时使用量接近限制已自动停止调度'
// 发送Webhook通知 if (!alreadyAutoStopped) {
try { const windowIdentifier =
const webhookNotifier = require('../utils/webhookNotifier') accountData.sessionWindowEnd || accountData.sessionWindowStart || 'unknown'
await webhookNotifier.sendAccountAnomalyNotification({
accountId, let warningCount = 0
accountName: accountData.name || 'Claude Account', if (accountData.fiveHourWarningWindow === windowIdentifier) {
platform: 'claude', const parsedCount = parseInt(accountData.fiveHourWarningCount || '0', 10)
status: 'warning', warningCount = Number.isNaN(parsedCount) ? 0 : parsedCount
errorCode: 'CLAUDE_5H_LIMIT_WARNING', }
reason: '5小时使用量接近限制已自动停止调度',
timestamp: getISOStringWithTimezone(new Date()) const maxWarningsPerWindow = this.maxFiveHourWarningsPerWindow
})
} catch (webhookError) { logger.warn(
logger.error('Failed to send webhook notification:', webhookError) `⚠️ 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' updatedAccountData.schedulable = 'true'
delete updatedAccountData.fiveHourAutoStopped delete updatedAccountData.fiveHourAutoStopped
delete updatedAccountData.fiveHourStoppedAt delete updatedAccountData.fiveHourStoppedAt
await this._clearFiveHourWarningMetadata(account.id, updatedAccountData)
delete updatedAccountData.stoppedReason delete updatedAccountData.stoppedReason
// 更新会话窗口(如果有新窗口) // 更新会话窗口(如果有新窗口)