| const { v4: uuidv4 } = require('uuid') |
| const crypto = require('crypto') |
| const redis = require('../models/redis') |
| const logger = require('../utils/logger') |
| const config = require('../../config/config') |
| const LRUCache = require('../utils/lruCache') |
|
|
| class OpenAIResponsesAccountService { |
| constructor() { |
| |
| this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' |
| this.ENCRYPTION_SALT = 'openai-responses-salt' |
|
|
| |
| this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:' |
| this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts' |
|
|
| |
| this._encryptionKeyCache = null |
|
|
| |
| this._decryptCache = new LRUCache(500) |
|
|
| |
| setInterval( |
| () => { |
| this._decryptCache.cleanup() |
| logger.info( |
| '🧹 OpenAI-Responses decrypt cache cleanup completed', |
| this._decryptCache.getStats() |
| ) |
| }, |
| 10 * 60 * 1000 |
| ) |
| } |
|
|
| |
| async createAccount(options = {}) { |
| const { |
| name = 'OpenAI Responses Account', |
| description = '', |
| baseApi = '', |
| apiKey = '', |
| userAgent = '', |
| priority = 50, |
| proxy = null, |
| isActive = true, |
| accountType = 'shared', |
| schedulable = true, |
| dailyQuota = 0, |
| quotaResetTime = '00:00', |
| rateLimitDuration = 60 |
| } = options |
|
|
| |
| if (!baseApi || !apiKey) { |
| throw new Error('Base API URL and API Key are required for OpenAI-Responses account') |
| } |
|
|
| |
| const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi |
|
|
| const accountId = uuidv4() |
|
|
| const accountData = { |
| id: accountId, |
| platform: 'openai-responses', |
| name, |
| description, |
| baseApi: normalizedBaseApi, |
| apiKey: this._encryptSensitiveData(apiKey), |
| userAgent, |
| priority: priority.toString(), |
| proxy: proxy ? JSON.stringify(proxy) : '', |
| isActive: isActive.toString(), |
| accountType, |
| schedulable: schedulable.toString(), |
|
|
| |
| |
| subscriptionExpiresAt: options.subscriptionExpiresAt || null, |
|
|
| createdAt: new Date().toISOString(), |
| lastUsedAt: '', |
| status: 'active', |
| errorMessage: '', |
| |
| rateLimitedAt: '', |
| rateLimitStatus: '', |
| rateLimitDuration: rateLimitDuration.toString(), |
| |
| dailyQuota: dailyQuota.toString(), |
| dailyUsage: '0', |
| lastResetDate: redis.getDateStringInTimezone(), |
| quotaResetTime, |
| quotaStoppedAt: '' |
| } |
|
|
| |
| await this._saveAccount(accountId, accountData) |
|
|
| logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`) |
|
|
| return { |
| ...accountData, |
| apiKey: '***' |
| } |
| } |
|
|
| |
| async getAccount(accountId) { |
| const client = redis.getClientSafe() |
| const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
| const accountData = await client.hgetall(key) |
|
|
| if (!accountData || !accountData.id) { |
| return null |
| } |
|
|
| |
| accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) |
|
|
| |
| if (accountData.proxy) { |
| try { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } catch (e) { |
| accountData.proxy = null |
| } |
| } |
|
|
| return accountData |
| } |
|
|
| |
| async updateAccount(accountId, updates) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| |
| if (updates.apiKey) { |
| updates.apiKey = this._encryptSensitiveData(updates.apiKey) |
| } |
|
|
| |
| if (updates.proxy !== undefined) { |
| updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' |
| } |
|
|
| |
| if (updates.baseApi) { |
| updates.baseApi = updates.baseApi.endsWith('/') |
| ? updates.baseApi.slice(0, -1) |
| : updates.baseApi |
| } |
|
|
| |
| |
| if (updates.subscriptionExpiresAt !== undefined) { |
| |
| } |
|
|
| |
| const client = redis.getClientSafe() |
| const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
| await client.hset(key, updates) |
|
|
| logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`) |
|
|
| return { success: true } |
| } |
|
|
| |
| async deleteAccount(accountId) { |
| const client = redis.getClientSafe() |
| const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) |
|
|
| |
| await client.del(key) |
|
|
| logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`) |
|
|
| return { success: true } |
| } |
|
|
| |
| async getAllAccounts(includeInactive = false) { |
| const client = redis.getClientSafe() |
| const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY) |
| const accounts = [] |
|
|
| for (const accountId of accountIds) { |
| const account = await this.getAccount(accountId) |
| if (account) { |
| |
| if (includeInactive || account.isActive === 'true') { |
| |
| account.apiKey = '***' |
|
|
| |
| const rateLimitInfo = this._getRateLimitInfo(account) |
|
|
| |
| account.rateLimitStatus = rateLimitInfo.isRateLimited |
| ? { |
| isRateLimited: true, |
| rateLimitedAt: account.rateLimitedAt || null, |
| minutesRemaining: rateLimitInfo.remainingMinutes || 0 |
| } |
| : { |
| isRateLimited: false, |
| rateLimitedAt: null, |
| minutesRemaining: 0 |
| } |
|
|
| |
| account.schedulable = account.schedulable !== 'false' |
| |
| account.isActive = account.isActive === 'true' |
|
|
| |
| account.expiresAt = account.subscriptionExpiresAt || null |
| account.platform = account.platform || 'openai-responses' |
|
|
| accounts.push(account) |
| } |
| } |
| } |
|
|
| |
| const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) |
| for (const key of keys) { |
| const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '') |
| if (!accountIds.includes(accountId)) { |
| const accountData = await client.hgetall(key) |
| if (accountData && accountData.id) { |
| |
| if (includeInactive || accountData.isActive === 'true') { |
| |
| accountData.apiKey = '***' |
| |
| if (accountData.proxy) { |
| try { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } catch (e) { |
| accountData.proxy = null |
| } |
| } |
|
|
| |
| const rateLimitInfo = this._getRateLimitInfo(accountData) |
|
|
| |
| accountData.rateLimitStatus = rateLimitInfo.isRateLimited |
| ? { |
| isRateLimited: true, |
| rateLimitedAt: accountData.rateLimitedAt || null, |
| minutesRemaining: rateLimitInfo.remainingMinutes || 0 |
| } |
| : { |
| isRateLimited: false, |
| rateLimitedAt: null, |
| minutesRemaining: 0 |
| } |
|
|
| |
| accountData.schedulable = accountData.schedulable !== 'false' |
| |
| accountData.isActive = accountData.isActive === 'true' |
|
|
| |
| accountData.expiresAt = accountData.subscriptionExpiresAt || null |
| accountData.platform = accountData.platform || 'openai-responses' |
|
|
| accounts.push(accountData) |
| } |
| } |
| } |
| } |
|
|
| return accounts |
| } |
|
|
| |
| async markAccountRateLimited(accountId, duration = null) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return |
| } |
|
|
| const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 |
| const now = new Date() |
| const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) |
|
|
| await this.updateAccount(accountId, { |
| rateLimitedAt: now.toISOString(), |
| rateLimitStatus: 'limited', |
| rateLimitResetAt: resetAt.toISOString(), |
| rateLimitDuration: rateLimitDuration.toString(), |
| status: 'rateLimited', |
| schedulable: 'false', |
| errorMessage: `Rate limited until ${resetAt.toISOString()}` |
| }) |
|
|
| logger.warn( |
| `⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` |
| ) |
| } |
|
|
| |
| async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return |
| } |
|
|
| const now = new Date().toISOString() |
| const currentCount = parseInt(account.unauthorizedCount || '0', 10) |
| const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 |
|
|
| await this.updateAccount(accountId, { |
| status: 'unauthorized', |
| schedulable: 'false', |
| errorMessage: reason, |
| unauthorizedAt: now, |
| unauthorizedCount: unauthorizedCount.toString() |
| }) |
|
|
| logger.warn( |
| `🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error` |
| ) |
|
|
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || accountId, |
| platform: 'openai', |
| status: 'unauthorized', |
| errorCode: 'OPENAI_UNAUTHORIZED', |
| reason, |
| timestamp: now |
| }) |
| logger.info( |
| `📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state` |
| ) |
| } catch (webhookError) { |
| logger.error('Failed to send unauthorized webhook notification:', webhookError) |
| } |
| } |
|
|
| |
| async checkAndClearRateLimit(accountId) { |
| const account = await this.getAccount(accountId) |
| if (!account || account.rateLimitStatus !== 'limited') { |
| return false |
| } |
|
|
| const now = new Date() |
| let shouldClear = false |
|
|
| |
| if (account.rateLimitResetAt) { |
| const resetAt = new Date(account.rateLimitResetAt) |
| shouldClear = now >= resetAt |
| } else { |
| |
| const rateLimitedAt = new Date(account.rateLimitedAt) |
| const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 |
| shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 |
| } |
|
|
| if (shouldClear) { |
| |
| await this.updateAccount(accountId, { |
| rateLimitedAt: '', |
| rateLimitStatus: '', |
| rateLimitResetAt: '', |
| status: 'active', |
| schedulable: 'true', |
| errorMessage: '' |
| }) |
|
|
| logger.info(`✅ Rate limit cleared for account ${account.name}`) |
| return true |
| } |
|
|
| return false |
| } |
|
|
| |
| async toggleSchedulable(accountId) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' |
| await this.updateAccount(accountId, { |
| schedulable: newSchedulableStatus |
| }) |
|
|
| logger.info( |
| `🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}` |
| ) |
|
|
| return { |
| success: true, |
| schedulable: newSchedulableStatus === 'true' |
| } |
| } |
|
|
| |
| async updateUsageQuota(accountId, amount) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return |
| } |
|
|
| |
| const today = redis.getDateStringInTimezone() |
| if (account.lastResetDate !== today) { |
| |
| await this.updateAccount(accountId, { |
| dailyUsage: amount.toString(), |
| lastResetDate: today, |
| quotaStoppedAt: '' |
| }) |
| } else { |
| |
| const currentUsage = parseFloat(account.dailyUsage) || 0 |
| const newUsage = currentUsage + amount |
| const dailyQuota = parseFloat(account.dailyQuota) || 0 |
|
|
| const updates = { |
| dailyUsage: newUsage.toString() |
| } |
|
|
| |
| if (dailyQuota > 0 && newUsage >= dailyQuota) { |
| updates.status = 'quotaExceeded' |
| updates.quotaStoppedAt = new Date().toISOString() |
| updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` |
| logger.warn(`💸 Account ${account.name} exceeded daily quota`) |
| } |
|
|
| await this.updateAccount(accountId, updates) |
| } |
| } |
|
|
| |
| async updateAccountUsage(accountId, tokens = 0) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| return |
| } |
|
|
| const updates = { |
| lastUsedAt: new Date().toISOString() |
| } |
|
|
| |
| if (tokens > 0) { |
| const currentTokens = parseInt(account.totalUsedTokens) || 0 |
| updates.totalUsedTokens = (currentTokens + tokens).toString() |
| } |
|
|
| await this.updateAccount(accountId, updates) |
| } |
|
|
| |
| async recordUsage(accountId, tokens = 0) { |
| return this.updateAccountUsage(accountId, tokens) |
| } |
|
|
| |
| async resetAccountStatus(accountId) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const updates = { |
| |
| status: account.apiKey ? 'active' : 'created', |
| |
| schedulable: 'true', |
| |
| errorMessage: '', |
| rateLimitedAt: '', |
| rateLimitStatus: '', |
| rateLimitResetAt: '', |
| rateLimitDuration: '' |
| } |
|
|
| await this.updateAccount(accountId, updates) |
| logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || accountId, |
| platform: 'openai-responses', |
| status: 'recovered', |
| errorCode: 'STATUS_RESET', |
| reason: 'Account status manually reset', |
| timestamp: new Date().toISOString() |
| }) |
| logger.info( |
| `📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset` |
| ) |
| } catch (webhookError) { |
| logger.error('Failed to send status reset webhook notification:', webhookError) |
| } |
|
|
| return { success: true, message: 'Account status reset successfully' } |
| } |
|
|
| |
| isSubscriptionExpired(account) { |
| if (!account.subscriptionExpiresAt) { |
| return false |
| } |
|
|
| const expiryDate = new Date(account.subscriptionExpiresAt) |
| const now = new Date() |
|
|
| if (expiryDate <= now) { |
| logger.debug( |
| `⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}` |
| ) |
| return true |
| } |
|
|
| return false |
| } |
|
|
| |
| _getRateLimitInfo(accountData) { |
| if (accountData.rateLimitStatus !== 'limited') { |
| return { isRateLimited: false } |
| } |
|
|
| const now = new Date() |
| let willBeAvailableAt |
| let remainingMinutes |
|
|
| |
| if (accountData.rateLimitResetAt) { |
| willBeAvailableAt = new Date(accountData.rateLimitResetAt) |
| remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) |
| } else { |
| |
| const rateLimitedAt = new Date(accountData.rateLimitedAt) |
| const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 |
| const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) |
| remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) |
| willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) |
| } |
|
|
| return { |
| isRateLimited: remainingMinutes > 0, |
| remainingMinutes, |
| willBeAvailableAt |
| } |
| } |
|
|
| |
| _encryptSensitiveData(text) { |
| if (!text) { |
| return '' |
| } |
|
|
| const key = this._getEncryptionKey() |
| const iv = crypto.randomBytes(16) |
| const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
|
|
| let encrypted = cipher.update(text) |
| encrypted = Buffer.concat([encrypted, cipher.final()]) |
|
|
| return `${iv.toString('hex')}:${encrypted.toString('hex')}` |
| } |
|
|
| |
| _decryptSensitiveData(text) { |
| if (!text || text === '') { |
| return '' |
| } |
|
|
| |
| const cacheKey = crypto.createHash('sha256').update(text).digest('hex') |
| const cached = this._decryptCache.get(cacheKey) |
| if (cached !== undefined) { |
| return cached |
| } |
|
|
| try { |
| const key = this._getEncryptionKey() |
| const [ivHex, encryptedHex] = text.split(':') |
|
|
| const iv = Buffer.from(ivHex, 'hex') |
| const encryptedText = Buffer.from(encryptedHex, 'hex') |
|
|
| const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
| let decrypted = decipher.update(encryptedText) |
| decrypted = Buffer.concat([decrypted, decipher.final()]) |
|
|
| const result = decrypted.toString() |
|
|
| |
| this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) |
|
|
| return result |
| } catch (error) { |
| logger.error('Decryption error:', error) |
| return '' |
| } |
| } |
|
|
| |
| _getEncryptionKey() { |
| if (!this._encryptionKeyCache) { |
| this._encryptionKeyCache = crypto.scryptSync( |
| config.security.encryptionKey, |
| this.ENCRYPTION_SALT, |
| 32 |
| ) |
| } |
| return this._encryptionKeyCache |
| } |
|
|
| |
| async _saveAccount(accountId, accountData) { |
| const client = redis.getClientSafe() |
| const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
| |
| await client.hset(key, accountData) |
|
|
| |
| if (accountData.accountType === 'shared') { |
| await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) |
| } |
| } |
| } |
|
|
| module.exports = new OpenAIResponsesAccountService() |
|
|