| const { v4: uuidv4 } = require('uuid') |
| const crypto = require('crypto') |
| const ProxyHelper = require('../utils/proxyHelper') |
| const redis = require('../models/redis') |
| const logger = require('../utils/logger') |
| const config = require('../../config/config') |
| const LRUCache = require('../utils/lruCache') |
|
|
| class ClaudeConsoleAccountService { |
| constructor() { |
| |
| this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' |
| this.ENCRYPTION_SALT = 'claude-console-salt' |
|
|
| |
| this.ACCOUNT_KEY_PREFIX = 'claude_console_account:' |
| this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts' |
|
|
| |
| |
| this._encryptionKeyCache = null |
|
|
| |
| this._decryptCache = new LRUCache(500) |
|
|
| |
| setInterval( |
| () => { |
| this._decryptCache.cleanup() |
| logger.info( |
| '🧹 Claude Console decrypt cache cleanup completed', |
| this._decryptCache.getStats() |
| ) |
| }, |
| 10 * 60 * 1000 |
| ) |
| } |
|
|
| _getBlockedHandlingMinutes() { |
| const raw = process.env.CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES |
| if (raw === undefined || raw === null || raw === '') { |
| return 0 |
| } |
|
|
| const parsed = Number.parseInt(raw, 10) |
| if (!Number.isFinite(parsed) || parsed <= 0) { |
| return 0 |
| } |
|
|
| return parsed |
| } |
|
|
| |
| async createAccount(options = {}) { |
| const { |
| name = 'Claude Console Account', |
| description = '', |
| apiUrl = '', |
| apiKey = '', |
| priority = 50, |
| supportedModels = [], |
| userAgent = 'claude-cli/1.0.69 (external, cli)', |
| rateLimitDuration = 60, |
| proxy = null, |
| isActive = true, |
| accountType = 'shared', |
| schedulable = true, |
| dailyQuota = 0, |
| quotaResetTime = '00:00' |
| } = options |
|
|
| |
| if (!apiUrl || !apiKey) { |
| throw new Error('API URL and API Key are required for Claude Console account') |
| } |
|
|
| const accountId = uuidv4() |
|
|
| |
| const processedModels = this._processModelMapping(supportedModels) |
|
|
| const accountData = { |
| id: accountId, |
| platform: 'claude-console', |
| name, |
| description, |
| apiUrl, |
| apiKey: this._encryptSensitiveData(apiKey), |
| priority: priority.toString(), |
| supportedModels: JSON.stringify(processedModels), |
| userAgent, |
| rateLimitDuration: rateLimitDuration.toString(), |
| proxy: proxy ? JSON.stringify(proxy) : '', |
| isActive: isActive.toString(), |
| accountType, |
| createdAt: new Date().toISOString(), |
| lastUsedAt: '', |
| status: 'active', |
| errorMessage: '', |
|
|
| |
| |
| subscriptionExpiresAt: options.subscriptionExpiresAt || null, |
|
|
| |
| rateLimitedAt: '', |
| rateLimitStatus: '', |
| |
| schedulable: schedulable.toString(), |
| |
| dailyQuota: dailyQuota.toString(), |
| dailyUsage: '0', |
| |
| lastResetDate: redis.getDateStringInTimezone(), |
| quotaResetTime, |
| quotaStoppedAt: '' |
| } |
|
|
| const client = redis.getClientSafe() |
| logger.debug( |
| `[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` |
| ) |
| logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`) |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) |
|
|
| |
| if (accountType === 'shared') { |
| await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) |
| } |
|
|
| logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`) |
|
|
| return { |
| id: accountId, |
| name, |
| description, |
| apiUrl, |
| priority, |
| supportedModels, |
| userAgent, |
| rateLimitDuration, |
| isActive, |
| proxy, |
| accountType, |
| status: 'active', |
| createdAt: accountData.createdAt, |
| dailyQuota, |
| dailyUsage: 0, |
| lastResetDate: accountData.lastResetDate, |
| quotaResetTime, |
| quotaStoppedAt: null |
| } |
| } |
|
|
| |
| async getAllAccounts() { |
| try { |
| const client = redis.getClientSafe() |
| const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) |
| const accounts = [] |
|
|
| for (const key of keys) { |
| const accountData = await client.hgetall(key) |
| if (accountData && Object.keys(accountData).length > 0) { |
| if (!accountData.id) { |
| logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`) |
| await client.del(key) |
| continue |
| } |
|
|
| |
| const rateLimitInfo = this._getRateLimitInfo(accountData) |
|
|
| accounts.push({ |
| id: accountData.id, |
| platform: accountData.platform, |
| name: accountData.name, |
| description: accountData.description, |
| apiUrl: accountData.apiUrl, |
| priority: parseInt(accountData.priority) || 50, |
| supportedModels: JSON.parse(accountData.supportedModels || '[]'), |
| userAgent: accountData.userAgent, |
| rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration)) |
| ? 60 |
| : parseInt(accountData.rateLimitDuration), |
| isActive: accountData.isActive === 'true', |
| proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, |
| accountType: accountData.accountType || 'shared', |
| createdAt: accountData.createdAt, |
| lastUsedAt: accountData.lastUsedAt, |
| status: accountData.status || 'active', |
| errorMessage: accountData.errorMessage, |
| rateLimitInfo, |
| schedulable: accountData.schedulable !== 'false', |
|
|
| |
| expiresAt: accountData.subscriptionExpiresAt || null, |
|
|
| |
| dailyQuota: parseFloat(accountData.dailyQuota || '0'), |
| dailyUsage: parseFloat(accountData.dailyUsage || '0'), |
| lastResetDate: accountData.lastResetDate || '', |
| quotaResetTime: accountData.quotaResetTime || '00:00', |
| quotaStoppedAt: accountData.quotaStoppedAt || null |
| }) |
| } |
| } |
|
|
| return accounts |
| } catch (error) { |
| logger.error('❌ Failed to get Claude Console accounts:', error) |
| throw error |
| } |
| } |
|
|
| |
| async getAccount(accountId) { |
| const client = redis.getClientSafe() |
| logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`) |
| const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| if (!accountData || Object.keys(accountData).length === 0) { |
| logger.debug(`[DEBUG] No account data found for ID: ${accountId}`) |
| return null |
| } |
|
|
| logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`) |
| logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) |
|
|
| |
| const decryptedKey = this._decryptSensitiveData(accountData.apiKey) |
| logger.debug( |
| `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` |
| ) |
|
|
| accountData.apiKey = decryptedKey |
|
|
| |
| const parsedModels = JSON.parse(accountData.supportedModels || '[]') |
| logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) |
|
|
| accountData.supportedModels = parsedModels |
| accountData.priority = parseInt(accountData.priority) || 50 |
| { |
| const _parsedDuration = parseInt(accountData.rateLimitDuration) |
| accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration |
| } |
| accountData.isActive = accountData.isActive === 'true' |
| accountData.schedulable = accountData.schedulable !== 'false' |
|
|
| if (accountData.proxy) { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } |
|
|
| logger.debug( |
| `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` |
| ) |
|
|
| return accountData |
| } |
|
|
| |
| async updateAccount(accountId, updates) { |
| try { |
| const existingAccount = await this.getAccount(accountId) |
| if (!existingAccount) { |
| throw new Error('Account not found') |
| } |
|
|
| const client = redis.getClientSafe() |
| const updatedData = {} |
|
|
| |
| logger.debug( |
| `[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}` |
| ) |
| logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`) |
|
|
| if (updates.name !== undefined) { |
| updatedData.name = updates.name |
| } |
| if (updates.description !== undefined) { |
| updatedData.description = updates.description |
| } |
| if (updates.apiUrl !== undefined) { |
| logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`) |
| updatedData.apiUrl = updates.apiUrl |
| } |
| if (updates.apiKey !== undefined) { |
| logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`) |
| updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) |
| } |
| if (updates.priority !== undefined) { |
| updatedData.priority = updates.priority.toString() |
| } |
| if (updates.supportedModels !== undefined) { |
| logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) |
| |
| const processedModels = this._processModelMapping(updates.supportedModels) |
| updatedData.supportedModels = JSON.stringify(processedModels) |
| } |
| if (updates.userAgent !== undefined) { |
| updatedData.userAgent = updates.userAgent |
| } |
| if (updates.rateLimitDuration !== undefined) { |
| updatedData.rateLimitDuration = updates.rateLimitDuration.toString() |
| } |
| if (updates.proxy !== undefined) { |
| updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' |
| } |
| if (updates.isActive !== undefined) { |
| updatedData.isActive = updates.isActive.toString() |
| } |
| if (updates.schedulable !== undefined) { |
| updatedData.schedulable = updates.schedulable.toString() |
| |
| |
| updatedData.rateLimitAutoStopped = '' |
| updatedData.quotaAutoStopped = '' |
| |
| updatedData.autoStoppedAt = '' |
| updatedData.stoppedReason = '' |
|
|
| |
| if (updates.schedulable === true || updates.schedulable === 'true') { |
| logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`) |
| } else { |
| logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`) |
| } |
| } |
|
|
| |
| if (updates.dailyQuota !== undefined) { |
| updatedData.dailyQuota = updates.dailyQuota.toString() |
| } |
| if (updates.quotaResetTime !== undefined) { |
| updatedData.quotaResetTime = updates.quotaResetTime |
| } |
| if (updates.dailyUsage !== undefined) { |
| updatedData.dailyUsage = updates.dailyUsage.toString() |
| } |
| if (updates.lastResetDate !== undefined) { |
| updatedData.lastResetDate = updates.lastResetDate |
| } |
| if (updates.quotaStoppedAt !== undefined) { |
| updatedData.quotaStoppedAt = updates.quotaStoppedAt |
| } |
|
|
| |
| |
| if (updates.subscriptionExpiresAt !== undefined) { |
| updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt |
| } |
|
|
| |
| if (updates.accountType && updates.accountType !== existingAccount.accountType) { |
| updatedData.accountType = updates.accountType |
|
|
| if (updates.accountType === 'shared') { |
| await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) |
| } else { |
| await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) |
| } |
| } |
|
|
| updatedData.updatedAt = new Date().toISOString() |
|
|
| |
| if (updates.isActive === false && existingAccount.isActive === true) { |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: updatedData.name || existingAccount.name || 'Unknown Account', |
| platform: 'claude-console', |
| status: 'disabled', |
| errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', |
| reason: 'Account manually disabled by administrator' |
| }) |
| } catch (webhookError) { |
| logger.error( |
| 'Failed to send webhook notification for manual account disable:', |
| webhookError |
| ) |
| } |
| } |
|
|
| logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`) |
| logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) |
|
|
| logger.success(`📝 Updated Claude Console account: ${accountId}`) |
|
|
| return { success: true } |
| } catch (error) { |
| logger.error('❌ Failed to update Claude Console account:', error) |
| throw error |
| } |
| } |
|
|
| |
| async deleteAccount(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const account = await this.getAccount(accountId) |
|
|
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| |
| await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| |
| if (account.accountType === 'shared') { |
| await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) |
| } |
|
|
| logger.success(`🗑️ Deleted Claude Console account: ${accountId}`) |
|
|
| return { success: true } |
| } catch (error) { |
| logger.error('❌ Failed to delete Claude Console account:', error) |
| throw error |
| } |
| } |
|
|
| |
| async markAccountRateLimited(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const account = await this.getAccount(accountId) |
|
|
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| |
| if (account.rateLimitDuration === 0) { |
| logger.info( |
| `ℹ️ Claude Console account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` |
| ) |
| return { success: true, skipped: true } |
| } |
|
|
| const updates = { |
| rateLimitedAt: new Date().toISOString(), |
| rateLimitStatus: 'limited', |
| isActive: 'false', |
| schedulable: 'false', |
| errorMessage: `Rate limited at ${new Date().toISOString()}`, |
| |
| rateLimitAutoStopped: 'true' |
| } |
|
|
| |
| |
| const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status') |
| if (currentStatus !== 'quota_exceeded') { |
| updates.status = 'rate_limited' |
| } |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| const { getISOStringWithTimezone } = require('../utils/dateHelper') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || 'Claude Console Account', |
| platform: 'claude-console', |
| status: 'error', |
| errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', |
| reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, |
| timestamp: getISOStringWithTimezone(new Date()) |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send rate limit webhook notification:', webhookError) |
| } |
|
|
| logger.warn( |
| `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` |
| ) |
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| async removeAccountRateLimit(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| const [currentStatus, quotaStoppedAt] = await client.hmget( |
| accountKey, |
| 'status', |
| 'quotaStoppedAt' |
| ) |
|
|
| |
| await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') |
|
|
| |
| if (currentStatus === 'rate_limited') { |
| if (quotaStoppedAt) { |
| |
| await client.hset(accountKey, { |
| status: 'quota_exceeded' |
| |
| }) |
| logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) |
| } else { |
| |
| const accountData = await client.hgetall(accountKey) |
| const updateData = { |
| isActive: 'true', |
| status: 'active', |
| errorMessage: '' |
| } |
|
|
| const hadAutoStop = accountData.rateLimitAutoStopped === 'true' |
|
|
| |
| if (hadAutoStop && accountData.schedulable === 'false') { |
| updateData.schedulable = 'true' |
| logger.info( |
| `✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared` |
| ) |
| } |
|
|
| if (hadAutoStop) { |
| await client.hdel(accountKey, 'rateLimitAutoStopped') |
| } |
|
|
| await client.hset(accountKey, updateData) |
| logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`) |
| } |
| } else { |
| if (await client.hdel(accountKey, 'rateLimitAutoStopped')) { |
| logger.info( |
| `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery` |
| ) |
| } |
| logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) |
| } |
|
|
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| async isAccountRateLimited(accountId) { |
| try { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return false |
| } |
|
|
| |
| if (account.rateLimitDuration === 0) { |
| return false |
| } |
|
|
| if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { |
| const rateLimitedAt = new Date(account.rateLimitedAt) |
| const now = new Date() |
| const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60) |
|
|
| |
| const rateLimitDuration = |
| typeof account.rateLimitDuration === 'number' && !Number.isNaN(account.rateLimitDuration) |
| ? account.rateLimitDuration |
| : 60 |
|
|
| if (minutesSinceRateLimit >= rateLimitDuration) { |
| await this.removeAccountRateLimit(accountId) |
| return false |
| } |
|
|
| return true |
| } |
|
|
| return false |
| } catch (error) { |
| logger.error( |
| `❌ Failed to check rate limit status for Claude Console account: ${accountId}`, |
| error |
| ) |
| return false |
| } |
| } |
|
|
| |
| async isAccountQuotaExceeded(accountId) { |
| try { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return false |
| } |
|
|
| |
| const dailyQuota = parseFloat(account.dailyQuota || '0') |
| if (isNaN(dailyQuota) || dailyQuota <= 0) { |
| return false |
| } |
|
|
| |
| if (!account.quotaStoppedAt) { |
| return false |
| } |
|
|
| |
| if (this._shouldResetQuota(account)) { |
| await this.resetDailyUsage(accountId) |
| return false |
| } |
|
|
| |
| return true |
| } catch (error) { |
| logger.error( |
| `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`, |
| error |
| ) |
| return false |
| } |
| } |
|
|
| |
| _shouldResetQuota(account) { |
| |
| const tzNow = redis.getDateInTimezone(new Date()) |
| const today = redis.getDateStringInTimezone(tzNow) |
|
|
| |
| if (account.lastResetDate === today) { |
| return false |
| } |
|
|
| |
| const resetTime = account.quotaResetTime || '00:00' |
| const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n)) |
|
|
| const currentHour = tzNow.getUTCHours() |
| const currentMinute = tzNow.getUTCMinutes() |
|
|
| |
| return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute) |
| } |
|
|
| |
| async markAccountUnauthorized(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const account = await this.getAccount(accountId) |
|
|
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const updates = { |
| schedulable: 'false', |
| status: 'unauthorized', |
| errorMessage: 'API Key无效或已过期(401错误)', |
| unauthorizedAt: new Date().toISOString(), |
| unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1) |
| } |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || 'Claude Console Account', |
| platform: 'claude-console', |
| status: 'error', |
| errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED', |
| reason: 'API Key无效或已过期(401错误),账户已停止调度', |
| timestamp: new Date().toISOString() |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send unauthorized webhook notification:', webhookError) |
| } |
|
|
| logger.warn( |
| `🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})` |
| ) |
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| async markConsoleAccountBlocked(accountId, errorDetails = '') { |
| try { |
| const client = redis.getClientSafe() |
| const account = await this.getAccount(accountId) |
|
|
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const blockedMinutes = this._getBlockedHandlingMinutes() |
|
|
| if (blockedMinutes <= 0) { |
| logger.info( |
| `ℹ️ CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 未设置或为0,跳过账户封禁:${account.name} (${accountId})` |
| ) |
|
|
| if (account.blockedStatus === 'blocked') { |
| try { |
| await this.removeAccountBlocked(accountId) |
| } catch (cleanupError) { |
| logger.warn(`⚠️ 尝试移除账户封禁状态失败:${accountId}`, cleanupError) |
| } |
| } |
|
|
| return { success: false, skipped: true } |
| } |
|
|
| const updates = { |
| blockedAt: new Date().toISOString(), |
| blockedStatus: 'blocked', |
| isActive: 'false', |
| schedulable: 'false', |
| status: 'account_blocked', |
| errorMessage: '账户临时被禁用(400错误)', |
| |
| blockedAutoStopped: 'true' |
| } |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || 'Claude Console Account', |
| platform: 'claude-console', |
| status: 'error', |
| errorCode: 'CLAUDE_CONSOLE_BLOCKED', |
| reason: `账户临时被禁用(400错误)。账户将在 ${blockedMinutes} 分钟后自动恢复。`, |
| errorDetails: errorDetails || '无错误详情', |
| timestamp: new Date().toISOString() |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send blocked webhook notification:', webhookError) |
| } |
|
|
| logger.warn(`🚫 Claude Console account temporarily blocked: ${account.name} (${accountId})`) |
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to mark Claude Console account as blocked: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| async removeAccountBlocked(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| const [currentStatus, quotaStoppedAt] = await client.hmget( |
| accountKey, |
| 'status', |
| 'quotaStoppedAt' |
| ) |
|
|
| |
| await client.hdel(accountKey, 'blockedAt', 'blockedStatus') |
|
|
| |
| if (currentStatus === 'account_blocked') { |
| if (quotaStoppedAt) { |
| |
| await client.hset(accountKey, { |
| status: 'quota_exceeded' |
| |
| }) |
| logger.info( |
| `⚠️ Blocked status removed but quota exceeded remains for account: ${accountId}` |
| ) |
| } else { |
| |
| const accountData = await client.hgetall(accountKey) |
| const updateData = { |
| isActive: 'true', |
| status: 'active', |
| errorMessage: '' |
| } |
|
|
| const hadAutoStop = accountData.blockedAutoStopped === 'true' |
|
|
| |
| if (hadAutoStop && accountData.schedulable === 'false') { |
| updateData.schedulable = 'true' |
| logger.info( |
| `✅ Auto-resuming scheduling for Claude Console account ${accountId} after blocked status cleared` |
| ) |
| } |
|
|
| if (hadAutoStop) { |
| await client.hdel(accountKey, 'blockedAutoStopped') |
| } |
|
|
| await client.hset(accountKey, updateData) |
| logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`) |
| } |
| } else { |
| if (await client.hdel(accountKey, 'blockedAutoStopped')) { |
| logger.info( |
| `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery` |
| ) |
| } |
| logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`) |
| } |
|
|
| return { success: true } |
| } catch (error) { |
| logger.error( |
| `❌ Failed to remove blocked status for Claude Console account: ${accountId}`, |
| error |
| ) |
| throw error |
| } |
| } |
|
|
| |
| async isAccountBlocked(accountId) { |
| try { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return false |
| } |
|
|
| if (account.blockedStatus === 'blocked' && account.blockedAt) { |
| const blockedDuration = this._getBlockedHandlingMinutes() |
|
|
| if (blockedDuration <= 0) { |
| await this.removeAccountBlocked(accountId) |
| return false |
| } |
|
|
| const blockedAt = new Date(account.blockedAt) |
| const now = new Date() |
| const minutesSinceBlocked = (now - blockedAt) / (1000 * 60) |
|
|
| |
| if (minutesSinceBlocked >= blockedDuration) { |
| await this.removeAccountBlocked(accountId) |
| return false |
| } |
|
|
| return true |
| } |
|
|
| return false |
| } catch (error) { |
| logger.error( |
| `❌ Failed to check blocked status for Claude Console account: ${accountId}`, |
| error |
| ) |
| return false |
| } |
| } |
|
|
| |
| async markAccountOverloaded(accountId) { |
| try { |
| const client = redis.getClientSafe() |
| const account = await this.getAccount(accountId) |
|
|
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const updates = { |
| overloadedAt: new Date().toISOString(), |
| overloadStatus: 'overloaded', |
| errorMessage: '服务过载(529错误)' |
| } |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || 'Claude Console Account', |
| platform: 'claude-console', |
| status: 'error', |
| errorCode: 'CLAUDE_CONSOLE_OVERLOADED', |
| reason: '服务过载(529错误)。账户将暂时停止调度', |
| timestamp: new Date().toISOString() |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send overload webhook notification:', webhookError) |
| } |
|
|
| logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`) |
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| async removeAccountOverload(accountId) { |
| try { |
| const client = redis.getClientSafe() |
|
|
| await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') |
|
|
| logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`) |
| return { success: true } |
| } catch (error) { |
| logger.error( |
| `❌ Failed to remove overload status for Claude Console account: ${accountId}`, |
| error |
| ) |
| throw error |
| } |
| } |
|
|
| |
| async isAccountOverloaded(accountId) { |
| try { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return false |
| } |
|
|
| if (account.overloadStatus === 'overloaded' && account.overloadedAt) { |
| const overloadedAt = new Date(account.overloadedAt) |
| const now = new Date() |
| const minutesSinceOverload = (now - overloadedAt) / (1000 * 60) |
|
|
| |
| if (minutesSinceOverload >= 10) { |
| await this.removeAccountOverload(accountId) |
| return false |
| } |
|
|
| return true |
| } |
|
|
| return false |
| } catch (error) { |
| logger.error( |
| `❌ Failed to check overload status for Claude Console account: ${accountId}`, |
| error |
| ) |
| return false |
| } |
| } |
|
|
| |
| async blockAccount(accountId, reason) { |
| try { |
| const client = redis.getClientSafe() |
|
|
| |
| const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| const updates = { |
| status: 'blocked', |
| errorMessage: reason, |
| blockedAt: new Date().toISOString() |
| } |
|
|
| await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`) |
|
|
| |
| if (accountData && Object.keys(accountData).length > 0) { |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: accountData.name || 'Unknown Account', |
| platform: 'claude-console', |
| status: 'blocked', |
| errorCode: 'CLAUDE_CONSOLE_BLOCKED', |
| reason |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send webhook notification:', webhookError) |
| } |
| } |
|
|
| return { success: true } |
| } catch (error) { |
| logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| _createProxyAgent(proxyConfig) { |
| const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) |
| if (proxyAgent) { |
| logger.info( |
| `🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}` |
| ) |
| } else if (proxyConfig) { |
| logger.debug('🌐 Failed to create proxy agent for Claude Console') |
| } else { |
| logger.debug('🌐 No proxy configured for Claude Console 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') |
|
|
| 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 { |
| if (encryptedData.includes(':')) { |
| 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) |
| let decrypted = decipher.update(encrypted, 'hex', 'utf8') |
| decrypted += decipher.final('utf8') |
|
|
| |
| this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) |
|
|
| |
| if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { |
| this._decryptCache.printStats() |
| } |
|
|
| return decrypted |
| } |
| } |
|
|
| return encryptedData |
| } catch (error) { |
| logger.error('❌ Decryption error:', error) |
| return encryptedData |
| } |
| } |
|
|
| |
| _generateEncryptionKey() { |
| |
| |
| |
| if (!this._encryptionKeyCache) { |
| |
| |
| this._encryptionKeyCache = crypto.scryptSync( |
| config.security.encryptionKey, |
| this.ENCRYPTION_SALT, |
| 32 |
| ) |
| logger.info('🔑 Console encryption key derived and cached for performance optimization') |
| } |
| return this._encryptionKeyCache |
| } |
|
|
| |
| _maskApiUrl(apiUrl) { |
| if (!apiUrl) { |
| return '' |
| } |
|
|
| try { |
| const url = new URL(apiUrl) |
| return `${url.protocol}//${url.hostname}/***` |
| } catch { |
| return '***' |
| } |
| } |
|
|
| |
| _getRateLimitInfo(accountData) { |
| if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { |
| const rateLimitedAt = new Date(accountData.rateLimitedAt) |
| const now = new Date() |
| const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) |
| const __parsedDuration = parseInt(accountData.rateLimitDuration) |
| const rateLimitDuration = Number.isNaN(__parsedDuration) ? 60 : __parsedDuration |
| const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit) |
|
|
| return { |
| isRateLimited: minutesRemaining > 0, |
| rateLimitedAt: accountData.rateLimitedAt, |
| minutesSinceRateLimit, |
| minutesRemaining |
| } |
| } |
|
|
| return { |
| isRateLimited: false, |
| rateLimitedAt: null, |
| minutesSinceRateLimit: 0, |
| minutesRemaining: 0 |
| } |
| } |
|
|
| |
| _processModelMapping(supportedModels) { |
| |
| if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { |
| return {} |
| } |
|
|
| |
| if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { |
| return supportedModels |
| } |
|
|
| |
| if (Array.isArray(supportedModels)) { |
| const mapping = {} |
| supportedModels.forEach((model) => { |
| if (model && typeof model === 'string') { |
| mapping[model] = model |
| } |
| }) |
| return mapping |
| } |
|
|
| |
| return {} |
| } |
|
|
| |
| isModelSupported(modelMapping, requestedModel) { |
| |
| if (!modelMapping || Object.keys(modelMapping).length === 0) { |
| return true |
| } |
|
|
| |
| if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) { |
| return true |
| } |
|
|
| |
| const requestedModelLower = requestedModel.toLowerCase() |
| for (const key of Object.keys(modelMapping)) { |
| if (key.toLowerCase() === requestedModelLower) { |
| return true |
| } |
| } |
|
|
| return false |
| } |
|
|
| |
| getMappedModel(modelMapping, requestedModel) { |
| |
| if (!modelMapping || Object.keys(modelMapping).length === 0) { |
| return requestedModel |
| } |
|
|
| |
| if (modelMapping[requestedModel]) { |
| return modelMapping[requestedModel] |
| } |
|
|
| |
| const requestedModelLower = requestedModel.toLowerCase() |
| for (const [key, value] of Object.entries(modelMapping)) { |
| if (key.toLowerCase() === requestedModelLower) { |
| return value |
| } |
| } |
|
|
| |
| return requestedModel |
| } |
|
|
| |
| async checkQuotaUsage(accountId) { |
| try { |
| |
| const usageStats = await redis.getAccountUsageStats(accountId) |
| const currentDailyCost = usageStats.daily.cost || 0 |
|
|
| |
| const accountData = await this.getAccount(accountId) |
| if (!accountData) { |
| logger.warn(`Account not found: ${accountId}`) |
| return |
| } |
|
|
| |
| const dailyQuota = parseFloat(accountData.dailyQuota || '0') |
| if (isNaN(dailyQuota) || dailyQuota <= 0) { |
| |
| return |
| } |
|
|
| |
| if (!accountData.isActive && accountData.quotaStoppedAt) { |
| return |
| } |
|
|
| |
| if (currentDailyCost >= dailyQuota) { |
| |
| const client = redis.getClientSafe() |
| const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt') |
| if (existingQuotaStop) { |
| return |
| } |
|
|
| |
| const updates = { |
| isActive: false, |
| quotaStoppedAt: new Date().toISOString(), |
| errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`, |
| schedulable: false, |
| |
| quotaAutoStopped: 'true' |
| } |
|
|
| |
| |
| const currentStatus = await client.hget(accountKey, 'status') |
| if (currentStatus === 'active') { |
| updates.status = 'quota_exceeded' |
| } |
|
|
| await this.updateAccount(accountId, updates) |
|
|
| logger.warn( |
| `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` |
| ) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: accountData.name || 'Unknown Account', |
| platform: 'claude-console', |
| status: 'quota_exceeded', |
| errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED', |
| reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` |
| }) |
| } catch (webhookError) { |
| logger.error('Failed to send webhook notification for quota exceeded:', webhookError) |
| } |
| } |
|
|
| logger.debug( |
| `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}` |
| ) |
| } catch (error) { |
| logger.error('Failed to check quota usage:', error) |
| } |
| } |
|
|
| |
| async resetDailyUsage(accountId) { |
| try { |
| const accountData = await this.getAccount(accountId) |
| if (!accountData) { |
| return |
| } |
|
|
| const today = redis.getDateStringInTimezone() |
| const updates = { |
| lastResetDate: today |
| } |
|
|
| |
| |
| if ( |
| accountData.quotaStoppedAt && |
| accountData.isActive === false && |
| (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited') |
| ) { |
| updates.isActive = true |
| updates.status = 'active' |
| updates.errorMessage = '' |
| updates.quotaStoppedAt = '' |
|
|
| |
| if (accountData.quotaAutoStopped === 'true') { |
| updates.schedulable = true |
| updates.quotaAutoStopped = '' |
| } |
|
|
| |
| if (accountData.status === 'rate_limited') { |
| const client = redis.getClientSafe() |
| const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
| await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped') |
| } |
|
|
| logger.info( |
| `✅ Restored account ${accountId} after daily reset (was ${accountData.status})` |
| ) |
| } |
|
|
| await this.updateAccount(accountId, updates) |
|
|
| logger.debug(`🔄 Reset daily usage for account ${accountId}`) |
| } catch (error) { |
| logger.error('Failed to reset daily usage:', error) |
| } |
| } |
|
|
| |
| async resetAllDailyUsage() { |
| try { |
| const accounts = await this.getAllAccounts() |
| |
| const today = redis.getDateStringInTimezone() |
| let resetCount = 0 |
|
|
| for (const account of accounts) { |
| |
| if (account.lastResetDate !== today) { |
| await this.resetDailyUsage(account.id) |
| resetCount += 1 |
| } |
| } |
|
|
| logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`) |
| } catch (error) { |
| logger.error('Failed to reset all daily usage:', error) |
| } |
| } |
|
|
| |
| async getAccountUsageStats(accountId) { |
| try { |
| |
| const usageStats = await redis.getAccountUsageStats(accountId) |
| const currentDailyCost = usageStats.daily.cost || 0 |
|
|
| |
| const accountData = await this.getAccount(accountId) |
| if (!accountData) { |
| return null |
| } |
|
|
| const dailyQuota = parseFloat(accountData.dailyQuota || '0') |
|
|
| return { |
| dailyQuota, |
| dailyUsage: currentDailyCost, |
| remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, |
| usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, |
| lastResetDate: accountData.lastResetDate, |
| quotaStoppedAt: accountData.quotaStoppedAt, |
| isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, |
| |
| fullUsageStats: usageStats |
| } |
| } catch (error) { |
| logger.error('Failed to get account usage stats:', error) |
| return null |
| } |
| } |
|
|
| |
| async resetAccountStatus(accountId) { |
| try { |
| const accountData = await this.getAccount(accountId) |
| if (!accountData) { |
| throw new Error('Account not found') |
| } |
|
|
| const client = redis.getClientSafe() |
| const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| const updates = { |
| status: 'active', |
| errorMessage: '', |
| schedulable: 'true', |
| isActive: 'true' |
| } |
|
|
| |
| const fieldsToDelete = [ |
| 'rateLimitedAt', |
| 'rateLimitStatus', |
| 'unauthorizedAt', |
| 'unauthorizedCount', |
| 'overloadedAt', |
| 'overloadStatus', |
| 'blockedAt', |
| 'quotaStoppedAt' |
| ] |
|
|
| |
| await client.hset(accountKey, updates) |
| await client.hdel(accountKey, ...fieldsToDelete) |
|
|
| logger.success(`✅ Reset all error status for Claude Console account ${accountId}`) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: accountData.name || accountId, |
| platform: 'claude-console', |
| status: 'recovered', |
| errorCode: 'STATUS_RESET', |
| reason: 'Account status manually reset', |
| timestamp: new Date().toISOString() |
| }) |
| } catch (webhookError) { |
| logger.warn('Failed to send webhook notification:', webhookError) |
| } |
|
|
| return { success: true, accountId } |
| } catch (error) { |
| logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error) |
| throw error |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| isSubscriptionExpired(account) { |
| if (!account.subscriptionExpiresAt) { |
| return false |
| } |
| const expiryDate = new Date(account.subscriptionExpiresAt) |
| return expiryDate <= new Date() |
| } |
| } |
|
|
| module.exports = new ClaudeConsoleAccountService() |
|
|