Merge branch 'dev' into main

This commit is contained in:
Wesley Liddick
2025-09-08 16:14:54 +08:00
committed by GitHub
23 changed files with 1398 additions and 65 deletions

View File

@@ -258,6 +258,126 @@ class ApiKeyService {
}
}
// 🔍 验证API Key仅用于统计查询不触发激活
async validateApiKeyForStats(apiKey) {
try {
if (!apiKey || !apiKey.startsWith(this.prefix)) {
return { valid: false, error: 'Invalid API key format' }
}
// 计算API Key的哈希值
const hashedKey = this._hashApiKey(apiKey)
// 通过哈希值直接查找API Key性能优化
const keyData = await redis.findApiKeyByHash(hashedKey)
if (!keyData) {
return { valid: false, error: 'API key not found' }
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { valid: false, error: 'API key is disabled' }
}
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
// 检查是否过期(仅对已激活的 Key 检查)
if (
keyData.isActivated === 'true' &&
keyData.expiresAt &&
new Date() > new Date(keyData.expiresAt)
) {
return { valid: false, error: 'API key has expired' }
}
// 如果API Key属于某个用户检查用户是否被禁用
if (keyData.userId) {
try {
const userService = require('./userService')
const user = await userService.getUserById(keyData.userId, false)
if (!user || !user.isActive) {
return { valid: false, error: 'User account is disabled' }
}
} catch (userError) {
// 如果用户服务出错记录但不影响API Key验证
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
}
}
// 获取当日费用
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
// 获取使用统计
const usage = await redis.getUsageStats(keyData.id)
// 解析限制模型数据
let restrictedModels = []
try {
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
} catch (e) {
restrictedModels = []
}
// 解析允许的客户端
let allowedClients = []
try {
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
} catch (e) {
allowedClients = []
}
// 解析标签
let tags = []
try {
tags = keyData.tags ? JSON.parse(keyData.tags) : []
} catch (e) {
tags = []
}
return {
valid: true,
keyData: {
id: keyData.id,
name: keyData.name,
description: keyData.description,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
}
}
} catch (error) {
logger.error('❌ API key validation error (stats):', error)
return { valid: false, error: 'Internal validation error' }
}
}
// 📋 获取所有API Keys
async getAllApiKeys(includeDeleted = false) {
try {

View File

@@ -60,7 +60,9 @@ class ClaudeAccountService {
schedulable = true, // 是否可被调度
subscriptionInfo = null, // 手动设置的订阅信息
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent = false // 是否使用统一Claude Code版本的User-Agent
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '' // 统一的客户端标识
} = options
const accountId = uuidv4()
@@ -93,6 +95,8 @@ class ClaudeAccountService {
schedulable: schedulable.toString(), // 是否可被调度
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
// 优先使用手动设置的订阅信息否则使用OAuth数据中的否则默认为空
subscriptionInfo: subscriptionInfo
? JSON.stringify(subscriptionInfo)
@@ -166,7 +170,10 @@ class ClaudeAccountService {
createdAt: accountData.createdAt,
expiresAt: accountData.expiresAt,
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
autoStopOnWarning
autoStopOnWarning,
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId
}
}
@@ -492,6 +499,9 @@ class ClaudeAccountService {
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
// 添加统一User-Agent设置
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
// 添加统一客户端标识设置
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
// 添加停止原因
stoppedReason: account.stoppedReason || null
}
@@ -528,7 +538,9 @@ class ClaudeAccountService {
'schedulable',
'subscriptionInfo',
'autoStopOnWarning',
'useUnifiedUserAgent'
'useUnifiedUserAgent',
'useUnifiedClientId',
'unifiedClientId'
]
const updatedData = { ...accountData }
@@ -1075,6 +1087,8 @@ class ClaudeAccountService {
const updatedAccountData = { ...accountData }
updatedAccountData.rateLimitedAt = new Date().toISOString()
updatedAccountData.rateLimitStatus = 'limited'
// 限流时停止调度,与 OpenAI 账号保持一致
updatedAccountData.schedulable = false
// 如果提供了准确的限流重置时间戳来自API响应头
if (rateLimitResetTimestamp) {
@@ -1159,9 +1173,33 @@ class ClaudeAccountService {
delete accountData.rateLimitedAt
delete accountData.rateLimitStatus
delete accountData.rateLimitEndAt // 清除限流结束时间
// 恢复可调度状态,与 OpenAI 账号保持一致
accountData.schedulable = true
await redis.setClaudeAccount(accountId, accountData)
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
logger.success(
`✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored`
)
// 发送 Webhook 通知限流已解除
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude-oauth',
status: 'recovered',
errorCode: 'CLAUDE_OAUTH_RATE_LIMIT_CLEARED',
reason: 'Rate limit has been cleared and account is now schedulable',
timestamp: getISOStringWithTimezone(new Date())
})
logger.info(
`📢 Webhook notification sent for Claude account ${accountData.name} rate limit cleared`
)
} catch (webhookError) {
logger.error('Failed to send rate limit cleared webhook notification:', webhookError)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)

View File

@@ -400,6 +400,7 @@ class ClaudeConsoleAccountService {
rateLimitedAt: new Date().toISOString(),
rateLimitStatus: 'limited',
isActive: 'false', // 禁用账户
schedulable: 'false', // 停止调度,与其他平台保持一致
errorMessage: `Rate limited at ${new Date().toISOString()}`
}
@@ -468,6 +469,7 @@ class ClaudeConsoleAccountService {
// 没有额度限制,完全恢复
await client.hset(accountKey, {
isActive: 'true',
schedulable: 'true', // 恢复调度,与其他平台保持一致
status: 'active',
errorMessage: ''
})
@@ -1131,6 +1133,66 @@ class ClaudeConsoleAccountService {
return null
}
}
// 🔄 重置账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 准备要更新的字段
const updates = {
status: 'active',
errorMessage: '',
schedulable: 'true',
isActive: 'true' // 重要必须恢复isActive状态
}
// 删除所有异常状态相关的字段
const fieldsToDelete = [
'rateLimitedAt',
'rateLimitStatus',
'unauthorizedAt',
'unauthorizedCount',
'overloadedAt',
'overloadStatus',
'blockedAt',
'quotaStoppedAt'
]
// 执行更新
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'claude-console',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
throw error
}
}
}
module.exports = new ClaudeConsoleAccountService()

