feat: 新增Claude Console账户临时封禁处理和错误消息清理

- 新增 CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 配置项,自动处理账户临时禁用的 400 错误(如 "organization has been disabled"、"too many active sessions" 等)。
  - 添加 errorSanitizer 工具模块,自动清理上游错误响应中的供应商特定信息(URL、供应商名称等),避免泄露中转服务商信息。
  - 统一调度器现在会主动检查并恢复已过期的封禁账户,确保账户在临时封禁时长结束后可以立即重新使用。
This commit is contained in:
sususu
2025-10-17 15:27:47 +08:00
parent f6eb077d82
commit b0917b75a4
6 changed files with 548 additions and 59 deletions

View File

@@ -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
}