mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-03-30 00:33:35 +00:00
Merge pull request #1024 from SunSeekerX/feat/oai_optimize2
Title: feat: 账户错误历史记录 + 完善 disableAutoProtection 覆盖
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
44
src/routes/admin/errorHistory.js
Normal file
44
src/routes/admin/errorHistory.js
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user