Merge pull request #1024 from SunSeekerX/feat/oai_optimize2

Title: feat: 账户错误历史记录 + 完善 disableAutoProtection 覆盖
This commit is contained in:
Wesley Liddick
2026-02-27 11:26:04 +08:00
committed by GitHub
16 changed files with 787 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,234 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex max-h-[92vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-red-500 to-orange-500 text-white shadow-lg"
>
<i class="fas fa-exclamation-triangle text-sm" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ accountName }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">错误历史 (最近 3 )</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
v-if="list.length > 0"
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-600 transition hover:bg-red-100 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/20"
@click="handleClear"
>
清除历史
</button>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
@click="handleClose"
>
<i class="fas fa-times" />
</button>
</div>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<!-- 加载中 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<i class="fas fa-spinner fa-spin mr-2 text-gray-400" />
<span class="text-sm text-gray-500 dark:text-gray-400">加载中...</span>
</div>
<!-- 空状态 -->
<div
v-else-if="!list.length"
class="flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500"
>
<i class="fas fa-check-circle mb-3 text-3xl text-green-400" />
<span class="text-sm">暂无错误记录</span>
</div>
<!-- 错误列表 -->
<div v-else class="space-y-3">
<div
v-for="(item, idx) in list"
:key="idx"
class="rounded-lg border border-gray-100 bg-gray-50/50 p-3 dark:border-gray-700/50 dark:bg-gray-800/50"
>
<!-- 头部: 时间 + 状态码 + 错误类型 -->
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-bold"
:class="statusClass(item.status)"
>
{{ item.status }}
</span>
<span
v-if="item.errorType"
class="rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{{ item.errorType }}
</span>
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">
{{ formatTime(item.time) }}
</span>
</div>
<!-- 上下文摘要 -->
<div
v-if="item.context"
class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400"
>
<span v-if="item.context.model">
<i class="fas fa-robot mr-1" />{{ item.context.model }}
</span>
<span v-if="item.context.path">
<i class="fas fa-route mr-1" />{{ item.context.path }}
</span>
<span v-if="item.context.apiKeyName">
<i class="fas fa-key mr-1" />{{ item.context.apiKeyName }}
</span>
</div>
<!-- 可折叠错误详情 -->
<div v-if="item.context?.errorBody" class="mt-2">
<button
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
@click="toggleDetail(idx)"
>
{{ expandedIdx === idx ? '收起详情' : '查看详情' }}
<i
class="ml-1"
:class="expandedIdx === idx ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"
/>
</button>
<pre
v-if="expandedIdx === idx"
class="mt-2 max-h-48 overflow-auto rounded bg-gray-100 p-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>{{ formatBody(item.context.errorBody) }}</pre
>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="flex justify-center pb-1 pt-2">
<button
class="rounded-lg bg-gray-100 px-4 py-2 text-sm text-gray-600 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="loadingMore"
@click="loadMore"
>
<i v-if="loadingMore" class="fas fa-spinner fa-spin mr-1" />
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import dayjs from 'dayjs'
import * as httpApis from '@/utils/http_apis'
const PAGE_SIZE = 50
const props = defineProps({
show: Boolean,
accountType: { type: String, default: '' },
accountId: { type: String, default: '' },
accountName: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const loading = ref(false)
const loadingMore = ref(false)
const list = ref([])
const hasMore = ref(false)
const expandedIdx = ref(null)
const fetchHistory = async (offset = 0) => {
const res = await httpApis.getAccountErrorHistoryApi(props.accountType, props.accountId, {
offset,
limit: PAGE_SIZE
})
if (res.success) {
const data = res.data || []
if (offset === 0) {
list.value = data
} else {
list.value.push(...data)
}
hasMore.value = data.length >= PAGE_SIZE
}
}
watch(
() => props.show,
async (val) => {
if (val) {
loading.value = true
list.value = []
expandedIdx.value = null
await fetchHistory(0)
loading.value = false
}
}
)
const loadMore = async () => {
loadingMore.value = true
await fetchHistory(list.value.length)
loadingMore.value = false
}
const handleClose = () => emit('close')
const handleClear = async () => {
await httpApis.clearAccountErrorHistoryApi(props.accountType, props.accountId)
list.value = []
hasMore.value = false
}
const toggleDetail = (idx) => {
expandedIdx.value = expandedIdx.value === idx ? null : idx
}
const formatTime = (time) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const formatBody = (body) => {
if (typeof body === 'string') {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body
}
}
return JSON.stringify(body, null, 2)
}
const statusClass = (status) => {
if (status >= 500 || status === 529)
return 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
if (status === 429)
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400'
if (status === 401 || status === 403)
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}
</script>

View File

@@ -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 = () =>

View File

@@ -1315,6 +1315,14 @@
<i class="fas fa-chart-line" />
<span class="ml-1">详情</span>
</button>
<button
class="rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-800/50"
title="查看错误历史"
@click="openErrorHistory(account)"
>
<i class="fas fa-exclamation-triangle" />
<span class="ml-1">错误</span>
</button>
<button
v-if="canTestAccount(account)"
class="rounded bg-cyan-100 px-2.5 py-1 text-xs font-medium text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
@@ -1823,6 +1831,13 @@
<i class="fas fa-chart-line" />
详情
</button>
<button
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-800/50"
@click="openErrorHistory(account)"
>
<i class="fas fa-exclamation-triangle" />
错误
</button>
<button
v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
@@ -1998,6 +2013,15 @@
@close="closeAccountUsageModal"
/>
<!-- 错误历史弹窗 -->
<AccountErrorHistoryModal
:account-id="errorHistoryTarget.accountId"
:account-name="errorHistoryTarget.accountName"
:account-type="errorHistoryTarget.accountType"
:show="showErrorHistoryModal"
@close="showErrorHistoryModal = false"
/>
<!-- 账户过期时间编辑弹窗 -->
<AccountExpiryEditModal
ref="expiryEditModalRef"
@@ -2187,6 +2211,7 @@ import * as httpApis from '@/utils/http_apis'
import AccountForm from '@/components/accounts/AccountForm.vue'
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountErrorHistoryModal from '@/components/accounts/AccountErrorHistoryModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import UnifiedTestModal from '@/components/common/UnifiedTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
@@ -2253,6 +2278,24 @@ const selectAllChecked = ref(false)
const isIndeterminate = ref(false)
const showCheckboxes = ref(false)
// 错误历史弹窗状态
const showErrorHistoryModal = ref(false)
const errorHistoryTarget = ref({ accountType: '', accountId: '', accountName: '' })
// 前端 platform → 后端 error_history key 的 accountType 映射
const platformToAccountType = (platform) => {
if (platform === 'claude' || platform === 'claude-oauth') return 'claude-official'
if (platform === 'azure_openai') return 'azure-openai'
return platform
}
const openErrorHistory = (account) => {
errorHistoryTarget.value = {
accountType: platformToAccountType(account.platform),
accountId: account.id,
accountName: account.name || account.email || account.id
}
showErrorHistoryModal.value = true
}
// 账号使用详情弹窗状态
const showAccountUsageModal = ref(false)
const accountUsageLoading = ref(false)
@@ -2549,6 +2592,15 @@ const getAccountActions = (account) => {
})
}
// 错误历史
actions.push({
key: 'error-history',
label: '错误历史',
icon: 'fa-exclamation-triangle',
color: 'red',
handler: () => openErrorHistory(account)
})
// 测试账户
if (canTestAccount(account)) {
actions.push({