|
|
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() |
|
|
|