diff --git a/Dcodegithubclaude-relay-servicelogs.claude-relay-audit.log.json b/Dcodegithubclaude-relay-servicelogs.claude-relay-audit.log.json new file mode 100644 index 00000000..76e548a5 --- /dev/null +++ b/Dcodegithubclaude-relay-servicelogs.claude-relay-audit.log.json @@ -0,0 +1,35 @@ +{ + "keep": { + "days": false, + "amount": 5 + }, + "auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-audit.log.json", + "files": [ + { + "date": 1769443203308, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-01-27.log", + "hash": "2d09cc308d32c20207a0bf4eb0ae7aa7f25485b5cf2fee4dfce4be9ff2db8055" + }, + { + "date": 1770353006077, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-06.log", + "hash": "b370804c9b7f59563bcbbdb8bb2a920bd5711af9f25af77d50cb78e11c482e34" + }, + { + "date": 1770535480737, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-08.log", + "hash": "641ed3aaf8fe8003a5ed1ebb2331bf18f6957f26bd084e74d33b54aa69a5ff69" + }, + { + "date": 1770566683359, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-09.log", + "hash": "66a23ce0a7addb428d1f03594856b909a5660cf19574ab4b7cedf42aa48574f5" + }, + { + "date": 1772092540273, + "name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-2026-02-26.log", + "hash": "671e89d9c69f5f7f9962102e73f796ab7dc452b3e1e0bcd52156b3283aadd949" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/Dcodegithubclaude-relay-servicelogs.claude-relay-auth-detail-audit.log.json b/Dcodegithubclaude-relay-servicelogs.claude-relay-auth-detail-audit.log.json new file mode 100644 index 00000000..f781a8bd --- /dev/null +++ b/Dcodegithubclaude-relay-servicelogs.claude-relay-auth-detail-audit.log.json @@ -0,0 +1,35 @@ +{ + "keep": { + "days": false, + "amount": 5 + }, + "auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-auth-detail-audit.log.json", + "files": [ + { + "date": 1769440584327, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-01-26.log", + "hash": "08a424abf9d6edc0047328d0c2ca6c1f377b61c71ce13d4bf5226719ea0ac4a6" + }, + { + "date": 1770353006082, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-06.log", + "hash": "94960d294f44af312596a8c363a42f21d4ac14d6641bf0d41ce6a7892a72777d" + }, + { + "date": 1770535480742, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-08.log", + "hash": "2c8ab04264ba558d4f288851d4ecc293f3ca6f1f44a6b1a7fa8572372cc222bc" + }, + { + "date": 1770568187072, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-09.log", + "hash": "2e53b7737b1c159bb0767a53ec6840e5fb40632bc09e75d68b436e709ae77e48" + }, + { + "date": 1772092540291, + "name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-auth-detail-2026-02-26.log", + "hash": "001efc6519ea4016a960399e3f36a10dfe42ead7ca1b29b250134b429ccabf2f" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/Dcodegithubclaude-relay-servicelogs.claude-relay-error-audit.log.json b/Dcodegithubclaude-relay-servicelogs.claude-relay-error-audit.log.json new file mode 100644 index 00000000..4db7fa98 --- /dev/null +++ b/Dcodegithubclaude-relay-servicelogs.claude-relay-error-audit.log.json @@ -0,0 +1,35 @@ +{ + "keep": { + "days": false, + "amount": 5 + }, + "auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-error-audit.log.json", + "files": [ + { + "date": 1769440584323, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-01-26.log", + "hash": "8370bcc61ba4622611d3962dabf9e174600f5d3ac541e6ec3e3fab9f25557d39" + }, + { + "date": 1770353006079, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-06.log", + "hash": "7ef9cbd6923d8439948c9933ccdcc1969bf574b5bf3ce363df613278e9fe0ff7" + }, + { + "date": 1770535480739, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-08.log", + "hash": "003cd6b8b6d1b690ecc5db32d12c6bf928ac063292d0411191d38b5e353e03f7" + }, + { + "date": 1770568187069, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-09.log", + "hash": "3e1b5ef731712188fb42b1f8dfdfcfb3938ce4c735007d064586286bad026978" + }, + { + "date": 1772092540279, + "name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-error-2026-02-26.log", + "hash": "d2766330bdcbbf5ea596c30a582fb375bb3f30df42c8ea1faa2539d13e0cb683" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/Dcodegithubclaude-relay-servicelogs.claude-relay-security-audit.log.json b/Dcodegithubclaude-relay-servicelogs.claude-relay-security-audit.log.json new file mode 100644 index 00000000..b3e60f91 --- /dev/null +++ b/Dcodegithubclaude-relay-servicelogs.claude-relay-security-audit.log.json @@ -0,0 +1,35 @@ +{ + "keep": { + "days": false, + "amount": 5 + }, + "auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-security-audit.log.json", + "files": [ + { + "date": 1769443932546, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-01-27.log", + "hash": "8fd27ee13e9c6f8c3f20ce2dd84292d0ba2b132ae3c5a67e86c3ebb212efe36d" + }, + { + "date": 1770353006080, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-06.log", + "hash": "4efd558cc9ecacc592809085db8e0014db2285fa05dd06a4476fdefc66fcef06" + }, + { + "date": 1770535480741, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-08.log", + "hash": "59e5ca0add7eccb588c701630ef109e319fa617a9eed87986a9c8ea117fecb4b" + }, + { + "date": 1770567313766, + "name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-09.log", + "hash": "5f58e9eb8dda2a1bde5e2573152c310e76716e8856072d9d32746e0561cdeb23" + }, + { + "date": 1772092540285, + "name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-security-2026-02-26.log", + "hash": "3c7834465fa06c087416554d57bb8c3616d6aa172a639596ae36bf1bd6b6d3b6" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/src/routes/admin/errorHistory.js b/src/routes/admin/errorHistory.js new file mode 100644 index 00000000..314383f4 --- /dev/null +++ b/src/routes/admin/errorHistory.js @@ -0,0 +1,44 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +const router = express.Router() + +// 查询账户错误历史 +router.get( + '/accounts/:accountType/:accountId/error-history', + authenticateAdmin, + async (req, res) => { + try { + const { accountType, accountId } = req.params + const offset = parseInt(req.query.offset) || 0 + const limit = parseInt(req.query.limit) || 50 + const data = await upstreamErrorHelper.getErrorHistory(accountType, accountId, offset, limit) + return res.json({ success: true, data }) + } catch (error) { + logger.error('Failed to get error history:', error) + return res.status(500).json({ error: 'Failed to get error history', message: error.message }) + } + } +) + +// 清除账户错误历史 +router.delete( + '/accounts/:accountType/:accountId/error-history', + authenticateAdmin, + async (req, res) => { + try { + const { accountType, accountId } = req.params + await upstreamErrorHelper.clearErrorHistory(accountType, accountId) + return res.json({ success: true }) + } catch (error) { + logger.error('Failed to clear error history:', error) + return res + .status(500) + .json({ error: 'Failed to clear error history', message: error.message }) + } + } +) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index 01380a9c..bb8a30c5 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -28,6 +28,7 @@ const claudeRelayConfigRoutes = require('./claudeRelayConfig') const syncRoutes = require('./sync') const serviceRatesRoutes = require('./serviceRates') const quotaCardsRoutes = require('./quotaCards') +const errorHistoryRoutes = require('./errorHistory') // 挂载所有子路由 // 使用完整路径的模块(直接挂载到根路径) @@ -47,6 +48,7 @@ router.use('/', claudeRelayConfigRoutes) router.use('/', syncRoutes) router.use('/', serviceRatesRoutes) router.use('/', quotaCardsRoutes) +router.use('/', errorHistoryRoutes) // 使用相对路径的模块(需要指定基础路径前缀) router.use('/account-groups', accountGroupsRoutes) diff --git a/src/services/account/ccrAccountService.js b/src/services/account/ccrAccountService.js index e957cb53..e26f6636 100644 --- a/src/services/account/ccrAccountService.js +++ b/src/services/account/ccrAccountService.js @@ -364,6 +364,15 @@ class CcrAccountService { throw new Error('CCR Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 429, 'rate_limit').catch(() => {}) + return { success: true, skipped: true } + } + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 if (account.rateLimitDuration === 0) { logger.info( @@ -468,6 +477,15 @@ class CcrAccountService { throw new Error('CCR Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountOverloaded` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 529, 'overload').catch(() => {}) + return { success: true, skipped: true } + } + const now = new Date().toISOString() await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { status: 'overloaded', @@ -527,6 +545,15 @@ class CcrAccountService { throw new Error('CCR Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 401, 'auth_error').catch(() => {}) + return { success: true, skipped: true } + } + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { status: 'unauthorized', errorMessage: 'API key invalid or unauthorized' diff --git a/src/services/account/claudeAccountService.js b/src/services/account/claudeAccountService.js index 8e876990..a6524811 100644 --- a/src/services/account/claudeAccountService.js +++ b/src/services/account/claudeAccountService.js @@ -396,9 +396,25 @@ class ClaudeAccountService { const accountData = await redis.getClaudeAccount(accountId) if (accountData) { logRefreshError(accountId, accountData.name, 'claude', error) - accountData.status = 'error' - accountData.errorMessage = error.message - await redis.setClaudeAccount(accountId, accountData) + + // disableAutoProtection 检查:跳过状态修改,仅记录日志和错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping error status on token refresh failure` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 0, 'token_refresh_failed', { + errorBody: error.message + }) + .catch(() => {}) + } else { + accountData.status = 'error' + accountData.errorMessage = error.message + await redis.setClaudeAccount(accountId, accountData) + } // 发送Webhook通知 try { @@ -1329,6 +1345,20 @@ class ClaudeAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping rate limit marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 429, 'rate_limit') + .catch(() => {}) + return { success: true, skipped: true } + } + // 设置限流状态和时间 const updatedAccountData = { ...accountData } updatedAccountData.rateLimitedAt = new Date().toISOString() @@ -2367,6 +2397,21 @@ class ClaudeAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping ${errorType} marking` + ) + const statusCode = errorType === 'unauthorized' ? 401 : 403 + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', statusCode, errorType) + .catch(() => {}) + return { success: true, skipped: true } + } + // 更新账户状态 const updatedAccountData = { ...accountData } updatedAccountData.status = errorConfig.status @@ -2639,6 +2684,20 @@ class ClaudeAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping temp error marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 500, 'server_error') + .catch(() => {}) + return { success: true, skipped: true } + } + // 更新账户状态 const updatedAccountData = { ...accountData } updatedAccountData.status = 'temp_error' // 新增的临时错误状态 @@ -2848,6 +2907,20 @@ class ClaudeAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查:跳过过载标记,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping overload marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 529, 'overload') + .catch(() => {}) + return { success: true, skipped: true } + } + // 获取配置的过载处理时间(分钟) const overloadMinutes = config.overloadHandling?.enabled || 0 diff --git a/src/services/account/claudeConsoleAccountService.js b/src/services/account/claudeConsoleAccountService.js index 8d68dbcb..1b83c6f2 100644 --- a/src/services/account/claudeConsoleAccountService.js +++ b/src/services/account/claudeConsoleAccountService.js @@ -484,6 +484,17 @@ class ClaudeConsoleAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 429, 'rate_limit') + .catch(() => {}) + return { success: true, skipped: true } + } + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 if (account.rateLimitDuration === 0) { logger.info( @@ -715,6 +726,17 @@ class ClaudeConsoleAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 401, 'auth_error') + .catch(() => {}) + return { success: true, skipped: true } + } + const updates = { schedulable: 'false', status: 'unauthorized', @@ -761,6 +783,17 @@ class ClaudeConsoleAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markConsoleAccountBlocked` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 403, 'server_error') + .catch(() => {}) + return { success: true, skipped: true } + } + const blockedMinutes = this._getBlockedHandlingMinutes() if (blockedMinutes <= 0) { @@ -938,6 +971,17 @@ class ClaudeConsoleAccountService { throw new Error('Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountOverloaded` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 529, 'overload') + .catch(() => {}) + return { success: true, skipped: true } + } + const updates = { overloadedAt: new Date().toISOString(), overloadStatus: 'overloaded', diff --git a/src/services/account/geminiApiAccountService.js b/src/services/account/geminiApiAccountService.js index 35812fb8..a455c922 100644 --- a/src/services/account/geminiApiAccountService.js +++ b/src/services/account/geminiApiAccountService.js @@ -321,6 +321,17 @@ class GeminiApiAccountService { } if (isLimited) { + // disableAutoProtection 检查(仅在设置限流时) + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping setAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'gemini-api', 429, 'rate_limit') + .catch(() => {}) + return + } + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 const now = new Date() const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) @@ -360,6 +371,17 @@ class GeminiApiAccountService { return } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'gemini-api', 401, 'auth_error') + .catch(() => {}) + return + } + const now = new Date().toISOString() const currentCount = parseInt(account.unauthorizedCount || '0', 10) const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 diff --git a/src/services/account/openaiAccountService.js b/src/services/account/openaiAccountService.js index 7fd41e1a..8fa80861 100644 --- a/src/services/account/openaiAccountService.js +++ b/src/services/account/openaiAccountService.js @@ -940,6 +940,21 @@ function isRateLimited(account) { // 设置账户限流状态 async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { + // disableAutoProtection 检查(仅在设置限流时) + if (isLimited) { + const account = await getAccount(accountId) + if ( + account && + (account.disableAutoProtection === true || account.disableAutoProtection === 'true') + ) { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping setAccountRateLimited` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'openai', 429, 'rate_limit').catch(() => {}) + return + } + } + const updates = { rateLimitStatus: isLimited ? 'limited' : 'normal', rateLimitedAt: isLimited ? new Date().toISOString() : null, @@ -1001,6 +1016,15 @@ async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证 throw new Error('Account not found') } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'openai', 401, 'auth_error').catch(() => {}) + return + } + const now = new Date().toISOString() const currentCount = parseInt(account.unauthorizedCount || '0', 10) const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 diff --git a/src/services/account/openaiResponsesAccountService.js b/src/services/account/openaiResponsesAccountService.js index c2cae7a8..e16c7129 100644 --- a/src/services/account/openaiResponsesAccountService.js +++ b/src/services/account/openaiResponsesAccountService.js @@ -275,6 +275,17 @@ class OpenAIResponsesAccountService { return } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'openai-responses', 429, 'rate_limit') + .catch(() => {}) + return + } + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 const now = new Date() const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) @@ -301,6 +312,17 @@ class OpenAIResponsesAccountService { return } + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'openai-responses', 401, 'auth_error') + .catch(() => {}) + return + } + const now = new Date().toISOString() const currentCount = parseInt(account.unauthorizedCount || '0', 10) const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 diff --git a/src/utils/upstreamErrorHelper.js b/src/utils/upstreamErrorHelper.js index 7a37444c..57a2f271 100644 --- a/src/utils/upstreamErrorHelper.js +++ b/src/utils/upstreamErrorHelper.js @@ -1,6 +1,9 @@ const logger = require('./logger') const TEMP_UNAVAILABLE_PREFIX = 'temp_unavailable' +const ERROR_HISTORY_PREFIX = 'error_history' +const ERROR_HISTORY_MAX = 5000 +const ERROR_HISTORY_TTL = 3 * 24 * 60 * 60 // 3天 // 默认 TTL(秒) const DEFAULT_TTL = { @@ -110,8 +113,90 @@ const parseRetryAfter = (headers) => { return null } +// 记录错误历史到 Redis List +const recordErrorHistory = async ( + accountId, + accountType, + statusCode, + errorType, + context = null +) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + + const entry = JSON.stringify({ + time: new Date().toISOString(), + status: statusCode, + errorType, + context: context + ? { + ...context, + errorBody: + typeof context.errorBody === 'string' + ? context.errorBody.slice(0, 2000) + : context.errorBody + ? JSON.stringify(context.errorBody).slice(0, 2000) + : undefined + } + : null + }) + + const pipeline = client.pipeline() + pipeline.lpush(redisKey, entry) + pipeline.ltrim(redisKey, 0, ERROR_HISTORY_MAX - 1) + pipeline.expire(redisKey, ERROR_HISTORY_TTL) + await pipeline.exec() + } catch (err) { + logger.warn(`⚠️ [ErrorHistory] Failed to record error history for ${accountId}: ${err.message}`) + } +} + +// 查询错误历史(分页) +const getErrorHistory = async (accountType, accountId, offset = 0, limit = 50) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const o = Math.max(0, Math.floor(offset)) + const l = Math.min(500, Math.max(1, Math.floor(limit))) + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + const list = await client.lrange(redisKey, o, o + l - 1) + return list + .map((item) => { + try { + return JSON.parse(item) + } catch { + return null + } + }) + .filter((item) => item?.time) + } catch (error) { + logger.error(`❌ [ErrorHistory] Failed to get error history for ${accountId}:`, error) + return [] + } +} + +// 清除错误历史 +const clearErrorHistory = async (accountType, accountId) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + await client.del(redisKey) + } catch (error) { + logger.error(`❌ [ErrorHistory] Failed to clear error history for ${accountId}:`, error) + } +} + // 标记账户为临时不可用 -const markTempUnavailable = async (accountId, accountType, statusCode, customTtl = null) => { +const markTempUnavailable = async ( + accountId, + accountType, + statusCode, + customTtl = null, + context = null +) => { try { const errorType = classifyError(statusCode) if (!errorType) { @@ -138,6 +223,9 @@ const markTempUnavailable = async (accountId, accountType, statusCode, customTtl `⏱️ [UpstreamError] Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s (${statusCode} ${errorType})` ) + // 异步记录错误历史,不阻塞主流程 + recordErrorHistory(accountId, accountType, statusCode, errorType, context).catch(() => {}) + return { success: true, ttlSeconds, errorType } } catch (error) { logger.error( @@ -251,5 +339,9 @@ module.exports = { classifyError, parseRetryAfter, sanitizeErrorForClient, - TEMP_UNAVAILABLE_PREFIX + recordErrorHistory, + getErrorHistory, + clearErrorHistory, + TEMP_UNAVAILABLE_PREFIX, + ERROR_HISTORY_PREFIX } diff --git a/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue b/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue new file mode 100644 index 00000000..d410d42a --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue @@ -0,0 +1,234 @@ + + + diff --git a/web/admin-spa/src/utils/http_apis.js b/web/admin-spa/src/utils/http_apis.js index b7c5c37c..697d4ce5 100644 --- a/web/admin-spa/src/utils/http_apis.js +++ b/web/admin-spa/src/utils/http_apis.js @@ -266,6 +266,12 @@ export const updateQuotaCardLimitsApi = (data) => // 账户余额 export const getAccountBalanceApi = (id, params) => request({ url: `/admin/accounts/${id}/balance`, method: 'GET', params }) + +// 账户错误历史 +export const getAccountErrorHistoryApi = (accountType, accountId, params) => + request({ url: `/admin/accounts/${accountType}/${accountId}/error-history`, params }) +export const clearAccountErrorHistoryApi = (accountType, accountId) => + request({ url: `/admin/accounts/${accountType}/${accountId}/error-history`, method: 'DELETE' }) export const refreshAccountBalanceApi = (id, data) => request({ url: `/admin/accounts/${id}/balance/refresh`, method: 'POST', data }) export const getBalanceSummaryApi = () => diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 22d24caa..db902f26 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1315,6 +1315,14 @@ 详情 + +