| const redisClient = require('../models/redis') |
| const { v4: uuidv4 } = require('uuid') |
| const crypto = require('crypto') |
| const axios = require('axios') |
| const ProxyHelper = require('../utils/proxyHelper') |
| const config = require('../../config/config') |
| const logger = require('../utils/logger') |
| |
| const { |
| logRefreshStart, |
| logRefreshSuccess, |
| logRefreshError, |
| logTokenUsage, |
| logRefreshSkipped |
| } = require('../utils/tokenRefreshLogger') |
| const LRUCache = require('../utils/lruCache') |
| const tokenRefreshService = require('./tokenRefreshService') |
|
|
| |
| const ALGORITHM = 'aes-256-cbc' |
| const ENCRYPTION_SALT = 'openai-account-salt' |
| const IV_LENGTH = 16 |
|
|
| |
| |
| let _encryptionKeyCache = null |
|
|
| |
| const decryptCache = new LRUCache(500) |
|
|
| |
| function generateEncryptionKey() { |
| if (!_encryptionKeyCache) { |
| _encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) |
| logger.info('🔑 OpenAI encryption key derived and cached for performance optimization') |
| } |
| return _encryptionKeyCache |
| } |
|
|
| |
| const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:' |
| const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts' |
| const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:' |
|
|
| |
| function encrypt(text) { |
| if (!text) { |
| return '' |
| } |
| const key = generateEncryptionKey() |
| const iv = crypto.randomBytes(IV_LENGTH) |
| const cipher = crypto.createCipheriv(ALGORITHM, key, iv) |
| let encrypted = cipher.update(text) |
| encrypted = Buffer.concat([encrypted, cipher.final()]) |
| return `${iv.toString('hex')}:${encrypted.toString('hex')}` |
| } |
|
|
| |
| function decrypt(text) { |
| if (!text || text === '') { |
| return '' |
| } |
|
|
| |
| if (text.length < 33 || text.charAt(32) !== ':') { |
| logger.warn('Invalid encrypted text format, returning empty string', { |
| textLength: text ? text.length : 0, |
| char32: text && text.length > 32 ? text.charAt(32) : 'N/A', |
| first50: text ? text.substring(0, 50) : 'N/A' |
| }) |
| return '' |
| } |
|
|
| |
| const cacheKey = crypto.createHash('sha256').update(text).digest('hex') |
| const cached = decryptCache.get(cacheKey) |
| if (cached !== undefined) { |
| return cached |
| } |
|
|
| try { |
| const key = generateEncryptionKey() |
| |
| const ivHex = text.substring(0, 32) |
| const encryptedHex = text.substring(33) |
|
|
| const iv = Buffer.from(ivHex, 'hex') |
| const encryptedText = Buffer.from(encryptedHex, 'hex') |
| const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) |
| let decrypted = decipher.update(encryptedText) |
| decrypted = Buffer.concat([decrypted, decipher.final()]) |
| const result = decrypted.toString() |
|
|
| |
| decryptCache.set(cacheKey, result, 5 * 60 * 1000) |
|
|
| |
| if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) { |
| decryptCache.printStats() |
| } |
|
|
| return result |
| } catch (error) { |
| logger.error('Decryption error:', error) |
| return '' |
| } |
| } |
|
|
| |
| setInterval( |
| () => { |
| decryptCache.cleanup() |
| logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats()) |
| }, |
| 10 * 60 * 1000 |
| ) |
|
|
| function toNumberOrNull(value) { |
| if (value === undefined || value === null || value === '') { |
| return null |
| } |
|
|
| const num = Number(value) |
| return Number.isFinite(num) ? num : null |
| } |
|
|
| function computeResetMeta(updatedAt, resetAfterSeconds) { |
| if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) { |
| return { |
| resetAt: null, |
| remainingSeconds: null |
| } |
| } |
|
|
| const updatedMs = Date.parse(updatedAt) |
| if (Number.isNaN(updatedMs)) { |
| return { |
| resetAt: null, |
| remainingSeconds: null |
| } |
| } |
|
|
| const resetMs = updatedMs + resetAfterSeconds * 1000 |
| return { |
| resetAt: new Date(resetMs).toISOString(), |
| remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000)) |
| } |
| } |
|
|
| function buildCodexUsageSnapshot(accountData) { |
| const updatedAt = accountData.codexUsageUpdatedAt |
|
|
| const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent) |
| const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds) |
| const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes) |
| const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent) |
| const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds) |
| const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes) |
| const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent) |
|
|
| const hasPrimaryData = |
| primaryUsedPercent !== null || |
| primaryResetAfterSeconds !== null || |
| primaryWindowMinutes !== null |
| const hasSecondaryData = |
| secondaryUsedPercent !== null || |
| secondaryResetAfterSeconds !== null || |
| secondaryWindowMinutes !== null |
|
|
| if (!updatedAt && !hasPrimaryData && !hasSecondaryData) { |
| return null |
| } |
|
|
| const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds) |
| const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds) |
|
|
| return { |
| updatedAt, |
| primary: { |
| usedPercent: primaryUsedPercent, |
| resetAfterSeconds: primaryResetAfterSeconds, |
| windowMinutes: primaryWindowMinutes, |
| resetAt: primaryMeta.resetAt, |
| remainingSeconds: primaryMeta.remainingSeconds |
| }, |
| secondary: { |
| usedPercent: secondaryUsedPercent, |
| resetAfterSeconds: secondaryResetAfterSeconds, |
| windowMinutes: secondaryWindowMinutes, |
| resetAt: secondaryMeta.resetAt, |
| remainingSeconds: secondaryMeta.remainingSeconds |
| }, |
| primaryOverSecondaryPercent: overSecondaryPercent |
| } |
| } |
|
|
| |
| async function refreshAccessToken(refreshToken, proxy = null) { |
| try { |
| |
| const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' |
|
|
| |
| const requestData = new URLSearchParams({ |
| grant_type: 'refresh_token', |
| client_id: CLIENT_ID, |
| refresh_token: refreshToken, |
| scope: 'openid profile email' |
| }).toString() |
|
|
| |
| const requestOptions = { |
| method: 'POST', |
| url: 'https://auth.openai.com/oauth/token', |
| headers: { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| 'Content-Length': requestData.length |
| }, |
| data: requestData, |
| timeout: config.requestTimeout || 600000 |
| } |
|
|
| |
| const proxyAgent = ProxyHelper.createProxyAgent(proxy) |
| if (proxyAgent) { |
| requestOptions.httpAgent = proxyAgent |
| requestOptions.httpsAgent = proxyAgent |
| requestOptions.proxy = false |
| logger.info( |
| `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` |
| ) |
| } else { |
| logger.debug('🌐 No proxy configured for OpenAI token refresh') |
| } |
|
|
| |
| logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent) |
| const response = await axios(requestOptions) |
|
|
| if (response.status === 200 && response.data) { |
| const result = response.data |
|
|
| logger.info('✅ Successfully refreshed OpenAI token') |
|
|
| |
| return { |
| access_token: result.access_token, |
| id_token: result.id_token, |
| refresh_token: result.refresh_token || refreshToken, |
| expires_in: result.expires_in || 3600, |
| expiry_date: Date.now() + (result.expires_in || 3600) * 1000 |
| } |
| } else { |
| throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) |
| } |
| } catch (error) { |
| if (error.response) { |
| |
| const errorData = error.response.data || {} |
| logger.error('OpenAI token refresh failed:', { |
| status: error.response.status, |
| data: errorData, |
| headers: error.response.headers |
| }) |
|
|
| |
| let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` |
|
|
| if (error.response.status === 400) { |
| if (errorData.error === 'invalid_grant') { |
| errorMessage = 'Refresh Token 无效或已过期,请重新授权' |
| } else if (errorData.error === 'invalid_request') { |
| errorMessage = `请求参数错误:${errorData.error_description || errorData.error}` |
| } else { |
| errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}` |
| } |
| } else if (error.response.status === 401) { |
| errorMessage = '认证失败:Refresh Token 无效' |
| } else if (error.response.status === 403) { |
| errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用' |
| } else if (error.response.status === 429) { |
| errorMessage = '请求过于频繁,请稍后重试' |
| } else if (error.response.status >= 500) { |
| errorMessage = 'OpenAI 服务器内部错误,请稍后重试' |
| } else if (errorData.error_description) { |
| errorMessage = errorData.error_description |
| } else if (errorData.error) { |
| errorMessage = errorData.error |
| } else if (errorData.message) { |
| errorMessage = errorData.message |
| } |
|
|
| const fullError = new Error(errorMessage) |
| fullError.status = error.response.status |
| fullError.details = errorData |
| throw fullError |
| } else if (error.request) { |
| |
| logger.error('OpenAI token refresh no response:', error.message) |
|
|
| let errorMessage = '无法连接到 OpenAI 服务器' |
| if (proxy) { |
| errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})` |
| } |
| if (error.code === 'ECONNREFUSED') { |
| errorMessage += ' - 连接被拒绝' |
| } else if (error.code === 'ETIMEDOUT') { |
| errorMessage += ' - 连接超时' |
| } else if (error.code === 'ENOTFOUND') { |
| errorMessage += ' - 无法解析域名' |
| } else if (error.code === 'EPROTO') { |
| errorMessage += ' - 协议错误(可能是代理配置问题)' |
| } else if (error.message) { |
| errorMessage += ` - ${error.message}` |
| } |
|
|
| const fullError = new Error(errorMessage) |
| fullError.code = error.code |
| throw fullError |
| } else { |
| |
| logger.error('OpenAI token refresh error:', error.message) |
| const fullError = new Error(`请求设置错误: ${error.message}`) |
| fullError.originalError = error |
| throw fullError |
| } |
| } |
| } |
|
|
| |
| function isTokenExpired(account) { |
| if (!account.expiresAt) { |
| return false |
| } |
| return new Date(account.expiresAt) <= new Date() |
| } |
|
|
| |
| |
| |
| |
| |
| function isSubscriptionExpired(account) { |
| if (!account.subscriptionExpiresAt) { |
| return false |
| } |
| const expiryDate = new Date(account.subscriptionExpiresAt) |
| return expiryDate <= new Date() |
| } |
|
|
| |
| async function refreshAccountToken(accountId) { |
| let lockAcquired = false |
| let account = null |
| let accountName = accountId |
|
|
| try { |
| account = await getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| accountName = account.name || accountId |
|
|
| |
| |
| const refreshToken = account.refreshToken || null |
|
|
| if (!refreshToken) { |
| logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') |
| throw new Error('No refresh token available') |
| } |
|
|
| |
| lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai') |
|
|
| if (!lockAcquired) { |
| |
| logger.info( |
| `🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})` |
| ) |
| logRefreshSkipped(accountId, accountName, 'openai', 'already_locked') |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 2000)) |
|
|
| |
| const updatedAccount = await getAccount(accountId) |
| if (updatedAccount && !isTokenExpired(updatedAccount)) { |
| return { |
| access_token: decrypt(updatedAccount.accessToken), |
| id_token: updatedAccount.idToken, |
| refresh_token: updatedAccount.refreshToken, |
| expires_in: 3600, |
| expiry_date: new Date(updatedAccount.expiresAt).getTime() |
| } |
| } |
|
|
| throw new Error('Token refresh in progress by another process') |
| } |
|
|
| |
| logRefreshStart(accountId, accountName, 'openai') |
| logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`) |
|
|
| |
| let proxy = null |
| if (account.proxy) { |
| try { |
| proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy |
| } catch (e) { |
| logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) |
| } |
| } |
|
|
| const newTokens = await refreshAccessToken(refreshToken, proxy) |
| if (!newTokens) { |
| throw new Error('Failed to refresh token') |
| } |
|
|
| |
| const updates = { |
| accessToken: newTokens.access_token, |
| expiresAt: new Date(newTokens.expiry_date).toISOString() |
| } |
|
|
| |
| if (newTokens.id_token) { |
| updates.idToken = newTokens.id_token |
|
|
| |
| if (!account.idToken || account.idToken === '') { |
| try { |
| const idTokenParts = newTokens.id_token.split('.') |
| if (idTokenParts.length === 3) { |
| const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString()) |
| const authClaims = payload['https://api.openai.com/auth'] || {} |
|
|
| |
| |
| if (authClaims.chatgpt_account_id) { |
| updates.accountId = authClaims.chatgpt_account_id |
| } |
| if (authClaims.chatgpt_user_id) { |
| updates.chatgptUserId = authClaims.chatgpt_user_id |
| } else if (authClaims.user_id) { |
| |
| updates.chatgptUserId = authClaims.user_id |
| } |
| if (authClaims.organizations?.[0]?.id) { |
| updates.organizationId = authClaims.organizations[0].id |
| } |
| if (authClaims.organizations?.[0]?.role) { |
| updates.organizationRole = authClaims.organizations[0].role |
| } |
| if (authClaims.organizations?.[0]?.title) { |
| updates.organizationTitle = authClaims.organizations[0].title |
| } |
| if (payload.email) { |
| updates.email = payload.email |
| } |
| if (payload.email_verified !== undefined) { |
| updates.emailVerified = payload.email_verified |
| } |
|
|
| logger.info(`Updated user info from ID Token for account ${accountId}`) |
| } |
| } catch (e) { |
| logger.warn(`Failed to parse ID Token for account ${accountId}:`, e) |
| } |
| } |
| } |
|
|
| |
| if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) { |
| updates.refreshToken = newTokens.refresh_token |
| logger.info(`Updated refresh token for account ${accountId}`) |
| } |
|
|
| |
| await updateAccount(accountId, updates) |
|
|
| logRefreshSuccess(accountId, accountName, 'openai', newTokens) |
| return newTokens |
| } catch (error) { |
| logRefreshError(accountId, account?.name || accountName, 'openai', error.message) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account?.name || accountName, |
| platform: 'openai', |
| status: 'error', |
| errorCode: 'OPENAI_TOKEN_REFRESH_FAILED', |
| reason: `Token refresh failed: ${error.message}`, |
| timestamp: new Date().toISOString() |
| }) |
| logger.info( |
| `📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure` |
| ) |
| } catch (webhookError) { |
| logger.error('Failed to send webhook notification:', webhookError) |
| } |
|
|
| throw error |
| } finally { |
| |
| if (lockAcquired) { |
| await tokenRefreshService.releaseRefreshLock(accountId, 'openai') |
| logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`) |
| } |
| } |
| } |
|
|
| |
| async function createAccount(accountData) { |
| const accountId = uuidv4() |
| const now = new Date().toISOString() |
|
|
| |
| let oauthData = {} |
| if (accountData.openaiOauth) { |
| oauthData = |
| typeof accountData.openaiOauth === 'string' |
| ? JSON.parse(accountData.openaiOauth) |
| : accountData.openaiOauth |
| } |
|
|
| |
| const accountInfo = accountData.accountInfo || {} |
|
|
| |
| const isEmailEncrypted = |
| accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' |
|
|
| const account = { |
| id: accountId, |
| name: accountData.name, |
| description: accountData.description || '', |
| accountType: accountData.accountType || 'shared', |
| groupId: accountData.groupId || null, |
| priority: accountData.priority || 50, |
| rateLimitDuration: |
| accountData.rateLimitDuration !== undefined && accountData.rateLimitDuration !== null |
| ? accountData.rateLimitDuration |
| : 60, |
| |
| |
| idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', |
| accessToken: |
| oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '', |
| refreshToken: |
| oauthData.refreshToken && oauthData.refreshToken.trim() |
| ? encrypt(oauthData.refreshToken) |
| : '', |
| openaiOauth: encrypt(JSON.stringify(oauthData)), |
| |
| accountId: accountInfo.accountId || '', |
| chatgptUserId: accountInfo.chatgptUserId || '', |
| organizationId: accountInfo.organizationId || '', |
| organizationRole: accountInfo.organizationRole || '', |
| organizationTitle: accountInfo.organizationTitle || '', |
| planType: accountInfo.planType || '', |
| |
| email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), |
| emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', |
| |
| expiresAt: oauthData.expires_in |
| ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() |
| : new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), |
|
|
| |
| subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, |
|
|
| |
| isActive: accountData.isActive !== false ? 'true' : 'false', |
| status: 'active', |
| schedulable: accountData.schedulable !== false ? 'true' : 'false', |
| lastRefresh: now, |
| createdAt: now, |
| updatedAt: now |
| } |
|
|
| |
| if (accountData.proxy) { |
| account.proxy = |
| typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) |
| } |
|
|
| const client = redisClient.getClientSafe() |
| await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) |
|
|
| |
| if (account.accountType === 'shared') { |
| await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
| } |
|
|
| logger.info(`Created OpenAI account: ${accountId}`) |
| return account |
| } |
|
|
| |
| async function getAccount(accountId) { |
| const client = redisClient.getClientSafe() |
| const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| if (!accountData || Object.keys(accountData).length === 0) { |
| return null |
| } |
|
|
| |
| if (accountData.idToken) { |
| accountData.idToken = decrypt(accountData.idToken) |
| } |
| |
| |
| |
| |
| if (accountData.refreshToken) { |
| accountData.refreshToken = decrypt(accountData.refreshToken) |
| } |
| if (accountData.email) { |
| accountData.email = decrypt(accountData.email) |
| } |
| if (accountData.openaiOauth) { |
| try { |
| accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth)) |
| } catch (e) { |
| accountData.openaiOauth = null |
| } |
| } |
|
|
| |
| if (accountData.proxy && typeof accountData.proxy === 'string') { |
| try { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } catch (e) { |
| accountData.proxy = null |
| } |
| } |
|
|
| return accountData |
| } |
|
|
| |
| async function updateAccount(accountId, updates) { |
| const existingAccount = await getAccount(accountId) |
| if (!existingAccount) { |
| throw new Error('Account not found') |
| } |
|
|
| updates.updatedAt = new Date().toISOString() |
|
|
| |
| if (updates.openaiOauth) { |
| const oauthData = |
| typeof updates.openaiOauth === 'string' |
| ? updates.openaiOauth |
| : JSON.stringify(updates.openaiOauth) |
| updates.openaiOauth = encrypt(oauthData) |
| } |
| if (updates.idToken) { |
| updates.idToken = encrypt(updates.idToken) |
| } |
| if (updates.accessToken) { |
| updates.accessToken = encrypt(updates.accessToken) |
| } |
| if (updates.refreshToken && updates.refreshToken.trim()) { |
| updates.refreshToken = encrypt(updates.refreshToken) |
| } |
| if (updates.email) { |
| updates.email = encrypt(updates.email) |
| } |
|
|
| |
| if (updates.proxy) { |
| updates.proxy = |
| typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) |
| } |
|
|
| |
| |
| if (updates.subscriptionExpiresAt !== undefined) { |
| |
| } |
|
|
| |
| const client = redisClient.getClientSafe() |
| if (updates.accountType && updates.accountType !== existingAccount.accountType) { |
| if (updates.accountType === 'shared') { |
| await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
| } else { |
| await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
| } |
| } |
|
|
| await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
| logger.info(`Updated OpenAI account: ${accountId}`) |
|
|
| |
| const updatedAccount = { ...existingAccount, ...updates } |
|
|
| |
| if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { |
| try { |
| updatedAccount.proxy = JSON.parse(updatedAccount.proxy) |
| } catch (e) { |
| updatedAccount.proxy = null |
| } |
| } |
|
|
| return updatedAccount |
| } |
|
|
| |
| async function deleteAccount(accountId) { |
| const account = await getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| |
| const client = redisClient.getClientSafe() |
| await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| |
| if (account.accountType === 'shared') { |
| await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
| } |
|
|
| |
| const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) |
| for (const key of sessionMappings) { |
| const mappedAccountId = await client.get(key) |
| if (mappedAccountId === accountId) { |
| await client.del(key) |
| } |
| } |
|
|
| logger.info(`Deleted OpenAI account: ${accountId}`) |
| return true |
| } |
|
|
| |
| async function getAllAccounts() { |
| const client = redisClient.getClientSafe() |
| const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`) |
| const accounts = [] |
|
|
| for (const key of keys) { |
| const accountData = await client.hgetall(key) |
| if (accountData && Object.keys(accountData).length > 0) { |
| const codexUsage = buildCodexUsageSnapshot(accountData) |
|
|
| |
| if (accountData.email) { |
| accountData.email = decrypt(accountData.email) |
| } |
|
|
| |
| const hasRefreshTokenFlag = !!accountData.refreshToken |
| const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : '' |
| const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : '' |
| const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : '' |
|
|
| |
| delete accountData.idToken |
| delete accountData.accessToken |
| delete accountData.refreshToken |
| delete accountData.openaiOauth |
| delete accountData.codexPrimaryUsedPercent |
| delete accountData.codexPrimaryResetAfterSeconds |
| delete accountData.codexPrimaryWindowMinutes |
| delete accountData.codexSecondaryUsedPercent |
| delete accountData.codexSecondaryResetAfterSeconds |
| delete accountData.codexSecondaryWindowMinutes |
| delete accountData.codexPrimaryOverSecondaryLimitPercent |
| |
| delete accountData.codexUsageUpdatedAt |
|
|
| |
| const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) |
|
|
| |
| if (accountData.proxy) { |
| try { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } catch (e) { |
| |
| accountData.proxy = null |
| } |
| } |
|
|
| const tokenExpiresAt = accountData.expiresAt || null |
| const subscriptionExpiresAt = |
| accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' |
| ? accountData.subscriptionExpiresAt |
| : null |
|
|
| |
| accounts.push({ |
| ...accountData, |
| isActive: accountData.isActive === 'true', |
| schedulable: accountData.schedulable !== 'false', |
| openaiOauth: maskedOauth, |
| accessToken: maskedAccessToken, |
| refreshToken: maskedRefreshToken, |
|
|
| |
| tokenExpiresAt, |
| subscriptionExpiresAt, |
| expiresAt: subscriptionExpiresAt, |
|
|
| |
| |
| scopes: |
| accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], |
| |
| hasRefreshToken: hasRefreshTokenFlag, |
| |
| rateLimitStatus: rateLimitInfo |
| ? { |
| status: rateLimitInfo.status, |
| isRateLimited: rateLimitInfo.isRateLimited, |
| rateLimitedAt: rateLimitInfo.rateLimitedAt, |
| rateLimitResetAt: rateLimitInfo.rateLimitResetAt, |
| minutesRemaining: rateLimitInfo.minutesRemaining |
| } |
| : { |
| status: 'normal', |
| isRateLimited: false, |
| rateLimitedAt: null, |
| rateLimitResetAt: null, |
| minutesRemaining: 0 |
| }, |
| codexUsage |
| }) |
| } |
| } |
|
|
| return accounts |
| } |
|
|
| |
| async function getAccountOverview(accountId) { |
| const client = redisClient.getClientSafe() |
| const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
| if (!accountData || Object.keys(accountData).length === 0) { |
| return null |
| } |
|
|
| const codexUsage = buildCodexUsageSnapshot(accountData) |
| const rateLimitInfo = await getAccountRateLimitInfo(accountId) |
|
|
| if (accountData.proxy) { |
| try { |
| accountData.proxy = JSON.parse(accountData.proxy) |
| } catch (error) { |
| accountData.proxy = null |
| } |
| } |
|
|
| const scopes = |
| accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [] |
|
|
| return { |
| id: accountData.id, |
| accountType: accountData.accountType || 'shared', |
| platform: accountData.platform || 'openai', |
| isActive: accountData.isActive === 'true', |
| schedulable: accountData.schedulable !== 'false', |
| rateLimitStatus: rateLimitInfo || { |
| status: 'normal', |
| isRateLimited: false, |
| rateLimitedAt: null, |
| rateLimitResetAt: null, |
| minutesRemaining: 0 |
| }, |
| codexUsage, |
| scopes |
| } |
| } |
|
|
| |
| async function selectAvailableAccount(apiKeyId, sessionHash = null) { |
| |
| const client = redisClient.getClientSafe() |
| if (sessionHash) { |
| const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) |
|
|
| if (mappedAccountId) { |
| const account = await getAccount(mappedAccountId) |
| if (account && account.isActive === 'true' && !isTokenExpired(account)) { |
| logger.debug(`Using sticky session account: ${mappedAccountId}`) |
| return account |
| } |
| } |
| } |
|
|
| |
| const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) |
|
|
| |
| if (apiKeyData.openaiAccountId) { |
| const account = await getAccount(apiKeyData.openaiAccountId) |
| if (account && account.isActive === 'true') { |
| |
| const isExpired = isTokenExpired(account) |
|
|
| |
| logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired) |
|
|
| if (isExpired) { |
| await refreshAccountToken(account.id) |
| return await getAccount(account.id) |
| } |
|
|
| |
| if (sessionHash) { |
| await client.setex( |
| `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, |
| 3600, |
| account.id |
| ) |
| } |
|
|
| return account |
| } |
| } |
|
|
| |
| const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY) |
| const availableAccounts = [] |
|
|
| for (const accountId of sharedAccountIds) { |
| const account = await getAccount(accountId) |
| if ( |
| account && |
| account.isActive === 'true' && |
| !isRateLimited(account) && |
| !isSubscriptionExpired(account) |
| ) { |
| availableAccounts.push(account) |
| } else if (account && isSubscriptionExpired(account)) { |
| logger.debug( |
| `⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}` |
| ) |
| } |
| } |
|
|
| if (availableAccounts.length === 0) { |
| throw new Error('No available OpenAI accounts') |
| } |
|
|
| |
| const selectedAccount = availableAccounts.reduce((prev, curr) => { |
| const prevUsage = parseInt(prev.totalUsage || 0) |
| const currUsage = parseInt(curr.totalUsage || 0) |
| return prevUsage <= currUsage ? prev : curr |
| }) |
|
|
| |
| if (isTokenExpired(selectedAccount)) { |
| await refreshAccountToken(selectedAccount.id) |
| return await getAccount(selectedAccount.id) |
| } |
|
|
| |
| if (sessionHash) { |
| await client.setex( |
| `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, |
| 3600, |
| selectedAccount.id |
| ) |
| } |
|
|
| return selectedAccount |
| } |
|
|
| |
| function isRateLimited(account) { |
| if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { |
| const limitedAt = new Date(account.rateLimitedAt).getTime() |
| const now = Date.now() |
| const limitDuration = 60 * 60 * 1000 |
|
|
| return now < limitedAt + limitDuration |
| } |
| return false |
| } |
|
|
| |
| async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { |
| const updates = { |
| rateLimitStatus: isLimited ? 'limited' : 'normal', |
| rateLimitedAt: isLimited ? new Date().toISOString() : null, |
| |
| schedulable: isLimited ? 'false' : 'true' |
| } |
|
|
| |
| if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) { |
| const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString() |
| updates.rateLimitResetAt = resetTime |
| logger.info( |
| `🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)` |
| ) |
| } else if (isLimited) { |
| |
| const defaultResetSeconds = 60 * 60 |
| const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString() |
| updates.rateLimitResetAt = resetTime |
| logger.warn( |
| `⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}` |
| ) |
| } else if (!isLimited) { |
| updates.rateLimitResetAt = null |
| } |
|
|
| await updateAccount(accountId, updates) |
| logger.info( |
| `Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}` |
| ) |
|
|
| |
| if (isLimited) { |
| try { |
| const account = await getAccount(accountId) |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || accountId, |
| platform: 'openai', |
| status: 'blocked', |
| errorCode: 'OPENAI_RATE_LIMITED', |
| reason: resetsInSeconds |
| ? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes` |
| : 'Account rate limited (429 error). Estimated reset in 1 hour', |
| timestamp: new Date().toISOString() |
| }) |
| logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`) |
| } catch (webhookError) { |
| logger.error('Failed to send rate limit webhook notification:', webhookError) |
| } |
| } |
| } |
|
|
| |
| async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') { |
| const account = await getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const now = new Date().toISOString() |
| const currentCount = parseInt(account.unauthorizedCount || '0', 10) |
| const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 |
|
|
| const updates = { |
| status: 'unauthorized', |
| schedulable: 'false', |
| errorMessage: reason, |
| unauthorizedAt: now, |
| unauthorizedCount: unauthorizedCount.toString() |
| } |
|
|
| await updateAccount(accountId, updates) |
| logger.warn( |
| `🚫 Marked OpenAI account ${account.name || accountId} 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 account ${account.name} unauthorized state` |
| ) |
| } catch (webhookError) { |
| logger.error('Failed to send unauthorized webhook notification:', webhookError) |
| } |
| } |
|
|
| |
| async function resetAccountStatus(accountId) { |
| const account = await getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| const updates = { |
| |
| status: account.accessToken ? 'active' : 'created', |
| |
| schedulable: 'true', |
| |
| errorMessage: null, |
| rateLimitedAt: null, |
| rateLimitStatus: 'normal', |
| rateLimitResetAt: null |
| } |
|
|
| await updateAccount(accountId, updates) |
| logger.info(`✅ Reset all error status for OpenAI account ${accountId}`) |
|
|
| |
| try { |
| const webhookNotifier = require('../utils/webhookNotifier') |
| await webhookNotifier.sendAccountAnomalyNotification({ |
| accountId, |
| accountName: account.name || accountId, |
| platform: 'openai', |
| status: 'recovered', |
| errorCode: 'STATUS_RESET', |
| reason: 'Account status manually reset', |
| timestamp: new Date().toISOString() |
| }) |
| logger.info(`📢 Webhook notification sent for OpenAI 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' } |
| } |
|
|
| |
| async function toggleSchedulable(accountId) { |
| const account = await getAccount(accountId) |
| if (!account) { |
| throw new Error('Account not found') |
| } |
|
|
| |
| const newSchedulable = account.schedulable === 'false' ? 'true' : 'false' |
|
|
| await updateAccount(accountId, { |
| schedulable: newSchedulable |
| }) |
|
|
| logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`) |
|
|
| return { |
| success: true, |
| schedulable: newSchedulable === 'true' |
| } |
| } |
|
|
| |
| async function getAccountRateLimitInfo(accountId) { |
| const account = await getAccount(accountId) |
| if (!account) { |
| return null |
| } |
|
|
| const status = account.rateLimitStatus || 'normal' |
| const rateLimitedAt = account.rateLimitedAt || null |
| const rateLimitResetAt = account.rateLimitResetAt || null |
|
|
| if (status === 'limited') { |
| const now = Date.now() |
| let remainingTime = 0 |
|
|
| if (rateLimitResetAt) { |
| const resetAt = new Date(rateLimitResetAt).getTime() |
| remainingTime = Math.max(0, resetAt - now) |
| } else if (rateLimitedAt) { |
| const limitedAt = new Date(rateLimitedAt).getTime() |
| const limitDuration = 60 * 60 * 1000 |
| remainingTime = Math.max(0, limitedAt + limitDuration - now) |
| } |
|
|
| const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0 |
|
|
| return { |
| status, |
| isRateLimited: minutesRemaining > 0, |
| rateLimitedAt, |
| rateLimitResetAt, |
| minutesRemaining |
| } |
| } |
|
|
| return { |
| status, |
| isRateLimited: false, |
| rateLimitedAt, |
| rateLimitResetAt, |
| minutesRemaining: 0 |
| } |
| } |
|
|
| |
| async function updateAccountUsage(accountId, tokens = 0) { |
| const account = await getAccount(accountId) |
| if (!account) { |
| return |
| } |
|
|
| const updates = { |
| lastUsedAt: new Date().toISOString() |
| } |
|
|
| |
| if (tokens > 0) { |
| const totalUsage = parseInt(account.totalUsage || 0) + tokens |
| updates.totalUsage = totalUsage.toString() |
| } |
|
|
| await updateAccount(accountId, updates) |
| } |
|
|
| |
| const recordUsage = updateAccountUsage |
|
|
| async function updateCodexUsageSnapshot(accountId, usageSnapshot) { |
| if (!usageSnapshot || typeof usageSnapshot !== 'object') { |
| return |
| } |
|
|
| const fieldMap = { |
| primaryUsedPercent: 'codexPrimaryUsedPercent', |
| primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds', |
| primaryWindowMinutes: 'codexPrimaryWindowMinutes', |
| secondaryUsedPercent: 'codexSecondaryUsedPercent', |
| secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds', |
| secondaryWindowMinutes: 'codexSecondaryWindowMinutes', |
| primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent' |
| } |
|
|
| const updates = {} |
| let hasPayload = false |
|
|
| for (const [key, field] of Object.entries(fieldMap)) { |
| if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) { |
| updates[field] = String(usageSnapshot[key]) |
| hasPayload = true |
| } |
| } |
|
|
| if (!hasPayload) { |
| return |
| } |
|
|
| updates.codexUsageUpdatedAt = new Date().toISOString() |
|
|
| const client = redisClient.getClientSafe() |
| await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
| } |
|
|
| module.exports = { |
| createAccount, |
| getAccount, |
| getAccountOverview, |
| updateAccount, |
| deleteAccount, |
| getAllAccounts, |
| selectAvailableAccount, |
| refreshAccountToken, |
| isTokenExpired, |
| setAccountRateLimited, |
| markAccountUnauthorized, |
| resetAccountStatus, |
| toggleSchedulable, |
| getAccountRateLimitInfo, |
| updateAccountUsage, |
| recordUsage, |
| updateCodexUsageSnapshot, |
| encrypt, |
| decrypt, |
| generateEncryptionKey, |
| decryptCache |
| } |
|
|