View File

@@ -126,8 +126,11 @@ class ClaudeRelayService {
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
@@ -344,7 +347,7 @@ class ClaudeRelayService {
}
// 🔄 处理请求体
_processRequestBody(body, clientHeaders = {}) {
_processRequestBody(body, clientHeaders = {}, account = null) {
if (!body) {
return body
}
@@ -446,9 +449,31 @@ class ClaudeRelayService {
delete processedBody.top_p
}
// 处理统一的客户端标识
if (account && account.useUnifiedClientId && account.unifiedClientId) {
this._replaceClientId(processedBody, account.unifiedClientId)
}
return processedBody
}
// 🔄 替换请求中的客户端标识
_replaceClientId(body, unifiedClientId) {
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
return
}
const userId = body.metadata.user_id
// user_id格式user_{64位十六进制}_account__session_{uuid}
// 只替换第一个下划线后到_account之前的部分客户端标识
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
if (match && match[1]) {
// 替换客户端标识部分
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
}
}
// 🔢 验证并限制max_tokens参数
_validateAndLimitMaxTokens(body) {
if (!body || !body.max_tokens) {
@@ -660,16 +685,13 @@ class ClaudeRelayService {
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
const userAgent =
unifiedUA ||
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
'claude-cli/1.0.102 (external, cli)'
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
}
logger.info(`🔗 指纹是这个: ${options.headers['User-Agent']}`)
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值
const betaHeader =
@@ -840,8 +862,11 @@ class ClaudeRelayService {
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
@@ -931,14 +956,13 @@ class ClaudeRelayService {
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
const userAgent =
unifiedUA ||
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
'claude-cli/1.0.102 (external, cli)'
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
}
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值
const betaHeader =
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader

View File

@@ -814,14 +814,37 @@ function isRateLimited(account) {
}
// 设置账户限流状态
async function setAccountRateLimited(accountId, isLimited) {
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) {
const updates = {
rateLimitStatus: isLimited ? 'limited' : 'normal',
rateLimitedAt: isLimited ? new Date().toISOString() : null
rateLimitedAt: isLimited ? new Date().toISOString() : null,
// 限流时停止调度,解除限流时恢复调度
schedulable: isLimited ? 'false' : 'true'
}
// 如果提供了重置时间(秒数),计算重置时间戳
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.info(
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
)
} else if (isLimited) {
// 如果没有提供重置时间使用默认的60分钟
const defaultResetSeconds = 60 * 60 // 1小时
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.warn(
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
)
} else if (!isLimited) {
updates.rateLimitResetAt = null
}
await updateAccount(accountId, updates)
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
logger.info(
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
)
// 如果被限流,发送 Webhook 通知
if (isLimited) {
@@ -834,7 +857,9 @@ async function setAccountRateLimited(accountId, isLimited) {
platform: 'openai',
status: 'blocked',
errorCode: 'OPENAI_RATE_LIMITED',
reason: 'Account rate limited (429 error). Estimated reset in 1 hour',
reason: resetsInSeconds
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
: 'Account rate limited (429 error). Estimated reset in 1 hour',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
@@ -844,6 +869,48 @@ async function setAccountRateLimited(accountId, isLimited) {
}
}
// 🔄 重置账户所有异常状态
async function resetAccountStatus(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 accessToken 来设置 status
status: account.accessToken ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: null,
rateLimitedAt: null,
rateLimitStatus: 'normal',
rateLimitResetAt: null
}
await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return { success: true, message: 'Account status reset successfully' }
}
// 切换账户调度状态
async function toggleSchedulable(accountId) {
const account = await getAccount(accountId)
@@ -873,15 +940,26 @@ async function getAccountRateLimitInfo(accountId) {
return null
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
if (account.rateLimitStatus === 'limited') {
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
let remainingTime = 0
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt).getTime()
remainingTime = Math.max(0, resetAt - now)
}
// 回退到使用 rateLimitedAt + 默认1小时
else if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const limitDuration = 60 * 60 * 1000 // 默认1小时
remainingTime = Math.max(0, limitedAt + limitDuration - now)
}
return {
isRateLimited: remainingTime > 0,
rateLimitedAt: account.rateLimitedAt,
rateLimitResetAt: account.rateLimitResetAt,
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
}
}
@@ -889,6 +967,7 @@ async function getAccountRateLimitInfo(accountId) {
return {
isRateLimited: false,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
}
}
@@ -926,6 +1005,7 @@ module.exports = {
refreshAccountToken,
isTokenExpired,
setAccountRateLimited,
resetAccountStatus,
toggleSchedulable,
getAccountRateLimitInfo,
updateAccountUsage,

View File

@@ -0,0 +1,351 @@
/**
* 限流状态自动清理服务
* 定期检查并清理所有类型账号的过期限流状态
*/
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)`
)
}
// 清空已清理账户列表
this.clearedAccounts = []
// 记录错误
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.isRunning = false
}
}
/**
* 清理 OpenAI 账号的过期限流
*/
async cleanupOpenAIAccounts(result) {
try {
const accounts = await openaiAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited') {
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 {
const accounts = await claudeAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
result.checked++
try {
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (!isStillLimited) {
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
})
}
}
}
} 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) {
// 检查两种状态字段rateLimitStatus 和 status
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
const hasStatusRateLimited = account.status === 'rate_limited'
if (hasRateLimitStatus || hasStatusRateLimited) {
result.checked++
try {
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
account.id
)
if (!isStillLimited) {
result.cleared++
// 如果 status 字段是 rate_limited需要额外清理
if (hasStatusRateLimited && !hasRateLimitStatus) {
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

View File

@@ -303,10 +303,10 @@ class UnifiedOpenAIScheduler {
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
try {
if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, true)
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds)
}
// 删除会话映射
@@ -349,12 +349,30 @@ class UnifiedOpenAIScheduler {
return false
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
if (account.rateLimitStatus === 'limited') {
// 如果有具体的重置时间,使用它
if (account.rateLimitResetAt) {
const resetTime = new Date(account.rateLimitResetAt).getTime()
const now = Date.now()
const isStillLimited = now < resetTime
return now < limitedAt + limitDuration
// 如果已经过了重置时间,自动清除限流状态
if (!isStillLimited) {
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
await openaiAccountService.setAccountRateLimited(accountId, false)
return false
}
return isStillLimited
}
// 如果没有具体的重置时间使用默认的1小时
if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
}
return false
} catch (error) {

View File

@@ -375,6 +375,7 @@ class WebhookService {
quotaWarning: '📊 配额警告',
systemError: '❌ 系统错误',
securityAlert: '🔒 安全警报',
rateLimitRecovery: '🎉 限流恢复通知',
test: '🧪 测试通知'
}
@@ -390,6 +391,7 @@ class WebhookService {
quotaWarning: 'active',
systemError: 'critical',
securityAlert: 'critical',
rateLimitRecovery: 'active',
test: 'passive'
}
@@ -405,6 +407,7 @@ class WebhookService {
quotaWarning: 'bell',
systemError: 'alert',
securityAlert: 'alarm',
rateLimitRecovery: 'success',
test: 'default'
}
@@ -470,6 +473,14 @@ class WebhookService {
lines.push(`**平台**: ${data.platform}`)
}
if (data.platforms) {
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
}
if (data.totalAccounts) {
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
}
if (data.status) {
lines.push(`**状态**: ${data.status}`)
}
@@ -539,6 +550,7 @@ class WebhookService {
quotaWarning: 'yellow',
systemError: 'red',
securityAlert: 'red',
rateLimitRecovery: 'green',
test: 'blue'
}
@@ -554,6 +566,7 @@ class WebhookService {
quotaWarning: ':chart_with_downwards_trend:',
systemError: ':x:',
securityAlert: ':lock:',
rateLimitRecovery: ':tada:',
test: ':test_tube:'
}
@@ -569,6 +582,7 @@ class WebhookService {
quotaWarning: 0xffeb3b, // 黄色
systemError: 0xf44336, // 红色
securityAlert: 0xf44336, // 红色
rateLimitRecovery: 0x4caf50, // 绿色
test: 0x2196f3 // 蓝色
}