|
|
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 CcrAccountService { |
|
|
constructor() { |
|
|
|
|
|
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' |
|
|
this.ENCRYPTION_SALT = 'ccr-account-salt' |
|
|
|
|
|
|
|
|
this.ACCOUNT_KEY_PREFIX = 'ccr_account:' |
|
|
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts' |
|
|
|
|
|
|
|
|
|
|
|
this._encryptionKeyCache = null |
|
|
|
|
|
|
|
|
this._decryptCache = new LRUCache(500) |
|
|
|
|
|
|
|
|
setInterval( |
|
|
() => { |
|
|
this._decryptCache.cleanup() |
|
|
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats()) |
|
|
}, |
|
|
10 * 60 * 1000 |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
async createAccount(options = {}) { |
|
|
const { |
|
|
name = 'CCR Account', |
|
|
description = '', |
|
|
apiUrl = '', |
|
|
apiKey = '', |
|
|
priority = 50, |
|
|
supportedModels = [], |
|
|
userAgent = 'claude-relay-service/1.0.0', |
|
|
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 CCR account') |
|
|
} |
|
|
|
|
|
const accountId = uuidv4() |
|
|
|
|
|
|
|
|
const processedModels = this._processModelMapping(supportedModels) |
|
|
|
|
|
const accountData = { |
|
|
id: accountId, |
|
|
platform: 'ccr', |
|
|
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, |
|
|
|
|
|
|
|
|
|
|
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null, |
|
|
|
|
|
createdAt: new Date().toISOString(), |
|
|
lastUsedAt: '', |
|
|
status: 'active', |
|
|
errorMessage: '', |
|
|
|
|
|
rateLimitedAt: '', |
|
|
rateLimitStatus: '', |
|
|
|
|
|
schedulable: schedulable.toString(), |
|
|
|
|
|
dailyQuota: dailyQuota.toString(), |
|
|
dailyUsage: '0', |
|
|
|
|
|
lastResetDate: redis.getDateStringInTimezone(), |
|
|
quotaResetTime, |
|
|
quotaStoppedAt: '' |
|
|
} |
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
logger.debug( |
|
|
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
) |
|
|
logger.debug(`[DEBUG] CCR 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 CCR 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) { |
|
|
|
|
|
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 CCR accounts:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAccount(accountId) { |
|
|
const client = redis.getClientSafe() |
|
|
logger.debug(`[DEBUG] Getting CCR 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 CCR account data found for ID: ${accountId}`) |
|
|
return null |
|
|
} |
|
|
|
|
|
logger.debug(`[DEBUG] Raw CCR 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 CCR 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('CCR Account not found') |
|
|
} |
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
const updatedData = {} |
|
|
|
|
|
|
|
|
logger.debug( |
|
|
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}` |
|
|
) |
|
|
logger.debug(`[DEBUG] CCR 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) { |
|
|
updatedData.apiUrl = updates.apiUrl |
|
|
} |
|
|
if (updates.apiKey !== undefined) { |
|
|
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() |
|
|
} |
|
|
if (updates.dailyQuota !== undefined) { |
|
|
updatedData.dailyQuota = updates.dailyQuota.toString() |
|
|
} |
|
|
if (updates.quotaResetTime !== undefined) { |
|
|
updatedData.quotaResetTime = updates.quotaResetTime |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (updates.subscriptionExpiresAt !== undefined) { |
|
|
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt |
|
|
} |
|
|
|
|
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) |
|
|
|
|
|
|
|
|
if (updates.accountType !== undefined) { |
|
|
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) |
|
|
} |
|
|
} |
|
|
|
|
|
logger.success(`📝 Updated CCR account: ${accountId}`) |
|
|
return await this.getAccount(accountId) |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to update CCR account ${accountId}:`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async deleteAccount(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
|
|
|
|
|
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) |
|
|
|
|
|
|
|
|
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
|
|
|
if (result === 0) { |
|
|
throw new Error('CCR Account not found or already deleted') |
|
|
} |
|
|
|
|
|
logger.success(`🗑️ Deleted CCR account: ${accountId}`) |
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async markAccountRateLimited(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('CCR Account not found') |
|
|
} |
|
|
|
|
|
|
|
|
if (account.rateLimitDuration === 0) { |
|
|
logger.info( |
|
|
`ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` |
|
|
) |
|
|
return { success: true, skipped: true } |
|
|
} |
|
|
|
|
|
const now = new Date().toISOString() |
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
status: 'rate_limited', |
|
|
rateLimitedAt: now, |
|
|
rateLimitStatus: 'active', |
|
|
errorMessage: 'Rate limited by upstream service' |
|
|
}) |
|
|
|
|
|
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`) |
|
|
return { success: true, rateLimitedAt: now } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async removeAccountRateLimit(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
|
|
|
|
|
|
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt') |
|
|
|
|
|
|
|
|
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') |
|
|
|
|
|
|
|
|
let newStatus = 'active' |
|
|
let errorMessage = '' |
|
|
|
|
|
|
|
|
if (quotaStoppedAt) { |
|
|
newStatus = 'quota_exceeded' |
|
|
errorMessage = 'Account stopped due to quota exceeded' |
|
|
logger.info( |
|
|
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded` |
|
|
) |
|
|
} else { |
|
|
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`) |
|
|
} |
|
|
|
|
|
await client.hmset(accountKey, { |
|
|
status: newStatus, |
|
|
errorMessage |
|
|
}) |
|
|
|
|
|
return { success: true, newStatus } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async isAccountRateLimited(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
const [rateLimitedAt, rateLimitDuration] = await client.hmget( |
|
|
accountKey, |
|
|
'rateLimitedAt', |
|
|
'rateLimitDuration' |
|
|
) |
|
|
|
|
|
if (rateLimitedAt) { |
|
|
const limitTime = new Date(rateLimitedAt) |
|
|
const duration = parseInt(rateLimitDuration) || 60 |
|
|
const now = new Date() |
|
|
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000) |
|
|
|
|
|
if (now < expireTime) { |
|
|
return true |
|
|
} else { |
|
|
|
|
|
await this.removeAccountRateLimit(accountId) |
|
|
return false |
|
|
} |
|
|
} |
|
|
return false |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error) |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async markAccountOverloaded(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('CCR Account not found') |
|
|
} |
|
|
|
|
|
const now = new Date().toISOString() |
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
status: 'overloaded', |
|
|
overloadedAt: now, |
|
|
errorMessage: 'Account overloaded' |
|
|
}) |
|
|
|
|
|
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`) |
|
|
return { success: true, overloadedAt: now } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async removeAccountOverload(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
|
|
|
|
|
|
await client.hdel(accountKey, 'overloadedAt') |
|
|
|
|
|
await client.hmset(accountKey, { |
|
|
status: 'active', |
|
|
errorMessage: '' |
|
|
}) |
|
|
|
|
|
logger.success(`✅ Removed overload status for CCR account: ${accountId}`) |
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async isAccountOverloaded(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
const status = await client.hget(accountKey, 'status') |
|
|
return status === 'overloaded' |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error) |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async markAccountUnauthorized(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('CCR Account not found') |
|
|
} |
|
|
|
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
status: 'unauthorized', |
|
|
errorMessage: 'API key invalid or unauthorized' |
|
|
}) |
|
|
|
|
|
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`) |
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_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 |
|
|
} |
|
|
|
|
|
|
|
|
_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('❌ CCR 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 { |
|
|
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) |
|
|
|
|
|
return decrypted |
|
|
} else { |
|
|
logger.error('❌ Invalid CCR encrypted data format') |
|
|
return encryptedData |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ CCR decryption error:', error) |
|
|
return encryptedData |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_generateEncryptionKey() { |
|
|
|
|
|
if (!this._encryptionKeyCache) { |
|
|
this._encryptionKeyCache = crypto.scryptSync( |
|
|
config.security.encryptionKey, |
|
|
this.ENCRYPTION_SALT, |
|
|
32 |
|
|
) |
|
|
} |
|
|
return this._encryptionKeyCache |
|
|
} |
|
|
|
|
|
|
|
|
_getRateLimitInfo(accountData) { |
|
|
const { rateLimitedAt } = accountData |
|
|
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 |
|
|
|
|
|
if (rateLimitedAt) { |
|
|
const limitTime = new Date(rateLimitedAt) |
|
|
const now = new Date() |
|
|
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000) |
|
|
const remainingMs = expireTime.getTime() - now.getTime() |
|
|
|
|
|
return { |
|
|
isRateLimited: remainingMs > 0, |
|
|
rateLimitedAt, |
|
|
rateLimitExpireAt: expireTime.toISOString(), |
|
|
remainingTimeMs: Math.max(0, remainingMs), |
|
|
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000))) |
|
|
} |
|
|
} |
|
|
|
|
|
return { |
|
|
isRateLimited: false, |
|
|
rateLimitedAt: null, |
|
|
rateLimitExpireAt: null, |
|
|
remainingTimeMs: 0, |
|
|
remainingTimeMinutes: 0 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_createProxyAgent(proxy) { |
|
|
return ProxyHelper.createProxyAgent(proxy) |
|
|
} |
|
|
|
|
|
|
|
|
async checkQuotaUsage(accountId) { |
|
|
try { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return false |
|
|
} |
|
|
|
|
|
const dailyQuota = parseFloat(account.dailyQuota || '0') |
|
|
|
|
|
if (dailyQuota <= 0) { |
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
const today = redis.getDateStringInTimezone() |
|
|
if (account.lastResetDate !== today) { |
|
|
await this.resetDailyUsage(accountId) |
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
const usageStats = await this.getAccountUsageStats(accountId) |
|
|
if (!usageStats) { |
|
|
return false |
|
|
} |
|
|
|
|
|
const dailyUsage = usageStats.dailyUsage || 0 |
|
|
const isExceeded = dailyUsage >= dailyQuota |
|
|
|
|
|
if (isExceeded) { |
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
status: 'quota_exceeded', |
|
|
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, |
|
|
quotaStoppedAt: new Date().toISOString() |
|
|
}) |
|
|
logger.warn( |
|
|
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` |
|
|
) |
|
|
|
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account.name || accountId, |
|
|
platform: 'ccr', |
|
|
status: 'quota_exceeded', |
|
|
errorCode: 'QUOTA_EXCEEDED', |
|
|
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} catch (webhookError) { |
|
|
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError) |
|
|
} |
|
|
} |
|
|
|
|
|
return isExceeded |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error) |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async resetDailyUsage(accountId) { |
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
dailyUsage: '0', |
|
|
lastResetDate: redis.getDateStringInTimezone(), |
|
|
quotaStoppedAt: '' |
|
|
}) |
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async isAccountQuotaExceeded(accountId) { |
|
|
try { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return false |
|
|
} |
|
|
|
|
|
const dailyQuota = parseFloat(account.dailyQuota || '0') |
|
|
|
|
|
if (dailyQuota <= 0) { |
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
const usageStats = await this.getAccountUsageStats(accountId) |
|
|
if (!usageStats) { |
|
|
return false |
|
|
} |
|
|
|
|
|
const dailyUsage = usageStats.dailyUsage || 0 |
|
|
const isExceeded = dailyUsage >= dailyQuota |
|
|
|
|
|
if (isExceeded && !account.quotaStoppedAt) { |
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { |
|
|
status: 'quota_exceeded', |
|
|
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, |
|
|
quotaStoppedAt: new Date().toISOString() |
|
|
}) |
|
|
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`) |
|
|
} |
|
|
|
|
|
return isExceeded |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error) |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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} CCR accounts`) |
|
|
return { success: true, resetCount } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to reset all CCR daily usage:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAccountUsageStats(accountId) { |
|
|
try { |
|
|
|
|
|
const usageStats = await redis.getAccountUsageStats(accountId) |
|
|
|
|
|
|
|
|
const accountData = await this.getAccount(accountId) |
|
|
if (!accountData) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const dailyQuota = parseFloat(accountData.dailyQuota || '0') |
|
|
const currentDailyCost = usageStats?.daily?.cost || 0 |
|
|
|
|
|
return { |
|
|
dailyQuota, |
|
|
dailyUsage: currentDailyCost, |
|
|
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, |
|
|
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, |
|
|
lastResetDate: accountData.lastResetDate, |
|
|
quotaResetTime: accountData.quotaResetTime, |
|
|
quotaStoppedAt: accountData.quotaStoppedAt, |
|
|
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, |
|
|
fullUsageStats: usageStats |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get CCR 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 CCR account ${accountId}`) |
|
|
|
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: accountData.name || accountId, |
|
|
platform: 'ccr', |
|
|
status: 'recovered', |
|
|
errorCode: 'STATUS_RESET', |
|
|
reason: 'Account status manually reset', |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} catch (webhookError) { |
|
|
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError) |
|
|
} |
|
|
|
|
|
return { success: true, accountId } |
|
|
} catch (error) { |
|
|
logger.error(`❌ Failed to reset CCR 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 CcrAccountService() |
|
|
|