diff --git a/.env.example b/.env.example index c70db5fd..c5e7e2e6 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,17 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20 # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) CLAUDE_OVERLOAD_HANDLING_MINUTES=0 +# 400错误处理:0表示禁用,>0表示临时禁用时间(分钟) +# 只有匹配特定错误模式的 400 才会触发临时禁用 +# - organization has been disabled +# - account has been disabled +# - account is disabled +# - no account supporting +# - account not found +# - invalid account +# - Too many active sessions +CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES=10 + # 🌐 代理配置 DEFAULT_PROXY_TIMEOUT=600000 MAX_PROXY_RETRIES=3 diff --git a/src/routes/api.js b/src/routes/api.js index 2d365440..bf795216 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -11,6 +11,7 @@ const logger = require('../utils/logger') const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { sanitizeUpstreamError } = require('../utils/errorSanitizer') const router = express.Router() function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { @@ -947,7 +948,13 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => // 尝试解析并返回JSON响应 try { const jsonData = JSON.parse(response.body) - res.json(jsonData) + // 对于非 2xx 响应,清理供应商特定信息 + if (response.statusCode < 200 || response.statusCode >= 300) { + const sanitizedData = sanitizeUpstreamError(jsonData) + res.json(sanitizedData) + } else { + res.json(jsonData) + } } catch (parseError) { res.send(response.body) } diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index ad773c9b..85144554 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -36,6 +36,20 @@ class ClaudeConsoleAccountService { ) } + _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 + } + // 🏢 创建Claude Console账户 async createAccount(options = {}) { const { @@ -690,6 +704,183 @@ class ClaudeConsoleAccountService { } } + // 🚫 标记账号为临时封禁状态(400错误 - 账户临时禁用) + 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', // 禁用账户(与429保持一致) + schedulable: 'false', // 停止调度(与429保持一致) + status: 'account_blocked', // 设置状态(与429保持一致) + errorMessage: '账户临时被禁用(400错误)', + // 使用独立的封禁自动停止标记 + blockedAutoStopped: 'true' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知,包含完整错误详情 + 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) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + 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 + } + } + // 🚫 标记账号为过载状态(529错误) async markAccountOverloaded(accountId) { try { diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index d241a283..f1d3da96 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -2,6 +2,11 @@ const axios = require('axios') const claudeConsoleAccountService = require('./claudeConsoleAccountService') const logger = require('../utils/logger') const config = require('../../config/config') +const { + sanitizeUpstreamError, + sanitizeErrorMessage, + isAccountDisabledError +} = require('../utils/errorSanitizer') class ClaudeConsoleRelayService { constructor() { @@ -172,14 +177,49 @@ class ClaudeConsoleRelayService { logger.debug( `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` ) - logger.debug( - `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` - ) + + // 对于错误响应,记录原始错误和清理后的预览 + if (response.status < 200 || response.status >= 300) { + // 记录原始错误响应(包含供应商信息,用于调试) + const rawData = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.error( + `📝 Upstream error response from ${account?.name || accountId}: ${rawData.substring(0, 500)}` + ) + + // 记录清理后的数据到error + try { + const responseData = + typeof response.data === 'string' ? JSON.parse(response.data) : response.data + const sanitizedData = sanitizeUpstreamError(responseData) + logger.error(`🧹 [SANITIZED] Error response to client: ${JSON.stringify(sanitizedData)}`) + } catch (e) { + const rawText = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + const sanitizedText = sanitizeErrorMessage(rawText) + logger.error(`🧹 [SANITIZED] Error response to client: ${sanitizedText}`) + } + } else { + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) + } + + // 检查是否为账户禁用/不可用的 400 错误 + const accountDisabledError = isAccountDisabledError(response.status, response.data) // 检查错误状态并相应处理 if (response.status === 401) { logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`) await claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (accountDisabledError) { + logger.error( + `🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked` + ) + // 传入完整的错误详情到 webhook + const errorDetails = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails) } else if (response.status === 429) { logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) // 收到429先检查是否因为超过了手动配置的每日额度 @@ -206,9 +246,30 @@ class ClaudeConsoleRelayService { // 更新最后使用时间 await this._updateLastUsedTime(accountId) - const responseBody = - typeof response.data === 'string' ? response.data : JSON.stringify(response.data) - logger.debug(`[DEBUG] Final response body to return: ${responseBody}`) + // 准备响应体并清理错误信息(如果是错误响应) + let responseBody + if (response.status < 200 || response.status >= 300) { + // 错误响应,清理供应商信息 + try { + const responseData = + typeof response.data === 'string' ? JSON.parse(response.data) : response.data + const sanitizedData = sanitizeUpstreamError(responseData) + responseBody = JSON.stringify(sanitizedData) + logger.debug(`🧹 Sanitized error response`) + } catch (parseError) { + // 如果无法解析为JSON,尝试清理文本 + const rawText = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + responseBody = sanitizeErrorMessage(rawText) + logger.debug(`🧹 Sanitized error text`) + } + } else { + // 成功响应,不需要清理 + responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + } + + logger.debug(`[DEBUG] Final response body to return: ${responseBody.substring(0, 200)}...`) return { statusCode: response.status, @@ -388,44 +449,83 @@ class ClaudeConsoleRelayService { `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}` ) - if (response.status === 401) { - claudeConsoleAccountService.markAccountUnauthorized(accountId) - } else if (response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId) - // 检查是否因为超过每日额度 - claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { - logger.error('❌ Failed to check quota after 429 error:', err) - }) - } else if (response.status === 529) { - claudeConsoleAccountService.markAccountOverloaded(accountId) - } + // 收集错误数据用于检测 + let errorDataForCheck = '' + const errorChunks = [] - // 设置错误响应的状态码和响应头 - if (!responseStream.headersSent) { - const errorHeaders = { - 'Content-Type': response.headers['content-type'] || 'application/json', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } - // 避免 Transfer-Encoding 冲突,让 Express 自动处理 - delete errorHeaders['Transfer-Encoding'] - delete errorHeaders['Content-Length'] - responseStream.writeHead(response.status, errorHeaders) - } - - // 直接透传错误数据,不进行包装 response.data.on('data', (chunk) => { - if (!responseStream.destroyed) { - responseStream.write(chunk) - } + errorChunks.push(chunk) + errorDataForCheck += chunk.toString() }) - response.data.on('end', () => { - if (!responseStream.destroyed) { - responseStream.end() + response.data.on('end', async () => { + // 记录原始错误消息到日志(方便调试,包含供应商信息) + logger.error( + `📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}` + ) + + // 检查是否为账户禁用错误 + const accountDisabledError = isAccountDisabledError( + response.status, + errorDataForCheck + ) + + if (response.status === 401) { + await claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (accountDisabledError) { + logger.error( + `🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked` + ) + // 传入完整的错误详情到 webhook + await claudeConsoleAccountService.markConsoleAccountBlocked( + accountId, + errorDataForCheck + ) + } else if (response.status === 429) { + await claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + } else if (response.status === 529) { + await claudeConsoleAccountService.markAccountOverloaded(accountId) + } + + // 设置响应头 + if (!responseStream.headersSent) { + responseStream.writeHead(response.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }) + } + + // 清理并发送错误响应 + try { + const fullErrorData = Buffer.concat(errorChunks).toString() + const errorJson = JSON.parse(fullErrorData) + const sanitizedError = sanitizeUpstreamError(errorJson) + + // 记录清理后的错误消息(发送给客户端的,完整记录) + logger.error( + `🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}` + ) + + if (!responseStream.destroyed) { + responseStream.write(JSON.stringify(sanitizedError)) + responseStream.end() + } + } catch (parseError) { + const sanitizedText = sanitizeErrorMessage(errorDataForCheck) + logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`) + + if (!responseStream.destroyed) { + responseStream.write(sanitizedText) + responseStream.end() + } } resolve() // 不抛出异常,正常完成流处理 }) + return } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 42ab7546..626a6f7c 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -527,68 +527,86 @@ class UnifiedClaudeScheduler { logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`) for (const account of consoleAccounts) { + // 主动检查封禁状态并尝试恢复(在过滤之前执行,确保可以恢复被封禁的账户) + const wasBlocked = await claudeConsoleAccountService.isAccountBlocked(account.id) + + // 如果账户之前被封禁但现在已恢复,重新获取最新状态 + let currentAccount = account + if (wasBlocked === false && account.status === 'account_blocked') { + // 可能刚刚被恢复,重新获取账户状态 + const freshAccount = await claudeConsoleAccountService.getAccount(account.id) + if (freshAccount) { + currentAccount = freshAccount + logger.info(`🔄 Account ${account.name} was recovered from blocked status`) + } + } + logger.info( - `🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + `🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}` ) - // 注意:getAllAccounts返回的isActive是布尔值 + // 注意:getAllAccounts返回的isActive是布尔值,getAccount返回的也是布尔值 if ( - account.isActive === true && - account.status === 'active' && - account.accountType === 'shared' && - this._isSchedulable(account.schedulable) + currentAccount.isActive === true && + currentAccount.status === 'active' && + currentAccount.accountType === 'shared' && + this._isSchedulable(currentAccount.schedulable) ) { // 检查是否可调度 // 检查模型支持 - if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) { + if (!this._isModelSupportedByAccount(currentAccount, 'claude-console', requestedModel)) { continue } // 检查订阅是否过期 - if (claudeConsoleAccountService.isSubscriptionExpired(account)) { + if (claudeConsoleAccountService.isSubscriptionExpired(currentAccount)) { logger.debug( - `⏰ Claude Console account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + `⏰ Claude Console account ${currentAccount.name} (${currentAccount.id}) expired at ${currentAccount.subscriptionExpiresAt}` ) continue } // 主动触发一次额度检查,确保状态即时生效 try { - await claudeConsoleAccountService.checkQuotaUsage(account.id) + await claudeConsoleAccountService.checkQuotaUsage(currentAccount.id) } catch (e) { logger.warn( - `Failed to check quota for Claude Console account ${account.name}: ${e.message}` + `Failed to check quota for Claude Console account ${currentAccount.name}: ${e.message}` ) // 继续处理该账号 } // 检查是否被限流 - const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) - const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id) + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( + currentAccount.id + ) + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + currentAccount.id + ) if (!isRateLimited && !isQuotaExceeded) { availableAccounts.push({ - ...account, - accountId: account.id, + ...currentAccount, + accountId: currentAccount.id, accountType: 'claude-console', - priority: parseInt(account.priority) || 50, - lastUsedAt: account.lastUsedAt || '0' + priority: parseInt(currentAccount.priority) || 50, + lastUsedAt: currentAccount.lastUsedAt || '0' }) logger.info( - `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` + `✅ Added Claude Console account to available pool: ${currentAccount.name} (priority: ${currentAccount.priority})` ) } else { if (isRateLimited) { - logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + logger.warn(`⚠️ Claude Console account ${currentAccount.name} is rate limited`) } if (isQuotaExceeded) { - logger.warn(`💰 Claude Console account ${account.name} quota exceeded`) + logger.warn(`💰 Claude Console account ${currentAccount.name} quota exceeded`) } } } else { logger.info( - `❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + `❌ Claude Console account ${currentAccount.name} not eligible - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}` ) } } diff --git a/src/utils/errorSanitizer.js b/src/utils/errorSanitizer.js new file mode 100644 index 00000000..2358e015 --- /dev/null +++ b/src/utils/errorSanitizer.js @@ -0,0 +1,162 @@ +/** + * 错误消息清理工具 + * 用于移除上游错误中的供应商特定信息(如 URL、引用等) + */ + +/** + * 清理错误消息中的 URL 和供应商引用 + * @param {string} message - 原始错误消息 + * @returns {string} - 清理后的消息 + */ +function sanitizeErrorMessage(message) { + if (typeof message !== 'string') { + return message + } + + // 移除 URL(http:// 或 https://) + let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '') + + // 移除常见的供应商引用模式 + cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '') + cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx" + cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx" + cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息 + + // 移除供应商特定关键词(包括整个单词) + cleaned = cleaned.replace(/88code\S*/gi, '') + cleaned = cleaned.replace(/duck\S*/gi, '') + cleaned = cleaned.replace(/packy\S*/gi, '') + cleaned = cleaned.replace(/ikun\S*/gi, '') + cleaned = cleaned.replace(/privnode\S*/gi, '') + cleaned = cleaned.replace(/yescode\S*/gi, '') + cleaned = cleaned.replace(/share\S*/gi, '') + cleaned = cleaned.replace(/yhlxj\S*/gi, '') + cleaned = cleaned.replace(/gac\S*/gi, '') + cleaned = cleaned.replace(/driod\S*/gi, '') + + cleaned = cleaned.replace(/\s+/g, ' ').trim() + + // 如果消息被清理得太短或为空,返回通用消息 + if (cleaned.length < 5) { + return 'The requested model is currently unavailable' + } + + return cleaned +} + +/** + * 递归清理对象中的所有错误消息字段 + * @param {Object} errorData - 原始错误数据对象 + * @returns {Object} - 清理后的错误数据 + */ +function sanitizeUpstreamError(errorData) { + if (!errorData || typeof errorData !== 'object') { + return errorData + } + + // 深拷贝避免修改原始对象 + const sanitized = JSON.parse(JSON.stringify(errorData)) + + // 递归清理嵌套的错误对象 + const sanitizeObject = (obj) => { + if (!obj || typeof obj !== 'object') { + return obj + } + + for (const key in obj) { + if (key === 'message' && typeof obj[key] === 'string') { + obj[key] = sanitizeErrorMessage(obj[key]) + } else if (typeof obj[key] === 'object') { + sanitizeObject(obj[key]) + } + } + + return obj + } + + return sanitizeObject(sanitized) +} + +/** + * 提取错误消息(支持多种错误格式) + * @param {*} body - 错误响应体(字符串或对象) + * @returns {string} - 提取的错误消息 + */ +function extractErrorMessage(body) { + if (!body) { + return '' + } + + // 处理字符串类型 + if (typeof body === 'string') { + const trimmed = body.trim() + if (!trimmed) { + return '' + } + try { + const parsed = JSON.parse(trimmed) + return extractErrorMessage(parsed) + } catch (error) { + return trimmed + } + } + + // 处理对象类型 + if (typeof body === 'object') { + // 常见错误格式: { error: "message" } + if (typeof body.error === 'string') { + return body.error + } + // 嵌套错误格式: { error: { message: "..." } } + if (body.error && typeof body.error === 'object') { + if (typeof body.error.message === 'string') { + return body.error.message + } + if (typeof body.error.error === 'string') { + return body.error.error + } + } + // 直接消息格式: { message: "..." } + if (typeof body.message === 'string') { + return body.message + } + } + + return '' +} + +/** + * 检测是否为账户被禁用或不可用的 400 错误 + * @param {number} statusCode - HTTP 状态码 + * @param {*} body - 响应体 + * @returns {boolean} - 是否为账户禁用错误 + */ +function isAccountDisabledError(statusCode, body) { + if (statusCode !== 400) { + return false + } + + const message = extractErrorMessage(body) + if (!message) { + return false + } + // 将消息全部转换为小写,进行模糊匹配(避免大小写问题) + const lowerMessage = message.toLowerCase() + // 检测常见的账户禁用/不可用模式 + return ( + lowerMessage.includes('organization has been disabled') || + lowerMessage.includes('account has been disabled') || + lowerMessage.includes('account is disabled') || + lowerMessage.includes('no account supporting') || + lowerMessage.includes('account not found') || + lowerMessage.includes('invalid account') || + lowerMessage.includes('too many active sessions') + ) +} + +module.exports = { + sanitizeErrorMessage, + sanitizeUpstreamError, + extractErrorMessage, + isAccountDisabledError +}