mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
主要改进: 1. 创建 mapExpiryField() 工具函数统一处理前后端字段映射(expiresAt -> subscriptionExpiresAt) 2. 统一 subscriptionExpiresAt 初始值为 null(替代空字符串) 3. 规范过期检查方法名为 isSubscriptionExpired(),返回 true 表示已过期 4. 优化过期检查条件判断,只检查 null 而非空字符串 5. 补充 OpenAI-Responses 和调度器中缺失的过期检查逻辑 6. 添加代码评审文档记录未修复问题 影响范围: - 所有 9 种账户服务的过期字段处理 - admin.js 中所有账户更新路由 - 统一调度器的过期账户过滤逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
3049 lines
111 KiB
JavaScript
3049 lines
111 KiB
JavaScript
const { v4: uuidv4 } = require('uuid')
|
||
const crypto = require('crypto')
|
||
const ProxyHelper = require('../utils/proxyHelper')
|
||
const axios = require('axios')
|
||
const redis = require('../models/redis')
|
||
const config = require('../../config/config')
|
||
const logger = require('../utils/logger')
|
||
const { maskToken } = require('../utils/tokenMask')
|
||
const {
|
||
logRefreshStart,
|
||
logRefreshSuccess,
|
||
logRefreshError,
|
||
logTokenUsage,
|
||
logRefreshSkipped
|
||
} = require('../utils/tokenRefreshLogger')
|
||
const tokenRefreshService = require('./tokenRefreshService')
|
||
const LRUCache = require('../utils/lruCache')
|
||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||
|
||
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'
|
||
this.ENCRYPTION_SALT = 'salt'
|
||
|
||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||
this._encryptionKeyCache = null
|
||
|
||
// 🔄 解密结果缓存,提高解密性能
|
||
this._decryptCache = new LRUCache(500)
|
||
|
||
// 🧹 定期清理缓存(每10分钟)
|
||
setInterval(
|
||
() => {
|
||
this._decryptCache.cleanup()
|
||
logger.info('🧹 Claude decrypt cache cleanup completed', this._decryptCache.getStats())
|
||
},
|
||
10 * 60 * 1000
|
||
)
|
||
}
|
||
|
||
// 🏢 创建Claude账户
|
||
async createAccount(options = {}) {
|
||
const {
|
||
name = 'Unnamed Account',
|
||
description = '',
|
||
email = '',
|
||
password = '',
|
||
refreshToken = '',
|
||
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||
isActive = true,
|
||
accountType = 'shared', // 'dedicated' or 'shared'
|
||
platform = 'claude',
|
||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||
schedulable = true, // 是否可被调度
|
||
subscriptionInfo = null, // 手动设置的订阅信息
|
||
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
|
||
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
|
||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||
unifiedClientId = '', // 统一的客户端标识
|
||
expiresAt = null // 账户订阅到期时间
|
||
} = options
|
||
|
||
const accountId = uuidv4()
|
||
|
||
let accountData
|
||
|
||
if (claudeAiOauth) {
|
||
// 使用Claude标准格式的OAuth数据
|
||
accountData = {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email: this._encryptSensitiveData(email),
|
||
password: this._encryptSensitiveData(password),
|
||
claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)),
|
||
accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken),
|
||
refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken),
|
||
expiresAt: claudeAiOauth.expiresAt.toString(),
|
||
scopes: claudeAiOauth.scopes.join(' '),
|
||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||
isActive: isActive.toString(),
|
||
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
|
||
platform,
|
||
priority: priority.toString(), // 调度优先级
|
||
createdAt: new Date().toISOString(),
|
||
lastUsedAt: '',
|
||
lastRefreshAt: '',
|
||
status: 'active', // 有OAuth数据的账户直接设为active
|
||
errorMessage: '',
|
||
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)
|
||
: claudeAiOauth.subscriptionInfo
|
||
? JSON.stringify(claudeAiOauth.subscriptionInfo)
|
||
: '',
|
||
// 账户订阅到期时间
|
||
subscriptionExpiresAt: expiresAt || ''
|
||
}
|
||
} else {
|
||
// 兼容旧格式
|
||
accountData = {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email: this._encryptSensitiveData(email),
|
||
password: this._encryptSensitiveData(password),
|
||
refreshToken: this._encryptSensitiveData(refreshToken),
|
||
accessToken: '',
|
||
expiresAt: '',
|
||
scopes: '',
|
||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||
isActive: isActive.toString(),
|
||
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
|
||
platform,
|
||
priority: priority.toString(), // 调度优先级
|
||
createdAt: new Date().toISOString(),
|
||
lastUsedAt: '',
|
||
lastRefreshAt: '',
|
||
status: 'created', // created, active, expired, error
|
||
errorMessage: '',
|
||
schedulable: schedulable.toString(), // 是否可被调度
|
||
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
||
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
||
// 手动设置的订阅信息
|
||
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '',
|
||
// 账户订阅到期时间
|
||
subscriptionExpiresAt: expiresAt || ''
|
||
}
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
logger.success(`🏢 Created Claude account: ${name} (${accountId})`)
|
||
|
||
// 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息
|
||
if (claudeAiOauth && claudeAiOauth.accessToken) {
|
||
// 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有)
|
||
const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile')
|
||
|
||
if (hasProfileScope) {
|
||
try {
|
||
const agent = this._createProxyAgent(proxy)
|
||
await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent)
|
||
logger.info(`📊 Successfully fetched profile info for new account: ${name}`)
|
||
} catch (profileError) {
|
||
logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`)
|
||
}
|
||
} else {
|
||
logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`)
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email,
|
||
isActive,
|
||
proxy,
|
||
accountType,
|
||
platform,
|
||
priority,
|
||
status: accountData.status,
|
||
createdAt: accountData.createdAt,
|
||
expiresAt: accountData.expiresAt,
|
||
subscriptionExpiresAt:
|
||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
||
? accountData.subscriptionExpiresAt
|
||
: null,
|
||
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
|
||
autoStopOnWarning,
|
||
useUnifiedUserAgent,
|
||
useUnifiedClientId,
|
||
unifiedClientId
|
||
}
|
||
}
|
||
|
||
// 🔄 刷新Claude账户token
|
||
async refreshAccountToken(accountId) {
|
||
let lockAcquired = false
|
||
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
const refreshToken = this._decryptSensitiveData(accountData.refreshToken)
|
||
|
||
if (!refreshToken) {
|
||
throw new Error('No refresh token available - manual token update required')
|
||
}
|
||
|
||
// 尝试获取分布式锁
|
||
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude')
|
||
|
||
if (!lockAcquired) {
|
||
// 如果无法获取锁,说明另一个进程正在刷新
|
||
logger.info(
|
||
`🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})`
|
||
)
|
||
logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked')
|
||
|
||
// 等待一段时间后返回,期望其他进程已完成刷新
|
||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||
|
||
// 重新获取账户数据(可能已被其他进程刷新)
|
||
const updatedData = await redis.getClaudeAccount(accountId)
|
||
if (updatedData && updatedData.accessToken) {
|
||
const accessToken = this._decryptSensitiveData(updatedData.accessToken)
|
||
return {
|
||
success: true,
|
||
accessToken,
|
||
expiresAt: updatedData.expiresAt
|
||
}
|
||
}
|
||
|
||
throw new Error('Token refresh in progress by another process')
|
||
}
|
||
|
||
// 记录开始刷新
|
||
logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh')
|
||
logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`)
|
||
|
||
// 创建代理agent
|
||
const agent = this._createProxyAgent(accountData.proxy)
|
||
|
||
const response = await axios.post(
|
||
this.claudeApiUrl,
|
||
{
|
||
grant_type: 'refresh_token',
|
||
refresh_token: refreshToken,
|
||
client_id: this.claudeOauthClientId
|
||
},
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json, text/plain, */*',
|
||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||
'Accept-Language': 'en-US,en;q=0.9',
|
||
Referer: 'https://claude.ai/',
|
||
Origin: 'https://claude.ai'
|
||
},
|
||
httpsAgent: agent,
|
||
timeout: 30000
|
||
}
|
||
)
|
||
|
||
if (response.status === 200) {
|
||
// 记录完整的响应数据到专门的认证详细日志
|
||
logger.authDetail('Token refresh response', response.data)
|
||
|
||
// 记录简化版本到主日志
|
||
logger.info('📊 Token refresh response (analyzing for subscription info):', {
|
||
status: response.status,
|
||
hasData: !!response.data,
|
||
dataKeys: response.data ? Object.keys(response.data) : []
|
||
})
|
||
|
||
const { access_token, refresh_token, expires_in } = response.data
|
||
|
||
// 检查是否有套餐信息
|
||
if (
|
||
response.data.subscription ||
|
||
response.data.plan ||
|
||
response.data.tier ||
|
||
response.data.account_type
|
||
) {
|
||
const subscriptionInfo = {
|
||
subscription: response.data.subscription,
|
||
plan: response.data.plan,
|
||
tier: response.data.tier,
|
||
accountType: response.data.account_type,
|
||
features: response.data.features,
|
||
limits: response.data.limits
|
||
}
|
||
logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo)
|
||
|
||
// 将套餐信息存储在账户数据中
|
||
accountData.subscriptionInfo = JSON.stringify(subscriptionInfo)
|
||
}
|
||
|
||
// 更新账户数据
|
||
accountData.accessToken = this._encryptSensitiveData(access_token)
|
||
accountData.refreshToken = this._encryptSensitiveData(refresh_token)
|
||
accountData.expiresAt = (Date.now() + expires_in * 1000).toString()
|
||
accountData.lastRefreshAt = new Date().toISOString()
|
||
accountData.status = 'active'
|
||
accountData.errorMessage = ''
|
||
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
// 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息
|
||
// 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有)
|
||
const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile')
|
||
|
||
if (hasProfileScope) {
|
||
try {
|
||
await this.fetchAndUpdateAccountProfile(accountId, access_token, agent)
|
||
} catch (profileError) {
|
||
logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`)
|
||
}
|
||
} else {
|
||
logger.debug(
|
||
`⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)`
|
||
)
|
||
}
|
||
|
||
// 记录刷新成功
|
||
logRefreshSuccess(accountId, accountData.name, 'claude', {
|
||
accessToken: access_token,
|
||
refreshToken: refresh_token,
|
||
expiresAt: accountData.expiresAt,
|
||
scopes: accountData.scopes
|
||
})
|
||
|
||
logger.success(
|
||
`🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}`
|
||
)
|
||
|
||
return {
|
||
success: true,
|
||
accessToken: access_token,
|
||
expiresAt: accountData.expiresAt
|
||
}
|
||
} else {
|
||
throw new Error(`Token refresh failed with status: ${response.status}`)
|
||
}
|
||
} catch (error) {
|
||
// 记录刷新失败
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (accountData) {
|
||
logRefreshError(accountId, accountData.name, 'claude', error)
|
||
accountData.status = 'error'
|
||
accountData.errorMessage = error.message
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
// 发送Webhook通知
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: accountData.name,
|
||
platform: 'claude-oauth',
|
||
status: 'error',
|
||
errorCode: 'CLAUDE_OAUTH_ERROR',
|
||
reason: `Token refresh failed: ${error.message}`
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error('Failed to send webhook notification:', webhookError)
|
||
}
|
||
}
|
||
|
||
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error)
|
||
|
||
throw error
|
||
} finally {
|
||
// 释放锁
|
||
if (lockAcquired) {
|
||
await tokenRefreshService.releaseRefreshLock(accountId, 'claude')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🔍 获取账户信息
|
||
async getAccount(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null
|
||
}
|
||
|
||
return accountData
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get Claude account:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 🎯 获取有效的访问token
|
||
async getValidAccessToken(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
if (accountData.isActive !== 'true') {
|
||
throw new Error('Account is disabled')
|
||
}
|
||
|
||
// 检查token是否过期
|
||
const expiresAt = parseInt(accountData.expiresAt)
|
||
const now = Date.now()
|
||
const isExpired = !expiresAt || now >= expiresAt - 60000 // 60秒提前刷新
|
||
|
||
// 记录token使用情况
|
||
logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired)
|
||
|
||
if (isExpired) {
|
||
logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`)
|
||
try {
|
||
const refreshResult = await this.refreshAccountToken(accountId)
|
||
return refreshResult.accessToken
|
||
} catch (refreshError) {
|
||
logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`)
|
||
// 如果刷新失败,仍然尝试使用当前token(可能是手动添加的长期有效token)
|
||
const currentToken = this._decryptSensitiveData(accountData.accessToken)
|
||
if (currentToken) {
|
||
logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`)
|
||
return currentToken
|
||
}
|
||
throw refreshError
|
||
}
|
||
}
|
||
|
||
const accessToken = this._decryptSensitiveData(accountData.accessToken)
|
||
|
||
if (!accessToken) {
|
||
throw new Error('No access token available')
|
||
}
|
||
|
||
// 更新最后使用时间和会话窗口
|
||
accountData.lastUsedAt = new Date().toISOString()
|
||
await this.updateSessionWindow(accountId, accountData)
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
return accessToken
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 📋 获取所有Claude账户
|
||
async getAllAccounts() {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
|
||
// 处理返回数据,移除敏感信息并添加限流状态和会话窗口信息
|
||
const processedAccounts = await Promise.all(
|
||
accounts.map(async (account) => {
|
||
// 获取限流状态信息
|
||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id)
|
||
|
||
// 获取会话窗口信息
|
||
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
||
|
||
// 构建 Claude Usage 快照(从 Redis 读取)
|
||
const claudeUsage = this.buildClaudeUsageSnapshot(account)
|
||
|
||
// 判断授权类型:检查 scopes 是否包含 OAuth 相关权限
|
||
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||
|
||
return {
|
||
id: account.id,
|
||
name: account.name,
|
||
description: account.description,
|
||
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
|
||
isActive: account.isActive === 'true',
|
||
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
||
status: account.status,
|
||
errorMessage: account.errorMessage,
|
||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
||
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
||
authType, // OAuth 或 Setup Token
|
||
createdAt: account.createdAt,
|
||
lastUsedAt: account.lastUsedAt,
|
||
lastRefreshAt: account.lastRefreshAt,
|
||
expiresAt: account.expiresAt || null,
|
||
subscriptionExpiresAt:
|
||
account.subscriptionExpiresAt && account.subscriptionExpiresAt !== ''
|
||
? account.subscriptionExpiresAt
|
||
: null,
|
||
// 添加 scopes 字段用于判断认证方式
|
||
// 处理空字符串的情况,避免返回 ['']
|
||
scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [],
|
||
// 添加 refreshToken 是否存在的标记(不返回实际值)
|
||
hasRefreshToken: !!account.refreshToken,
|
||
// 添加套餐信息(如果存在)
|
||
subscriptionInfo: account.subscriptionInfo
|
||
? JSON.parse(account.subscriptionInfo)
|
||
: null,
|
||
// 添加限流状态信息
|
||
rateLimitStatus: rateLimitInfo
|
||
? {
|
||
isRateLimited: rateLimitInfo.isRateLimited,
|
||
rateLimitedAt: rateLimitInfo.rateLimitedAt,
|
||
minutesRemaining: rateLimitInfo.minutesRemaining
|
||
}
|
||
: null,
|
||
// 添加会话窗口信息
|
||
sessionWindow: sessionWindowInfo || {
|
||
hasActiveWindow: false,
|
||
windowStart: null,
|
||
windowEnd: null,
|
||
progress: 0,
|
||
remainingTime: null,
|
||
lastRequestTime: null
|
||
},
|
||
// 添加 Claude Usage 信息(三窗口)
|
||
claudeUsage: claudeUsage || null,
|
||
// 添加调度状态
|
||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||
// 添加自动停止调度设置
|
||
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
||
// 添加5小时自动停止状态
|
||
fiveHourAutoStopped: account.fiveHourAutoStopped === 'true',
|
||
fiveHourStoppedAt: account.fiveHourStoppedAt || null,
|
||
// 添加统一User-Agent设置
|
||
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
||
// 添加统一客户端标识设置
|
||
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
|
||
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
|
||
// 添加停止原因
|
||
stoppedReason: account.stoppedReason || null
|
||
}
|
||
})
|
||
)
|
||
|
||
return processedAccounts
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get Claude accounts:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 📋 获取单个账号的概要信息(用于前端展示会话窗口等状态)
|
||
async getAccountOverview(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null
|
||
}
|
||
|
||
const [sessionWindowInfo, rateLimitInfo] = await Promise.all([
|
||
this.getSessionWindowInfo(accountId),
|
||
this.getAccountRateLimitInfo(accountId)
|
||
])
|
||
|
||
const sessionWindow = sessionWindowInfo || {
|
||
hasActiveWindow: false,
|
||
windowStart: null,
|
||
windowEnd: null,
|
||
progress: 0,
|
||
remainingTime: null,
|
||
lastRequestTime: accountData.lastRequestTime || null,
|
||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||
}
|
||
|
||
const rateLimitStatus = rateLimitInfo
|
||
? {
|
||
isRateLimited: !!rateLimitInfo.isRateLimited,
|
||
rateLimitedAt: rateLimitInfo.rateLimitedAt || null,
|
||
minutesRemaining: rateLimitInfo.minutesRemaining || 0,
|
||
rateLimitEndAt: rateLimitInfo.rateLimitEndAt || null
|
||
}
|
||
: {
|
||
isRateLimited: false,
|
||
rateLimitedAt: null,
|
||
minutesRemaining: 0,
|
||
rateLimitEndAt: null
|
||
}
|
||
|
||
return {
|
||
id: accountData.id,
|
||
accountType: accountData.accountType || 'shared',
|
||
platform: accountData.platform || 'claude',
|
||
isActive: accountData.isActive === 'true',
|
||
schedulable: accountData.schedulable !== 'false',
|
||
sessionWindow,
|
||
rateLimitStatus
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to build Claude account overview for ${accountId}:`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 📝 更新Claude账户
|
||
async updateAccount(accountId, updates) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
const allowedUpdates = [
|
||
'name',
|
||
'description',
|
||
'email',
|
||
'password',
|
||
'refreshToken',
|
||
'proxy',
|
||
'isActive',
|
||
'claudeAiOauth',
|
||
'accountType',
|
||
'priority',
|
||
'schedulable',
|
||
'subscriptionInfo',
|
||
'autoStopOnWarning',
|
||
'useUnifiedUserAgent',
|
||
'useUnifiedClientId',
|
||
'unifiedClientId',
|
||
'subscriptionExpiresAt'
|
||
]
|
||
const updatedData = { ...accountData }
|
||
let shouldClearAutoStopFields = false
|
||
|
||
// 检查是否新增了 refresh token
|
||
const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken)
|
||
|
||
for (const [field, value] of Object.entries(updates)) {
|
||
if (allowedUpdates.includes(field)) {
|
||
if (['email', 'password', 'refreshToken'].includes(field)) {
|
||
updatedData[field] = this._encryptSensitiveData(value)
|
||
} else if (field === 'proxy') {
|
||
updatedData[field] = value ? JSON.stringify(value) : ''
|
||
} else if (field === 'priority') {
|
||
updatedData[field] = value.toString()
|
||
} else if (field === 'subscriptionInfo') {
|
||
// 处理订阅信息更新
|
||
updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value)
|
||
} else if (field === 'subscriptionExpiresAt') {
|
||
// 处理订阅到期时间,允许 null 值(永不过期)
|
||
updatedData[field] = value ? value.toString() : ''
|
||
} else if (field === 'claudeAiOauth') {
|
||
// 更新 Claude AI OAuth 数据
|
||
if (value) {
|
||
updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value))
|
||
updatedData.accessToken = this._encryptSensitiveData(value.accessToken)
|
||
updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken)
|
||
updatedData.expiresAt = value.expiresAt.toString()
|
||
updatedData.scopes = value.scopes.join(' ')
|
||
updatedData.status = 'active'
|
||
updatedData.errorMessage = ''
|
||
updatedData.lastRefreshAt = new Date().toISOString()
|
||
}
|
||
} else {
|
||
updatedData[field] = value !== null && value !== undefined ? value.toString() : ''
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟
|
||
if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) {
|
||
const newExpiresAt = Date.now() + 10 * 60 * 1000 // 10分钟
|
||
updatedData.expiresAt = newExpiresAt.toString()
|
||
logger.info(
|
||
`🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes`
|
||
)
|
||
}
|
||
|
||
// 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token
|
||
if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) {
|
||
// 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟
|
||
const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt)
|
||
const now = Date.now()
|
||
const oneHour = 60 * 60 * 1000
|
||
|
||
if (providedExpiry - now > oneHour) {
|
||
const newExpiresAt = now + 10 * 60 * 1000 // 10分钟
|
||
updatedData.expiresAt = newExpiresAt.toString()
|
||
logger.info(
|
||
`🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token`
|
||
)
|
||
}
|
||
}
|
||
|
||
updatedData.updatedAt = new Date().toISOString()
|
||
|
||
// 如果是手动修改调度状态,清除所有自动停止相关的字段
|
||
if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) {
|
||
// 清除所有自动停止的标记,防止自动恢复
|
||
delete updatedData.rateLimitAutoStopped
|
||
delete updatedData.fiveHourAutoStopped
|
||
delete updatedData.fiveHourStoppedAt
|
||
delete updatedData.tempErrorAutoStopped
|
||
// 兼容旧的标记(逐步迁移)
|
||
delete updatedData.autoStoppedAt
|
||
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}`)
|
||
} else {
|
||
logger.info(`⛔ Manually disabled scheduling for account ${accountId}`)
|
||
}
|
||
}
|
||
|
||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||
if (updates.isActive === 'false' && accountData.isActive === 'true') {
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: updatedData.name || 'Unknown Account',
|
||
platform: 'claude-oauth',
|
||
status: 'disabled',
|
||
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
|
||
reason: 'Account manually disabled by administrator'
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error(
|
||
'Failed to send webhook notification for manual account disable:',
|
||
webhookError
|
||
)
|
||
}
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, updatedData)
|
||
|
||
if (shouldClearAutoStopFields) {
|
||
const fieldsToRemove = [
|
||
'rateLimitAutoStopped',
|
||
'fiveHourAutoStopped',
|
||
'fiveHourStoppedAt',
|
||
'tempErrorAutoStopped',
|
||
'autoStoppedAt',
|
||
'stoppedReason'
|
||
]
|
||
await this._removeAccountFields(accountId, fieldsToRemove, 'manual_schedule_update')
|
||
}
|
||
|
||
logger.success(`📝 Updated Claude account: ${accountId}`)
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update Claude account:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🗑️ 删除Claude账户
|
||
async deleteAccount(accountId) {
|
||
try {
|
||
// 首先从所有分组中移除此账户
|
||
const accountGroupService = require('./accountGroupService')
|
||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||
|
||
const result = await redis.deleteClaudeAccount(accountId)
|
||
|
||
if (result === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
logger.success(`🗑️ Deleted Claude account: ${accountId}`)
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error('❌ Failed to delete Claude account:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查账户订阅是否过期
|
||
* @param {Object} account - 账户对象
|
||
* @returns {boolean} - true: 已过期, false: 未过期
|
||
*/
|
||
isSubscriptionExpired(account) {
|
||
if (!account.subscriptionExpiresAt) {
|
||
return false // 未设置过期时间,视为永不过期
|
||
}
|
||
|
||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||
const now = new Date()
|
||
|
||
if (expiryDate <= now) {
|
||
logger.debug(
|
||
`⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
||
)
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// 🎯 智能选择可用账户(支持sticky会话和模型过滤)
|
||
async selectAvailableAccount(sessionHash = null, modelName = null) {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
|
||
let activeAccounts = accounts.filter(
|
||
(account) =>
|
||
account.isActive === 'true' &&
|
||
account.status !== 'error' &&
|
||
account.schedulable !== 'false' &&
|
||
!this.isSubscriptionExpired(account)
|
||
)
|
||
|
||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||
activeAccounts = activeAccounts.filter((account) => {
|
||
// 检查账号的订阅信息
|
||
if (account.subscriptionInfo) {
|
||
try {
|
||
const info = JSON.parse(account.subscriptionInfo)
|
||
// Pro 和 Free 账号不支持 Opus
|
||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||
return false // Claude Pro 不支持 Opus
|
||
}
|
||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||
}
|
||
} catch (e) {
|
||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||
return true
|
||
}
|
||
}
|
||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||
return true
|
||
})
|
||
|
||
if (activeAccounts.length === 0) {
|
||
throw new Error('No Claude accounts available that support Opus model')
|
||
}
|
||
}
|
||
|
||
if (activeAccounts.length === 0) {
|
||
throw new Error('No active Claude accounts available')
|
||
}
|
||
|
||
// 如果有会话哈希,检查是否有已映射的账户
|
||
if (sessionHash) {
|
||
const mappedAccountId = await redis.getSessionAccountMapping(sessionHash)
|
||
if (mappedAccountId) {
|
||
// 验证映射的账户是否仍然可用
|
||
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
|
||
if (mappedAccount) {
|
||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||
logger.info(
|
||
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||
)
|
||
return mappedAccountId
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account`
|
||
)
|
||
// 清理无效的映射
|
||
await redis.deleteSessionAccountMapping(sessionHash)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有映射或映射无效,选择新账户
|
||
// 优先选择最久未使用的账户(负载均衡)
|
||
const sortedAccounts = activeAccounts.sort((a, b) => {
|
||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||
return aLastUsed - bLastUsed // 最久未使用的优先
|
||
})
|
||
|
||
const selectedAccountId = sortedAccounts[0].id
|
||
|
||
// 如果有会话哈希,建立新的映射
|
||
if (sessionHash) {
|
||
// 从配置获取TTL(小时),转换为秒
|
||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||
logger.info(
|
||
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||
)
|
||
}
|
||
|
||
return selectedAccountId
|
||
} catch (error) {
|
||
logger.error('❌ Failed to select available account:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤)
|
||
async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) {
|
||
try {
|
||
// 如果API Key绑定了专属账户,优先使用
|
||
if (apiKeyData.claudeAccountId) {
|
||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||
if (
|
||
boundAccount &&
|
||
boundAccount.isActive === 'true' &&
|
||
boundAccount.status !== 'error' &&
|
||
boundAccount.schedulable !== 'false' &&
|
||
!this.isSubscriptionExpired(boundAccount)
|
||
) {
|
||
logger.info(
|
||
`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||
)
|
||
return apiKeyData.claudeAccountId
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool`
|
||
)
|
||
}
|
||
}
|
||
|
||
// 如果没有绑定账户或绑定账户不可用,从共享池选择
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
|
||
let sharedAccounts = accounts.filter(
|
||
(account) =>
|
||
account.isActive === 'true' &&
|
||
account.status !== 'error' &&
|
||
account.schedulable !== 'false' &&
|
||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||
!this.isSubscriptionExpired(account)
|
||
)
|
||
|
||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||
sharedAccounts = sharedAccounts.filter((account) => {
|
||
// 检查账号的订阅信息
|
||
if (account.subscriptionInfo) {
|
||
try {
|
||
const info = JSON.parse(account.subscriptionInfo)
|
||
// Pro 和 Free 账号不支持 Opus
|
||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||
return false // Claude Pro 不支持 Opus
|
||
}
|
||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||
}
|
||
} catch (e) {
|
||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||
return true
|
||
}
|
||
}
|
||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||
return true
|
||
})
|
||
|
||
if (sharedAccounts.length === 0) {
|
||
throw new Error('No shared Claude accounts available that support Opus model')
|
||
}
|
||
}
|
||
|
||
if (sharedAccounts.length === 0) {
|
||
throw new Error('No active shared Claude accounts available')
|
||
}
|
||
|
||
// 如果有会话哈希,检查是否有已映射的账户
|
||
if (sessionHash) {
|
||
const mappedAccountId = await redis.getSessionAccountMapping(sessionHash)
|
||
if (mappedAccountId) {
|
||
// 验证映射的账户是否仍然在共享池中且可用
|
||
const mappedAccount = sharedAccounts.find((acc) => acc.id === mappedAccountId)
|
||
if (mappedAccount) {
|
||
// 如果映射的账户被限流了,删除映射并重新选择
|
||
const isRateLimited = await this.isAccountRateLimited(mappedAccountId)
|
||
if (isRateLimited) {
|
||
logger.warn(
|
||
`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`
|
||
)
|
||
await redis.deleteSessionAccountMapping(sessionHash)
|
||
} else {
|
||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||
logger.info(
|
||
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||
)
|
||
return mappedAccountId
|
||
}
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`
|
||
)
|
||
// 清理无效的映射
|
||
await redis.deleteSessionAccountMapping(sessionHash)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 将账户分为限流和非限流两组
|
||
const nonRateLimitedAccounts = []
|
||
const rateLimitedAccounts = []
|
||
|
||
for (const account of sharedAccounts) {
|
||
const isRateLimited = await this.isAccountRateLimited(account.id)
|
||
if (isRateLimited) {
|
||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id)
|
||
account._rateLimitInfo = rateLimitInfo // 临时存储限流信息
|
||
rateLimitedAccounts.push(account)
|
||
} else {
|
||
nonRateLimitedAccounts.push(account)
|
||
}
|
||
}
|
||
|
||
// 优先从非限流账户中选择
|
||
let candidateAccounts = nonRateLimitedAccounts
|
||
|
||
// 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先)
|
||
if (candidateAccounts.length === 0) {
|
||
logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool')
|
||
candidateAccounts = rateLimitedAccounts.sort((a, b) => {
|
||
const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime()
|
||
const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime()
|
||
return aRateLimitedAt - bRateLimitedAt // 最早限流的优先
|
||
})
|
||
} else {
|
||
// 非限流账户按最后使用时间排序(最久未使用的优先)
|
||
candidateAccounts = candidateAccounts.sort((a, b) => {
|
||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||
return aLastUsed - bLastUsed // 最久未使用的优先
|
||
})
|
||
}
|
||
|
||
if (candidateAccounts.length === 0) {
|
||
throw new Error('No available shared Claude accounts')
|
||
}
|
||
|
||
const selectedAccountId = candidateAccounts[0].id
|
||
|
||
// 如果有会话哈希,建立新的映射
|
||
if (sessionHash) {
|
||
// 从配置获取TTL(小时),转换为秒
|
||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||
logger.info(
|
||
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||
)
|
||
}
|
||
|
||
logger.info(
|
||
`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`
|
||
)
|
||
return selectedAccountId
|
||
} catch (error) {
|
||
logger.error('❌ Failed to select account for API key:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🌐 创建代理agent(使用统一的代理工具)
|
||
_createProxyAgent(proxyConfig) {
|
||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||
if (proxyAgent) {
|
||
logger.info(
|
||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||
)
|
||
} else if (proxyConfig) {
|
||
logger.debug('🌐 Failed to create proxy agent for Claude')
|
||
} else {
|
||
logger.debug('🌐 No proxy configured for Claude request')
|
||
}
|
||
return proxyAgent
|
||
}
|
||
|
||
// 🔐 加密敏感数据
|
||
_encryptSensitiveData(data) {
|
||
if (!data) {
|
||
return ''
|
||
}
|
||
|
||
try {
|
||
const key = this._generateEncryptionKey()
|
||
const iv = crypto.randomBytes(16)
|
||
|
||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||
encrypted += cipher.final('hex')
|
||
|
||
// 将IV和加密数据一起返回,用:分隔
|
||
return `${iv.toString('hex')}:${encrypted}`
|
||
} catch (error) {
|
||
logger.error('❌ Encryption error:', error)
|
||
return data
|
||
}
|
||
}
|
||
|
||
// 🔓 解密敏感数据
|
||
_decryptSensitiveData(encryptedData) {
|
||
if (!encryptedData) {
|
||
return ''
|
||
}
|
||
|
||
// 🎯 检查缓存
|
||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||
const cached = this._decryptCache.get(cacheKey)
|
||
if (cached !== undefined) {
|
||
return cached
|
||
}
|
||
|
||
try {
|
||
let decrypted = ''
|
||
|
||
// 检查是否是新格式(包含IV)
|
||
if (encryptedData.includes(':')) {
|
||
// 新格式:iv:encryptedData
|
||
const parts = encryptedData.split(':')
|
||
if (parts.length === 2) {
|
||
const key = this._generateEncryptionKey()
|
||
const iv = Buffer.from(parts[0], 'hex')
|
||
const encrypted = parts[1]
|
||
|
||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||
decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||
decrypted += decipher.final('utf8')
|
||
|
||
// 💾 存入缓存(5分钟过期)
|
||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||
|
||
// 📊 定期打印缓存统计
|
||
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
|
||
this._decryptCache.printStats()
|
||
}
|
||
|
||
return decrypted
|
||
}
|
||
}
|
||
|
||
// 旧格式或格式错误,尝试旧方式解密(向后兼容)
|
||
// 注意:在新版本Node.js中这将失败,但我们会捕获错误
|
||
try {
|
||
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey)
|
||
decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
||
decrypted += decipher.final('utf8')
|
||
|
||
// 💾 旧格式也存入缓存
|
||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||
|
||
return decrypted
|
||
} catch (oldError) {
|
||
// 如果旧方式也失败,返回原数据
|
||
logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message)
|
||
return encryptedData
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Decryption error:', error)
|
||
return encryptedData
|
||
}
|
||
}
|
||
|
||
// 🔑 生成加密密钥(辅助方法)
|
||
_generateEncryptionKey() {
|
||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||
// scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解)
|
||
// 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用
|
||
if (!this._encryptionKeyCache) {
|
||
// 只在第一次调用时计算,后续使用缓存
|
||
// 由于输入参数固定,派生结果永远相同,不影响数据兼容性
|
||
this._encryptionKeyCache = crypto.scryptSync(
|
||
config.security.encryptionKey,
|
||
this.ENCRYPTION_SALT,
|
||
32
|
||
)
|
||
logger.info('🔑 Encryption key derived and cached for performance optimization')
|
||
}
|
||
return this._encryptionKeyCache
|
||
}
|
||
|
||
// 🎭 掩码邮箱地址
|
||
_maskEmail(email) {
|
||
if (!email || !email.includes('@')) {
|
||
return email
|
||
}
|
||
|
||
const [username, domain] = email.split('@')
|
||
const maskedUsername =
|
||
username.length > 2
|
||
? `${username.slice(0, 2)}***${username.slice(-1)}`
|
||
: `${username.slice(0, 1)}***`
|
||
|
||
return `${maskedUsername}@${domain}`
|
||
}
|
||
|
||
// 🔢 安全转换为数字或null
|
||
_toNumberOrNull(value) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return null
|
||
}
|
||
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num : null
|
||
}
|
||
|
||
// 🧹 清理错误账户
|
||
async cleanupErrorAccounts() {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
let cleanedCount = 0
|
||
|
||
for (const account of accounts) {
|
||
if (account.status === 'error' && account.lastRefreshAt) {
|
||
const lastRefresh = new Date(account.lastRefreshAt)
|
||
const now = new Date()
|
||
const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60)
|
||
|
||
// 如果错误状态超过24小时,尝试重新激活
|
||
if (hoursSinceLastRefresh > 24) {
|
||
account.status = 'created'
|
||
account.errorMessage = ''
|
||
await redis.setClaudeAccount(account.id, account)
|
||
cleanedCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedCount > 0) {
|
||
logger.success(`🧹 Reset ${cleanedCount} error accounts`)
|
||
}
|
||
|
||
return cleanedCount
|
||
} catch (error) {
|
||
logger.error('❌ Failed to cleanup error accounts:', error)
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 🚫 标记账号为限流状态
|
||
async markAccountRateLimited(accountId, sessionHash = null, rateLimitResetTimestamp = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 设置限流状态和时间
|
||
const updatedAccountData = { ...accountData }
|
||
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
||
updatedAccountData.rateLimitStatus = 'limited'
|
||
// 限流时停止调度,与 OpenAI 账号保持一致
|
||
updatedAccountData.schedulable = 'false'
|
||
// 使用独立的限流自动停止标记,避免与其他自动停止冲突
|
||
updatedAccountData.rateLimitAutoStopped = 'true'
|
||
|
||
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
||
if (rateLimitResetTimestamp) {
|
||
// 将Unix时间戳(秒)转换为毫秒并创建Date对象
|
||
const resetTime = new Date(rateLimitResetTimestamp * 1000)
|
||
updatedAccountData.rateLimitEndAt = resetTime.toISOString()
|
||
|
||
// 计算当前会话窗口的开始时间(重置时间减去5小时)
|
||
const windowStartTime = new Date(resetTime.getTime() - 5 * 60 * 60 * 1000)
|
||
updatedAccountData.sessionWindowStart = windowStartTime.toISOString()
|
||
updatedAccountData.sessionWindowEnd = resetTime.toISOString()
|
||
|
||
const now = new Date()
|
||
const minutesUntilEnd = Math.ceil((resetTime - now) / (1000 * 60))
|
||
logger.warn(
|
||
`🚫 Account marked as rate limited with accurate reset time: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining until ${resetTime.toISOString()}`
|
||
)
|
||
} else {
|
||
// 获取或创建会话窗口(预估方式)
|
||
const windowData = await this.updateSessionWindow(accountId, updatedAccountData)
|
||
Object.assign(updatedAccountData, windowData)
|
||
|
||
// 限流结束时间 = 会话窗口结束时间
|
||
if (updatedAccountData.sessionWindowEnd) {
|
||
updatedAccountData.rateLimitEndAt = updatedAccountData.sessionWindowEnd
|
||
const windowEnd = new Date(updatedAccountData.sessionWindowEnd)
|
||
const now = new Date()
|
||
const minutesUntilEnd = Math.ceil((windowEnd - now) / (1000 * 60))
|
||
logger.warn(
|
||
`🚫 Account marked as rate limited until estimated session window ends: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining`
|
||
)
|
||
} else {
|
||
// 如果没有会话窗口,使用默认1小时(兼容旧逻辑)
|
||
const oneHourLater = new Date(Date.now() + 60 * 60 * 1000)
|
||
updatedAccountData.rateLimitEndAt = oneHourLater.toISOString()
|
||
logger.warn(
|
||
`🚫 Account marked as rate limited (1 hour default): ${accountData.name} (${accountId})`
|
||
)
|
||
}
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
|
||
// 如果有会话哈希,删除粘性会话映射
|
||
if (sessionHash) {
|
||
await redis.deleteSessionAccountMapping(sessionHash)
|
||
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`)
|
||
}
|
||
|
||
// 发送Webhook通知
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: accountData.name || 'Claude Account',
|
||
platform: 'claude-oauth',
|
||
status: 'error',
|
||
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
|
||
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`,
|
||
timestamp: getISOStringWithTimezone(new Date())
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||
}
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🚫 标记账号的 Opus 限流状态(不影响其他模型调度)
|
||
async markAccountOpusRateLimited(accountId, rateLimitResetTimestamp = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
const updatedAccountData = { ...accountData }
|
||
const now = new Date()
|
||
updatedAccountData.opusRateLimitedAt = now.toISOString()
|
||
|
||
if (rateLimitResetTimestamp) {
|
||
const resetTime = new Date(rateLimitResetTimestamp * 1000)
|
||
updatedAccountData.opusRateLimitEndAt = resetTime.toISOString()
|
||
logger.warn(
|
||
`🚫 Account ${accountData.name} (${accountId}) reached Opus weekly cap, resets at ${resetTime.toISOString()}`
|
||
)
|
||
} else {
|
||
// 如果缺少准确时间戳,保留现有值但记录警告,便于后续人工干预
|
||
logger.warn(
|
||
`⚠️ Account ${accountData.name} (${accountId}) reported Opus limit without reset timestamp`
|
||
)
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to mark Opus rate limit for account: ${accountId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// ✅ 清除账号的 Opus 限流状态
|
||
async clearAccountOpusRateLimit(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return { success: true }
|
||
}
|
||
|
||
const updatedAccountData = { ...accountData }
|
||
delete updatedAccountData.opusRateLimitedAt
|
||
delete updatedAccountData.opusRateLimitEndAt
|
||
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
|
||
const redisKey = `claude:account:${accountId}`
|
||
if (redis.client && typeof redis.client.hdel === 'function') {
|
||
await redis.client.hdel(redisKey, 'opusRateLimitedAt', 'opusRateLimitEndAt')
|
||
}
|
||
|
||
logger.info(`✅ Cleared Opus rate limit state for account ${accountId}`)
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to clear Opus rate limit for account: ${accountId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔍 检查账号是否处于 Opus 限流状态(自动清理过期标记)
|
||
async isAccountOpusRateLimited(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return false
|
||
}
|
||
|
||
if (!accountData.opusRateLimitEndAt) {
|
||
return false
|
||
}
|
||
|
||
const resetTime = new Date(accountData.opusRateLimitEndAt)
|
||
if (Number.isNaN(resetTime.getTime())) {
|
||
await this.clearAccountOpusRateLimit(accountId)
|
||
return false
|
||
}
|
||
|
||
const now = new Date()
|
||
if (now >= resetTime) {
|
||
await this.clearAccountOpusRateLimit(accountId)
|
||
return false
|
||
}
|
||
|
||
return true
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to check Opus rate limit status for account: ${accountId}`, error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ♻️ 检查并清理已过期的 Opus 限流标记
|
||
async clearExpiredOpusRateLimit(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return { success: true }
|
||
}
|
||
|
||
if (!accountData.opusRateLimitEndAt) {
|
||
return { success: true }
|
||
}
|
||
|
||
const resetTime = new Date(accountData.opusRateLimitEndAt)
|
||
if (Number.isNaN(resetTime.getTime()) || new Date() >= resetTime) {
|
||
await this.clearAccountOpusRateLimit(accountId)
|
||
}
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to clear expired Opus rate limit for account: ${accountId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// ✅ 移除账号的限流状态
|
||
async removeAccountRateLimit(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
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 (hadAutoStop && accountData.schedulable === 'false') {
|
||
accountData.schedulable = 'true'
|
||
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
|
||
logger.info(
|
||
`📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}`
|
||
)
|
||
} else {
|
||
logger.info(
|
||
`ℹ️ 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中的限流字段,避免旧标记阻止账号恢复调度
|
||
await redis.client.hdel(
|
||
accountKey,
|
||
'rateLimitedAt',
|
||
'rateLimitStatus',
|
||
'rateLimitEndAt',
|
||
'rateLimitAutoStopped'
|
||
)
|
||
|
||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔍 检查账号是否处于限流状态
|
||
async isAccountRateLimited(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return false
|
||
}
|
||
|
||
const now = new Date()
|
||
|
||
// 检查是否有限流状态(包括字段缺失但有自动停止标记的情况)
|
||
if (
|
||
(accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) ||
|
||
(accountData.rateLimitAutoStopped === 'true' && accountData.rateLimitEndAt)
|
||
) {
|
||
// 优先使用 rateLimitEndAt(基于会话窗口)
|
||
if (accountData.rateLimitEndAt) {
|
||
const rateLimitEndAt = new Date(accountData.rateLimitEndAt)
|
||
|
||
// 如果当前时间超过限流结束时间,自动解除
|
||
if (now >= rateLimitEndAt) {
|
||
await this.removeAccountRateLimit(accountId)
|
||
return false
|
||
}
|
||
|
||
return true
|
||
} else if (accountData.rateLimitedAt) {
|
||
// 兼容旧数据:使用1小时限流
|
||
const rateLimitedAt = new Date(accountData.rateLimitedAt)
|
||
const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60)
|
||
|
||
// 如果限流超过1小时,自动解除
|
||
if (hoursSinceRateLimit >= 1) {
|
||
await this.removeAccountRateLimit(accountId)
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 📊 获取账号的限流信息
|
||
async getAccountRateLimitInfo(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null
|
||
}
|
||
|
||
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||
const rateLimitedAt = new Date(accountData.rateLimitedAt)
|
||
const now = new Date()
|
||
const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60))
|
||
|
||
let minutesRemaining
|
||
let rateLimitEndAt
|
||
|
||
// 优先使用 rateLimitEndAt(基于会话窗口)
|
||
if (accountData.rateLimitEndAt) {
|
||
;({ rateLimitEndAt } = accountData)
|
||
const endTime = new Date(accountData.rateLimitEndAt)
|
||
minutesRemaining = Math.max(0, Math.ceil((endTime - now) / (1000 * 60)))
|
||
} else {
|
||
// 兼容旧数据:使用1小时限流
|
||
minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit)
|
||
// 计算预期的结束时间
|
||
const endTime = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000)
|
||
rateLimitEndAt = endTime.toISOString()
|
||
}
|
||
|
||
return {
|
||
isRateLimited: minutesRemaining > 0,
|
||
rateLimitedAt: accountData.rateLimitedAt,
|
||
minutesSinceRateLimit,
|
||
minutesRemaining,
|
||
rateLimitEndAt // 新增:限流结束时间
|
||
}
|
||
}
|
||
|
||
return {
|
||
isRateLimited: false,
|
||
rateLimitedAt: null,
|
||
minutesSinceRateLimit: 0,
|
||
minutesRemaining: 0,
|
||
rateLimitEndAt: null
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 🕐 更新会话窗口
|
||
async updateSessionWindow(accountId, accountData = null) {
|
||
try {
|
||
// 如果没有传入accountData,从Redis获取
|
||
if (!accountData) {
|
||
accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
}
|
||
|
||
const now = new Date()
|
||
const currentTime = now.getTime()
|
||
|
||
let shouldClearSessionStatus = false
|
||
let shouldClearFiveHourFlags = false
|
||
|
||
// 检查当前是否有活跃的会话窗口
|
||
if (accountData.sessionWindowStart && accountData.sessionWindowEnd) {
|
||
const windowEnd = new Date(accountData.sessionWindowEnd).getTime()
|
||
|
||
// 如果当前时间在窗口内,只更新最后请求时间
|
||
if (currentTime < windowEnd) {
|
||
accountData.lastRequestTime = now.toISOString()
|
||
return accountData
|
||
}
|
||
|
||
// 窗口已过期,记录日志
|
||
const windowStart = new Date(accountData.sessionWindowStart)
|
||
logger.info(
|
||
`⏰ Session window expired for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${new Date(windowEnd).toISOString()}`
|
||
)
|
||
}
|
||
|
||
// 基于当前时间计算新的会话窗口
|
||
const windowStart = this._calculateSessionWindowStart(now)
|
||
const windowEnd = this._calculateSessionWindowEnd(windowStart)
|
||
|
||
// 更新会话窗口信息
|
||
accountData.sessionWindowStart = windowStart.toISOString()
|
||
accountData.sessionWindowEnd = windowEnd.toISOString()
|
||
accountData.lastRequestTime = now.toISOString()
|
||
|
||
// 清除会话窗口状态,因为进入了新窗口
|
||
if (accountData.sessionWindowStatus) {
|
||
delete accountData.sessionWindowStatus
|
||
delete accountData.sessionWindowStatusUpdatedAt
|
||
await this._clearFiveHourWarningMetadata(accountId, accountData)
|
||
shouldClearSessionStatus = true
|
||
}
|
||
|
||
// 如果账户因为5小时限制被自动停止,现在恢复调度
|
||
if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||
logger.info(
|
||
`✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started`
|
||
)
|
||
accountData.schedulable = 'true'
|
||
delete accountData.fiveHourAutoStopped
|
||
delete accountData.fiveHourStoppedAt
|
||
await this._clearFiveHourWarningMetadata(accountId, accountData)
|
||
shouldClearFiveHourFlags = true
|
||
|
||
// 发送Webhook通知
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: accountData.name || 'Claude Account',
|
||
platform: 'claude',
|
||
status: 'resumed',
|
||
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
|
||
reason: '进入新的5小时窗口,已自动恢复调度',
|
||
timestamp: getISOStringWithTimezone(new Date())
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error('Failed to send webhook notification:', webhookError)
|
||
}
|
||
}
|
||
|
||
if (shouldClearSessionStatus || shouldClearFiveHourFlags) {
|
||
const fieldsToRemove = []
|
||
if (shouldClearFiveHourFlags) {
|
||
fieldsToRemove.push('fiveHourAutoStopped', 'fiveHourStoppedAt')
|
||
}
|
||
if (shouldClearSessionStatus) {
|
||
fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt')
|
||
}
|
||
await this._removeAccountFields(accountId, fieldsToRemove, 'session_window_refresh')
|
||
}
|
||
|
||
logger.info(
|
||
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
|
||
)
|
||
|
||
return accountData
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to update session window for account ${accountId}:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🕐 计算会话窗口开始时间
|
||
_calculateSessionWindowStart(requestTime) {
|
||
// 从当前时间开始创建窗口,只将分钟取整到整点
|
||
const windowStart = new Date(requestTime)
|
||
windowStart.setMinutes(0)
|
||
windowStart.setSeconds(0)
|
||
windowStart.setMilliseconds(0)
|
||
|
||
return windowStart
|
||
}
|
||
|
||
// 🕐 计算会话窗口结束时间
|
||
_calculateSessionWindowEnd(startTime) {
|
||
const endTime = new Date(startTime)
|
||
endTime.setHours(endTime.getHours() + 5) // 加5小时
|
||
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 {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null
|
||
}
|
||
|
||
// 如果没有会话窗口信息,返回null
|
||
if (!accountData.sessionWindowStart || !accountData.sessionWindowEnd) {
|
||
return {
|
||
hasActiveWindow: false,
|
||
windowStart: null,
|
||
windowEnd: null,
|
||
progress: 0,
|
||
remainingTime: null,
|
||
lastRequestTime: accountData.lastRequestTime || null,
|
||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||
}
|
||
}
|
||
|
||
const now = new Date()
|
||
const windowStart = new Date(accountData.sessionWindowStart)
|
||
const windowEnd = new Date(accountData.sessionWindowEnd)
|
||
const currentTime = now.getTime()
|
||
|
||
// 检查窗口是否已过期
|
||
if (currentTime >= windowEnd.getTime()) {
|
||
return {
|
||
hasActiveWindow: false,
|
||
windowStart: accountData.sessionWindowStart,
|
||
windowEnd: accountData.sessionWindowEnd,
|
||
progress: 100,
|
||
remainingTime: 0,
|
||
lastRequestTime: accountData.lastRequestTime || null,
|
||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||
}
|
||
}
|
||
|
||
// 计算进度百分比
|
||
const totalDuration = windowEnd.getTime() - windowStart.getTime()
|
||
const elapsedTime = currentTime - windowStart.getTime()
|
||
const progress = Math.round((elapsedTime / totalDuration) * 100)
|
||
|
||
// 计算剩余时间(分钟)
|
||
const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60))
|
||
|
||
return {
|
||
hasActiveWindow: true,
|
||
windowStart: accountData.sessionWindowStart,
|
||
windowEnd: accountData.sessionWindowEnd,
|
||
progress,
|
||
remainingTime,
|
||
lastRequestTime: accountData.lastRequestTime || null,
|
||
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get session window info for account ${accountId}:`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 📊 获取 OAuth Usage 数据
|
||
async fetchOAuthUsage(accountId, accessToken = null, agent = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 如果没有提供 accessToken,使用 getValidAccessToken 自动检查过期并刷新
|
||
if (!accessToken) {
|
||
accessToken = await this.getValidAccessToken(accountId)
|
||
}
|
||
|
||
// 如果没有提供 agent,创建代理
|
||
if (!agent) {
|
||
agent = this._createProxyAgent(accountData.proxy)
|
||
}
|
||
|
||
logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
|
||
|
||
// 请求 OAuth usage 接口
|
||
const response = await axios.get('https://api.anthropic.com/api/oauth/usage', {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
'anthropic-beta': 'oauth-2025-04-20',
|
||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||
'Accept-Language': 'en-US,en;q=0.9'
|
||
},
|
||
httpsAgent: agent,
|
||
timeout: 15000
|
||
})
|
||
|
||
if (response.status === 200 && response.data) {
|
||
logger.debug('✅ Successfully fetched OAuth usage data:', {
|
||
accountId,
|
||
fiveHour: response.data.five_hour?.utilization,
|
||
sevenDay: response.data.seven_day?.utilization,
|
||
sevenDayOpus: response.data.seven_day_opus?.utilization
|
||
})
|
||
|
||
return response.data
|
||
}
|
||
|
||
logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`)
|
||
return null
|
||
} catch (error) {
|
||
// 403 错误通常表示使用的是 Setup Token 而非 OAuth
|
||
if (error.response?.status === 403) {
|
||
logger.debug(
|
||
`⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.`
|
||
)
|
||
return null
|
||
}
|
||
|
||
// 其他错误正常记录
|
||
logger.error(
|
||
`❌ Failed to fetch OAuth usage for account ${accountId}:`,
|
||
error.response?.data || error.message
|
||
)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 📊 构建 Claude Usage 快照(从 Redis 数据)
|
||
buildClaudeUsageSnapshot(accountData) {
|
||
const updatedAt = accountData.claudeUsageUpdatedAt
|
||
|
||
const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization)
|
||
const fiveHourResetsAt = accountData.claudeFiveHourResetsAt
|
||
const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization)
|
||
const sevenDayResetsAt = accountData.claudeSevenDayResetsAt
|
||
const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization)
|
||
const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt
|
||
|
||
const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt
|
||
const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt
|
||
const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt
|
||
|
||
if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) {
|
||
return null
|
||
}
|
||
|
||
const now = Date.now()
|
||
|
||
return {
|
||
updatedAt,
|
||
fiveHour: {
|
||
utilization: fiveHourUtilization,
|
||
resetsAt: fiveHourResetsAt,
|
||
remainingSeconds: fiveHourResetsAt
|
||
? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000))
|
||
: null
|
||
},
|
||
sevenDay: {
|
||
utilization: sevenDayUtilization,
|
||
resetsAt: sevenDayResetsAt,
|
||
remainingSeconds: sevenDayResetsAt
|
||
? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000))
|
||
: null
|
||
},
|
||
sevenDayOpus: {
|
||
utilization: sevenDayOpusUtilization,
|
||
resetsAt: sevenDayOpusResetsAt,
|
||
remainingSeconds: sevenDayOpusResetsAt
|
||
? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000))
|
||
: null
|
||
}
|
||
}
|
||
}
|
||
|
||
// 📊 更新 Claude Usage 快照到 Redis
|
||
async updateClaudeUsageSnapshot(accountId, usageData) {
|
||
if (!usageData || typeof usageData !== 'object') {
|
||
return
|
||
}
|
||
|
||
const updates = {}
|
||
|
||
// 5小时窗口
|
||
if (usageData.five_hour) {
|
||
if (usageData.five_hour.utilization !== undefined) {
|
||
updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization)
|
||
}
|
||
if (usageData.five_hour.resets_at) {
|
||
updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at
|
||
}
|
||
}
|
||
|
||
// 7天窗口
|
||
if (usageData.seven_day) {
|
||
if (usageData.seven_day.utilization !== undefined) {
|
||
updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization)
|
||
}
|
||
if (usageData.seven_day.resets_at) {
|
||
updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at
|
||
}
|
||
}
|
||
|
||
// 7天Opus窗口
|
||
if (usageData.seven_day_opus) {
|
||
if (usageData.seven_day_opus.utilization !== undefined) {
|
||
updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_opus.utilization)
|
||
}
|
||
if (usageData.seven_day_opus.resets_at) {
|
||
updates.claudeSevenDayOpusResetsAt = usageData.seven_day_opus.resets_at
|
||
}
|
||
}
|
||
|
||
if (Object.keys(updates).length === 0) {
|
||
return
|
||
}
|
||
|
||
updates.claudeUsageUpdatedAt = new Date().toISOString()
|
||
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (accountData && Object.keys(accountData).length > 0) {
|
||
Object.assign(accountData, updates)
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
logger.debug(
|
||
`📊 Updated Claude usage snapshot for account ${accountId}:`,
|
||
Object.keys(updates)
|
||
)
|
||
}
|
||
}
|
||
|
||
// 📊 获取账号 Profile 信息并更新账号类型
|
||
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 检查账户是否有 user:profile 权限
|
||
const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile')
|
||
if (!hasProfileScope) {
|
||
logger.warn(
|
||
`⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile`
|
||
)
|
||
throw new Error('Account does not have user:profile permission')
|
||
}
|
||
|
||
// 如果没有提供 accessToken,使用账号存储的 token
|
||
if (!accessToken) {
|
||
accessToken = this._decryptSensitiveData(accountData.accessToken)
|
||
if (!accessToken) {
|
||
throw new Error('No access token available')
|
||
}
|
||
}
|
||
|
||
// 如果没有提供 agent,创建代理
|
||
if (!agent) {
|
||
agent = this._createProxyAgent(accountData.proxy)
|
||
}
|
||
|
||
logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`)
|
||
|
||
// 请求 profile 接口
|
||
const response = await axios.get('https://api.anthropic.com/api/oauth/profile', {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||
'Accept-Language': 'en-US,en;q=0.9'
|
||
},
|
||
httpsAgent: agent,
|
||
timeout: 15000
|
||
})
|
||
|
||
if (response.status === 200 && response.data) {
|
||
const profileData = response.data
|
||
|
||
logger.info('✅ Successfully fetched profile data:', {
|
||
email: profileData.account?.email,
|
||
hasClaudeMax: profileData.account?.has_claude_max,
|
||
hasClaudePro: profileData.account?.has_claude_pro,
|
||
organizationType: profileData.organization?.organization_type
|
||
})
|
||
|
||
// 构建订阅信息
|
||
const subscriptionInfo = {
|
||
// 账号信息
|
||
email: profileData.account?.email,
|
||
fullName: profileData.account?.full_name,
|
||
displayName: profileData.account?.display_name,
|
||
hasClaudeMax: profileData.account?.has_claude_max || false,
|
||
hasClaudePro: profileData.account?.has_claude_pro || false,
|
||
accountUuid: profileData.account?.uuid,
|
||
|
||
// 组织信息
|
||
organizationName: profileData.organization?.name,
|
||
organizationUuid: profileData.organization?.uuid,
|
||
billingType: profileData.organization?.billing_type,
|
||
rateLimitTier: profileData.organization?.rate_limit_tier,
|
||
organizationType: profileData.organization?.organization_type,
|
||
|
||
// 账号类型(基于 has_claude_max 和 has_claude_pro 判断)
|
||
accountType:
|
||
profileData.account?.has_claude_max === true
|
||
? 'claude_max'
|
||
: profileData.account?.has_claude_pro === true
|
||
? 'claude_pro'
|
||
: 'free',
|
||
|
||
// 更新时间
|
||
profileFetchedAt: new Date().toISOString()
|
||
}
|
||
|
||
// 更新账户数据
|
||
accountData.subscriptionInfo = JSON.stringify(subscriptionInfo)
|
||
accountData.profileUpdatedAt = new Date().toISOString()
|
||
|
||
// 如果提供了邮箱,更新邮箱字段
|
||
if (profileData.account?.email) {
|
||
accountData.email = this._encryptSensitiveData(profileData.account.email)
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
logger.success(
|
||
`✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}`
|
||
)
|
||
|
||
return subscriptionInfo
|
||
} else {
|
||
throw new Error(`Failed to fetch profile with status: ${response.status}`)
|
||
}
|
||
} catch (error) {
|
||
if (error.response?.status === 401) {
|
||
logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`)
|
||
} else if (error.response?.status === 403) {
|
||
logger.warn(
|
||
`⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions`
|
||
)
|
||
} else {
|
||
logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔄 手动更新所有账号的 Profile 信息
|
||
async updateAllAccountProfiles() {
|
||
try {
|
||
logger.info('🔄 Starting batch profile update for all accounts...')
|
||
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
let successCount = 0
|
||
let failureCount = 0
|
||
const results = []
|
||
|
||
for (const account of accounts) {
|
||
// 跳过未激活或错误状态的账号
|
||
if (account.isActive !== 'true' || account.status === 'error') {
|
||
logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`)
|
||
continue
|
||
}
|
||
|
||
// 跳过没有 user:profile 权限的账号(Setup Token 账号)
|
||
const hasProfileScope = account.scopes && account.scopes.includes('user:profile')
|
||
if (!hasProfileScope) {
|
||
logger.info(
|
||
`⏩ Skipping account without user:profile scope: ${account.name} (${account.id})`
|
||
)
|
||
results.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
success: false,
|
||
error: 'No user:profile permission (Setup Token account)'
|
||
})
|
||
continue
|
||
}
|
||
|
||
try {
|
||
// 获取有效的 access token
|
||
const accessToken = await this.getValidAccessToken(account.id)
|
||
if (accessToken) {
|
||
const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken)
|
||
successCount++
|
||
results.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
success: true,
|
||
accountType: profileInfo.accountType
|
||
})
|
||
}
|
||
} catch (error) {
|
||
failureCount++
|
||
results.push({
|
||
accountId: account.id,
|
||
accountName: account.name,
|
||
success: false,
|
||
error: error.message
|
||
})
|
||
logger.warn(
|
||
`⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}`
|
||
)
|
||
}
|
||
|
||
// 添加延迟以避免触发限流
|
||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||
}
|
||
|
||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||
|
||
return {
|
||
totalAccounts: accounts.length,
|
||
successCount,
|
||
failureCount,
|
||
results
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update account profiles:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔄 初始化所有账户的会话窗口(从历史数据恢复)
|
||
async initializeSessionWindows(forceRecalculate = false) {
|
||
try {
|
||
logger.info('🔄 Initializing session windows for all Claude accounts...')
|
||
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
let validWindowCount = 0
|
||
let expiredWindowCount = 0
|
||
let noWindowCount = 0
|
||
const now = new Date()
|
||
|
||
for (const account of accounts) {
|
||
// 如果强制重算,清除现有窗口信息
|
||
if (forceRecalculate && (account.sessionWindowStart || account.sessionWindowEnd)) {
|
||
logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`)
|
||
delete account.sessionWindowStart
|
||
delete account.sessionWindowEnd
|
||
delete account.lastRequestTime
|
||
await redis.setClaudeAccount(account.id, account)
|
||
}
|
||
|
||
// 检查现有会话窗口
|
||
if (account.sessionWindowStart && account.sessionWindowEnd) {
|
||
const windowEnd = new Date(account.sessionWindowEnd)
|
||
const windowStart = new Date(account.sessionWindowStart)
|
||
const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60))
|
||
|
||
if (now.getTime() < windowEnd.getTime()) {
|
||
// 窗口仍然有效,保留它
|
||
validWindowCount++
|
||
logger.info(
|
||
`✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)`
|
||
)
|
||
} else {
|
||
// 窗口已过期,清除它
|
||
expiredWindowCount++
|
||
logger.warn(
|
||
`⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}`
|
||
)
|
||
|
||
// 清除过期的窗口信息
|
||
delete account.sessionWindowStart
|
||
delete account.sessionWindowEnd
|
||
delete account.lastRequestTime
|
||
await redis.setClaudeAccount(account.id, account)
|
||
}
|
||
} else {
|
||
noWindowCount++
|
||
logger.info(
|
||
`📭 Account ${account.name} (${account.id}) has no session window - will create on next request`
|
||
)
|
||
}
|
||
}
|
||
|
||
logger.success('✅ Session window initialization completed:')
|
||
logger.success(` 📊 Total accounts: ${accounts.length}`)
|
||
logger.success(` ✅ Valid windows: ${validWindowCount}`)
|
||
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`)
|
||
logger.success(` 📭 No windows: ${noWindowCount}`)
|
||
|
||
return {
|
||
total: accounts.length,
|
||
validWindows: validWindowCount,
|
||
expiredWindows: expiredWindowCount,
|
||
noWindows: noWindowCount
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Failed to initialize session windows:', error)
|
||
return {
|
||
total: 0,
|
||
validWindows: 0,
|
||
expiredWindows: 0,
|
||
noWindows: 0,
|
||
error: error.message
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🚫 通用的账户错误标记方法
|
||
async markAccountError(accountId, errorType, sessionHash = null) {
|
||
const ERROR_CONFIG = {
|
||
unauthorized: {
|
||
status: 'unauthorized',
|
||
errorMessage: 'Account unauthorized (401 errors detected)',
|
||
timestampField: 'unauthorizedAt',
|
||
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
|
||
logMessage: 'unauthorized'
|
||
},
|
||
blocked: {
|
||
status: 'blocked',
|
||
errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)',
|
||
timestampField: 'blockedAt',
|
||
errorCode: 'CLAUDE_OAUTH_BLOCKED',
|
||
logMessage: 'blocked'
|
||
}
|
||
}
|
||
|
||
try {
|
||
const errorConfig = ERROR_CONFIG[errorType]
|
||
if (!errorConfig) {
|
||
throw new Error(`Unsupported error type: ${errorType}`)
|
||
}
|
||
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 更新账户状态
|
||
const updatedAccountData = { ...accountData }
|
||
updatedAccountData.status = errorConfig.status
|
||
updatedAccountData.schedulable = 'false' // 设置为不可调度
|
||
updatedAccountData.errorMessage = errorConfig.errorMessage
|
||
updatedAccountData[errorConfig.timestampField] = new Date().toISOString()
|
||
|
||
// 保存更新后的账户数据
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
|
||
// 如果有sessionHash,删除粘性会话映射
|
||
if (sessionHash) {
|
||
await redis.client.del(`sticky_session:${sessionHash}`)
|
||
logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`)
|
||
}
|
||
|
||
logger.warn(
|
||
`⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling`
|
||
)
|
||
|
||
// 发送Webhook通知
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: accountData.name,
|
||
platform: 'claude-oauth',
|
||
status: errorConfig.status,
|
||
errorCode: errorConfig.errorCode,
|
||
reason: errorConfig.errorMessage,
|
||
timestamp: getISOStringWithTimezone(new Date())
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error('Failed to send webhook notification:', webhookError)
|
||
}
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🚫 标记账户为未授权状态(401错误)
|
||
async markAccountUnauthorized(accountId, sessionHash = null) {
|
||
return this.markAccountError(accountId, 'unauthorized', sessionHash)
|
||
}
|
||
|
||
// 🚫 标记账户为被封锁状态(403错误)
|
||
async markAccountBlocked(accountId, sessionHash = null) {
|
||
return this.markAccountError(accountId, 'blocked', sessionHash)
|
||
}
|
||
|
||
// 🔄 重置账户所有异常状态
|
||
async resetAccountStatus(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 重置账户状态
|
||
const updatedAccountData = { ...accountData }
|
||
|
||
// 根据是否有有效的accessToken来设置status
|
||
if (updatedAccountData.accessToken) {
|
||
updatedAccountData.status = 'active'
|
||
} else {
|
||
updatedAccountData.status = 'created'
|
||
}
|
||
|
||
// 恢复可调度状态(管理员手动重置时恢复调度是合理的)
|
||
updatedAccountData.schedulable = 'true'
|
||
// 清除所有自动停止相关的标记
|
||
delete updatedAccountData.rateLimitAutoStopped
|
||
delete updatedAccountData.fiveHourAutoStopped
|
||
delete updatedAccountData.fiveHourStoppedAt
|
||
delete updatedAccountData.tempErrorAutoStopped
|
||
delete updatedAccountData.fiveHourWarningWindow
|
||
delete updatedAccountData.fiveHourWarningCount
|
||
delete updatedAccountData.fiveHourWarningLastSentAt
|
||
// 兼容旧的标记
|
||
delete updatedAccountData.autoStoppedAt
|
||
delete updatedAccountData.stoppedReason
|
||
|
||
// 清除错误相关字段
|
||
delete updatedAccountData.errorMessage
|
||
delete updatedAccountData.unauthorizedAt
|
||
delete updatedAccountData.blockedAt
|
||
delete updatedAccountData.rateLimitedAt
|
||
delete updatedAccountData.rateLimitStatus
|
||
delete updatedAccountData.rateLimitEndAt
|
||
delete updatedAccountData.tempErrorAt
|
||
delete updatedAccountData.sessionWindowStart
|
||
delete updatedAccountData.sessionWindowEnd
|
||
|
||
// 保存更新后的账户数据
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
|
||
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
|
||
const fieldsToDelete = [
|
||
'errorMessage',
|
||
'unauthorizedAt',
|
||
'blockedAt',
|
||
'rateLimitedAt',
|
||
'rateLimitStatus',
|
||
'rateLimitEndAt',
|
||
'tempErrorAt',
|
||
'sessionWindowStart',
|
||
'sessionWindowEnd',
|
||
// 新的独立标记
|
||
'rateLimitAutoStopped',
|
||
'fiveHourAutoStopped',
|
||
'fiveHourStoppedAt',
|
||
'fiveHourWarningWindow',
|
||
'fiveHourWarningCount',
|
||
'fiveHourWarningLastSentAt',
|
||
'tempErrorAutoStopped',
|
||
// 兼容旧的标记
|
||
'autoStoppedAt',
|
||
'stoppedReason'
|
||
]
|
||
await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete)
|
||
|
||
// 清除401错误计数
|
||
const errorKey = `claude_account:${accountId}:401_errors`
|
||
await redis.client.del(errorKey)
|
||
|
||
// 清除限流状态(如果存在)
|
||
const rateLimitKey = `ratelimit:${accountId}`
|
||
await redis.client.del(rateLimitKey)
|
||
|
||
// 清除5xx错误计数
|
||
const serverErrorKey = `claude_account:${accountId}:5xx_errors`
|
||
await redis.client.del(serverErrorKey)
|
||
|
||
logger.info(
|
||
`✅ Successfully reset all error states for account ${accountData.name} (${accountId})`
|
||
)
|
||
|
||
return {
|
||
success: true,
|
||
account: {
|
||
id: accountId,
|
||
name: accountData.name,
|
||
status: updatedAccountData.status,
|
||
schedulable: updatedAccountData.schedulable === 'true'
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to reset account status for ${accountId}:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🧹 清理临时错误账户
|
||
async cleanupTempErrorAccounts() {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts()
|
||
let cleanedCount = 0
|
||
const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟)
|
||
|
||
for (const account of accounts) {
|
||
if (account.status === 'temp_error' && account.tempErrorAt) {
|
||
const tempErrorAt = new Date(account.tempErrorAt)
|
||
const now = new Date()
|
||
const minutesSinceTempError = (now - tempErrorAt) / (1000 * 60)
|
||
|
||
// 如果临时错误状态超过指定时间,尝试重新激活
|
||
if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) {
|
||
account.status = 'active' // 恢复为 active 状态
|
||
// 只恢复因临时错误而自动停止的账户
|
||
if (account.tempErrorAutoStopped === 'true') {
|
||
account.schedulable = 'true' // 恢复为可调度
|
||
delete account.tempErrorAutoStopped
|
||
}
|
||
delete account.errorMessage
|
||
delete account.tempErrorAt
|
||
await redis.setClaudeAccount(account.id, account)
|
||
|
||
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
|
||
await redis.client.hdel(
|
||
`claude:account:${account.id}`,
|
||
'errorMessage',
|
||
'tempErrorAt',
|
||
'tempErrorAutoStopped'
|
||
)
|
||
|
||
// 同时清除500错误计数
|
||
await this.clearInternalErrors(account.id)
|
||
cleanedCount++
|
||
logger.success(`🧹 Reset temp_error status for account ${account.name} (${account.id})`)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedCount > 0) {
|
||
logger.success(`🧹 Reset ${cleanedCount} temp_error accounts`)
|
||
}
|
||
|
||
return cleanedCount
|
||
} catch (error) {
|
||
logger.error('❌ Failed to cleanup temp_error accounts:', error)
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 记录5xx服务器错误
|
||
async recordServerError(accountId, statusCode) {
|
||
try {
|
||
const key = `claude_account:${accountId}:5xx_errors`
|
||
|
||
// 增加错误计数,设置5分钟过期时间
|
||
await redis.client.incr(key)
|
||
await redis.client.expire(key, 300) // 5分钟
|
||
|
||
logger.info(`📝 Recorded ${statusCode} error for account ${accountId}`)
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to record ${statusCode} error for account ${accountId}:`, error)
|
||
}
|
||
}
|
||
|
||
// 记录500内部错误(保留以便向后兼容)
|
||
async recordInternalError(accountId) {
|
||
return this.recordServerError(accountId, 500)
|
||
}
|
||
|
||
// 获取5xx错误计数
|
||
async getServerErrorCount(accountId) {
|
||
try {
|
||
const key = `claude_account:${accountId}:5xx_errors`
|
||
|
||
const count = await redis.client.get(key)
|
||
return parseInt(count) || 0
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get 5xx error count for account ${accountId}:`, error)
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 获取500错误计数(保留以便向后兼容)
|
||
async getInternalErrorCount(accountId) {
|
||
return this.getServerErrorCount(accountId)
|
||
}
|
||
|
||
// 清除500错误计数
|
||
async clearInternalErrors(accountId) {
|
||
try {
|
||
const key = `claude_account:${accountId}:5xx_errors`
|
||
|
||
await redis.client.del(key)
|
||
logger.info(`✅ Cleared 5xx error count for account ${accountId}`)
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to clear 5xx errors for account ${accountId}:`, error)
|
||
}
|
||
}
|
||
|
||
// 标记账号为临时错误状态
|
||
async markAccountTempError(accountId, sessionHash = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
// 更新账户状态
|
||
const updatedAccountData = { ...accountData }
|
||
updatedAccountData.status = 'temp_error' // 新增的临时错误状态
|
||
updatedAccountData.schedulable = 'false' // 设置为不可调度
|
||
updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors'
|
||
updatedAccountData.tempErrorAt = new Date().toISOString()
|
||
// 使用独立的临时错误自动停止标记
|
||
updatedAccountData.tempErrorAutoStopped = 'true'
|
||
|
||
// 保存更新后的账户数据
|
||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||
|
||
// 设置 5 分钟后自动恢复(一次性定时器)
|
||
setTimeout(
|
||
async () => {
|
||
try {
|
||
const account = await redis.getClaudeAccount(accountId)
|
||
if (account && account.status === 'temp_error' && account.tempErrorAt) {
|
||
// 验证是否确实过了 5 分钟(防止重复定时器)
|
||
const tempErrorAt = new Date(account.tempErrorAt)
|
||
const now = new Date()
|
||
const minutesSince = (now - tempErrorAt) / (1000 * 60)
|
||
|
||
if (minutesSince >= 5) {
|
||
// 恢复账户
|
||
account.status = 'active'
|
||
// 只恢复因临时错误而自动停止的账户
|
||
if (account.tempErrorAutoStopped === 'true') {
|
||
account.schedulable = 'true'
|
||
delete account.tempErrorAutoStopped
|
||
}
|
||
delete account.errorMessage
|
||
delete account.tempErrorAt
|
||
|
||
await redis.setClaudeAccount(accountId, account)
|
||
|
||
// 显式删除 Redis 字段
|
||
await redis.client.hdel(
|
||
`claude:account:${accountId}`,
|
||
'errorMessage',
|
||
'tempErrorAt',
|
||
'tempErrorAutoStopped'
|
||
)
|
||
|
||
// 清除 500 错误计数
|
||
await this.clearInternalErrors(accountId)
|
||
|
||
logger.success(
|
||
`✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})`
|
||
)
|
||
} else {
|
||
logger.debug(
|
||
`⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})`
|
||
)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error)
|
||
}
|
||
},
|
||
6 * 60 * 1000
|
||
) // 6 分钟后执行,确保已过 5 分钟
|
||
|
||
// 如果有sessionHash,删除粘性会话映射
|
||
if (sessionHash) {
|
||
await redis.client.del(`sticky_session:${sessionHash}`)
|
||
logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`)
|
||
}
|
||
|
||
logger.warn(
|
||
`⚠️ Account ${accountData.name} (${accountId}) marked as temp_error and disabled for scheduling`
|
||
)
|
||
|
||
// 发送Webhook通知
|
||
try {
|
||
const webhookNotifier = require('../utils/webhookNotifier')
|
||
await webhookNotifier.sendAccountAnomalyNotification({
|
||
accountId,
|
||
accountName: accountData.name,
|
||
platform: 'claude-oauth',
|
||
status: 'temp_error',
|
||
errorCode: 'CLAUDE_OAUTH_TEMP_ERROR',
|
||
reason: 'Account temporarily disabled due to consecutive 500 errors'
|
||
})
|
||
} catch (webhookError) {
|
||
logger.error('Failed to send webhook notification:', webhookError)
|
||
}
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to mark account ${accountId} as temp_error:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 更新会话窗口状态(allowed, allowed_warning, rejected)
|
||
async updateSessionWindowStatus(accountId, status) {
|
||
try {
|
||
// 参数验证
|
||
if (!accountId || !status) {
|
||
logger.warn(
|
||
`Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}`
|
||
)
|
||
return
|
||
}
|
||
|
||
const accountData = await redis.getClaudeAccount(accountId)
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
logger.warn(`Account not found: ${accountId}`)
|
||
return
|
||
}
|
||
|
||
// 验证状态值是否有效
|
||
const validStatuses = ['allowed', 'allowed_warning', 'rejected']
|
||
if (!validStatuses.includes(status)) {
|
||
logger.warn(`Invalid session window status: ${status} for account ${accountId}`)
|
||
return
|
||
}
|
||
|
||
const now = new Date()
|
||
const nowIso = now.toISOString()
|
||
|
||
// 更新会话窗口状态
|
||
accountData.sessionWindowStatus = status
|
||
accountData.sessionWindowStatusUpdatedAt = nowIso
|
||
|
||
// 如果状态是 allowed_warning 且账户设置了自动停止调度
|
||
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
|
||
const alreadyAutoStopped =
|
||
accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true'
|
||
|
||
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`
|
||
)
|
||
}
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, accountData)
|
||
|
||
logger.info(
|
||
`📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}`
|
||
)
|
||
} catch (error) {
|
||
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)
|
||
// 不抛出错误,移除过载状态失败不应该影响主流程
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查并恢复因5小时限制被自动停止的账号
|
||
* 用于定时任务自动恢复
|
||
* @returns {Promise<{checked: number, recovered: number, accounts: Array}>}
|
||
*/
|
||
async checkAndRecoverFiveHourStoppedAccounts() {
|
||
const result = {
|
||
checked: 0,
|
||
recovered: 0,
|
||
accounts: []
|
||
}
|
||
|
||
try {
|
||
const accounts = await this.getAllAccounts()
|
||
const now = new Date()
|
||
|
||
for (const account of accounts) {
|
||
// 只检查因5小时限制被自动停止的账号
|
||
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
||
if (account.fiveHourAutoStopped === true && account.schedulable === false) {
|
||
result.checked++
|
||
|
||
// 使用分布式锁防止并发修改
|
||
const lockKey = `lock:account:${account.id}:recovery`
|
||
const lockValue = `${Date.now()}_${Math.random()}`
|
||
const lockTTL = 5000 // 5秒锁超时
|
||
|
||
try {
|
||
// 尝试获取锁
|
||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL)
|
||
if (!lockAcquired) {
|
||
logger.debug(
|
||
`⏭️ Account ${account.name} (${account.id}) is being processed by another instance`
|
||
)
|
||
continue
|
||
}
|
||
|
||
// 重新获取账号数据,确保是最新的
|
||
const latestAccount = await redis.getClaudeAccount(account.id)
|
||
if (
|
||
!latestAccount ||
|
||
latestAccount.fiveHourAutoStopped !== 'true' ||
|
||
latestAccount.schedulable !== 'false'
|
||
) {
|
||
// 账号状态已变化,跳过
|
||
await redis.releaseAccountLock(lockKey, lockValue)
|
||
continue
|
||
}
|
||
|
||
// 检查当前时间是否已经进入新的5小时窗口
|
||
let shouldRecover = false
|
||
let newWindowStart = null
|
||
let newWindowEnd = null
|
||
|
||
if (latestAccount.sessionWindowEnd) {
|
||
const windowEnd = new Date(latestAccount.sessionWindowEnd)
|
||
|
||
// 使用严格的时间比较,添加1分钟缓冲避免边界问题
|
||
if (now.getTime() > windowEnd.getTime() + 60000) {
|
||
shouldRecover = true
|
||
|
||
// 计算新的窗口时间(基于窗口结束时间,而不是当前时间)
|
||
// 这样可以保证窗口时间的连续性
|
||
newWindowStart = new Date(windowEnd)
|
||
newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1)
|
||
newWindowEnd = new Date(newWindowStart)
|
||
newWindowEnd.setHours(newWindowEnd.getHours() + 5)
|
||
|
||
logger.info(
|
||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` +
|
||
`Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` +
|
||
`New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}`
|
||
)
|
||
}
|
||
} else {
|
||
// 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时
|
||
if (latestAccount.fiveHourStoppedAt) {
|
||
const stoppedAt = new Date(latestAccount.fiveHourStoppedAt)
|
||
const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60)
|
||
|
||
// 使用严格的5小时判断,加上1分钟缓冲
|
||
if (hoursSinceStopped > 5.017) {
|
||
// 5小时1分钟
|
||
shouldRecover = true
|
||
newWindowStart = this._calculateSessionWindowStart(now)
|
||
newWindowEnd = this._calculateSessionWindowEnd(newWindowStart)
|
||
|
||
logger.info(
|
||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering`
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (shouldRecover) {
|
||
// 恢复账号调度
|
||
const updatedAccountData = { ...latestAccount }
|
||
|
||
// 恢复调度状态
|
||
updatedAccountData.schedulable = 'true'
|
||
delete updatedAccountData.fiveHourAutoStopped
|
||
delete updatedAccountData.fiveHourStoppedAt
|
||
await this._clearFiveHourWarningMetadata(account.id, updatedAccountData)
|
||
delete updatedAccountData.stoppedReason
|
||
|
||
// 更新会话窗口(如果有新窗口)
|
||
if (newWindowStart && newWindowEnd) {
|
||
updatedAccountData.sessionWindowStart = newWindowStart.toISOString()
|
||
updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString()
|
||
|
||
// 清除会话窗口状态
|
||
delete updatedAccountData.sessionWindowStatus
|
||
delete updatedAccountData.sessionWindowStatusUpdatedAt
|
||
}
|
||
|
||
// 保存更新
|
||
await redis.setClaudeAccount(account.id, updatedAccountData)
|
||
|
||
const fieldsToRemove = ['fiveHourAutoStopped', 'fiveHourStoppedAt']
|
||
if (newWindowStart && newWindowEnd) {
|
||
fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt')
|
||
}
|
||
await this._removeAccountFields(account.id, fieldsToRemove, 'five_hour_recovery_task')
|
||
|
||
result.recovered++
|
||
result.accounts.push({
|
||
id: latestAccount.id,
|
||
name: latestAccount.name,
|
||
oldWindow: latestAccount.sessionWindowEnd
|
||
? {
|
||
start: latestAccount.sessionWindowStart,
|
||
end: latestAccount.sessionWindowEnd
|
||
}
|
||
: null,
|
||
newWindow:
|
||
newWindowStart && newWindowEnd
|
||
? {
|
||
start: newWindowStart.toISOString(),
|
||
end: newWindowEnd.toISOString()
|
||
}
|
||
: null
|
||
})
|
||
|
||
logger.info(
|
||
`✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired`
|
||
)
|
||
}
|
||
|
||
// 释放锁
|
||
await redis.releaseAccountLock(lockKey, lockValue)
|
||
} catch (error) {
|
||
// 确保释放锁
|
||
if (lockKey && lockValue) {
|
||
try {
|
||
await redis.releaseAccountLock(lockKey, lockValue)
|
||
} catch (unlockError) {
|
||
logger.error(`Failed to release lock for account ${account.id}:`, unlockError)
|
||
}
|
||
}
|
||
logger.error(
|
||
`❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`,
|
||
error
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (result.recovered > 0) {
|
||
logger.info(
|
||
`🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered`
|
||
)
|
||
}
|
||
|
||
return result
|
||
} catch (error) {
|
||
logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async _removeAccountFields(accountId, fields = [], context = 'general_cleanup') {
|
||
if (!Array.isArray(fields) || fields.length === 0) {
|
||
return
|
||
}
|
||
|
||
const filteredFields = fields.filter((field) => typeof field === 'string' && field.trim())
|
||
if (filteredFields.length === 0) {
|
||
return
|
||
}
|
||
|
||
const accountKey = `claude:account:${accountId}`
|
||
|
||
try {
|
||
await redis.client.hdel(accountKey, ...filteredFields)
|
||
logger.debug(
|
||
`🧹 已在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]`
|
||
)
|
||
} catch (error) {
|
||
logger.error(
|
||
`❌ 无法在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]:`,
|
||
error
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = new ClaudeAccountService()
|