feat: 引入 status_snapshot 统一 provider key 状态管理

- 新增 StatusSnapshot 模型,聚合 OAuth / 账号 / 配额三维状态
- 新增 StatusSnapshotStore 负责快照的持久化与查询
- 重构 response_builder / endpoint_models,基于 snapshot 输出状态字段
- 前端抽取 providerKeyStatus / oauthRefreshFeedback 工具函数,
  统一 PoolManagement、ProviderDetailDrawer、BatchDialog 的状态展示
- errorParser 增加已知 OAuth 错误的友好提示
- refresher 适配 snapshot 写入,account_state 扩展状态分类
- 新增 alembic 迁移及存量数据回填脚本
- 补充前后端单元测试
This commit is contained in:
fawney19
2026-03-20 19:16:52 +08:00
parent 25d38ae632
commit 46737d32f8
39 changed files with 2326 additions and 470 deletions

View File

@@ -1,6 +1,7 @@
import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
@@ -99,19 +100,20 @@ export interface PoolKeyDetail {
is_active: boolean
auth_type: string
oauth_expires_at?: number | null
oauth_invalid_at?: number | null
oauth_invalid_reason?: string | null
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_plan_type?: string | null
oauth_account_id?: string | null
oauth_account_user_id?: string | null
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
account_status_code?: string | null
account_status_label?: string | null
account_status_reason?: string | null
account_status_blocked?: boolean
account_status_recoverable?: boolean
account_status_source?: string | null
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
status_snapshot?: ProviderKeyStatusSnapshot | null
quota_updated_at?: number | null
health_score?: number
circuit_breaker_open?: boolean

View File

@@ -1,3 +1,5 @@
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
/**
* 代理配置类型
* 支持两种模式:
@@ -290,8 +292,9 @@ export interface EndpointAPIKey {
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
status_snapshot?: ProviderKeyStatusSnapshot | null
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理)

View File

@@ -0,0 +1,36 @@
export interface OAuthStatusSnapshot {
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
label?: string | null
reason?: string | null
expires_at?: number | null
invalid_at?: number | null
source?: string | null
requires_reauth?: boolean
expiring_soon?: boolean
}
export interface AccountStatusSnapshot {
code: string
label?: string | null
reason?: string | null
blocked: boolean
source?: string | null
recoverable?: boolean
}
export interface QuotaStatusSnapshot {
code: 'unknown' | 'ok' | 'exhausted'
label?: string | null
reason?: string | null
exhausted: boolean
usage_ratio?: number | null
updated_at?: number | null
reset_seconds?: number | null
plan_type?: string | null
}
export interface ProviderKeyStatusSnapshot {
oauth: OAuthStatusSnapshot
account: AccountStatusSnapshot
quota: QuotaStatusSnapshot
}

View File

@@ -174,15 +174,18 @@ export function getOAuthExpiresCountdown(
invalidReason?: string | null
): OAuthStatusInfo | null {
void _tick
const normalizedInvalidReason = typeof invalidReason === 'string'
? invalidReason.trim()
: ''
// 优先检查失效状态(失效比过期更严重)
if (invalidAt != null) {
if (invalidAt != null || normalizedInvalidReason) {
return {
text: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: invalidReason || undefined
invalidReason: normalizedInvalidReason || undefined
}
}

View File

@@ -278,8 +278,13 @@ import {
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { classifyAccountBlockLabel, cleanAccountBlockReason, isAccountLevelBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthStatusDisplay,
getOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
type QuickSelectorValue =
| 'banned'
@@ -423,21 +428,12 @@ function normalizeAuthTypeLabel(authType: string): string {
}
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
const explicitLabel = String(key.account_status_label || '').trim()
if (explicitLabel) return explicitLabel
const account = getAccountStatusDisplay(key)
if (account.blocked && account.label) return account.label
const reason = String(key.oauth_invalid_reason || '').trim()
if (isAccountLevelBlockReason(reason)) {
const cleaned = cleanAccountBlockReason(reason)
return classifyAccountBlockLabel(cleaned || reason)
}
if (normalizeText(key.auth_type) !== 'oauth') return null
if (isRefreshFailedReason(reason)) return '续期失败'
if (key.oauth_invalid_at != null || normalizeText(reason)) return 'Token 失效'
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
return key.oauth_expires_at * 1000 <= Date.now() ? 'Token 过期' : null
}
const oauth = getOAuthStatusDisplay(key, 0)
if (oauth?.isInvalid) return 'Token 失效'
if (oauth?.isExpired) return 'Token 过期'
return null
}
@@ -445,20 +441,11 @@ function getStatusBadgeTitle(key: PoolKeyDetail): string {
const label = getStatusBadgeLabel(key)
if (!label) return ''
const explicitReason = String(key.account_status_reason || '').trim()
if (explicitReason) return `${label}: ${explicitReason}`
const accountTitle = getAccountStatusTitle(key)
if (accountTitle) return accountTitle
const reason = String(key.oauth_invalid_reason || '').trim()
if (!reason) return label
if (isAccountLevelBlockReason(reason)) {
const cleaned = cleanAccountBlockReason(reason)
return cleaned ? `${label}: ${cleaned}` : label
}
if (isRefreshFailedReason(reason)) {
const cleaned = reason.replace(/^\[REFRESH_FAILED\]\s*/i, '').trim()
return cleaned ? `${label}: ${cleaned}` : label
}
return `${label}: ${reason}`
const oauthTitle = getOAuthStatusTitle(key, 0)
return oauthTitle || label
}
function formatRelativeTime(value: string): string {

View File

@@ -334,7 +334,7 @@
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="getKeyOAuthExpires(key)">
<!-- 账号级别异常醒目提示 + 清除按钮 -->
<template v-if="getKeyOAuthExpires(key)?.isInvalid && isAccountLevelBlock(key)">
<template v-if="isAccountLevelBlock(key)">
<Badge
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
@@ -375,7 +375,7 @@
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.id"
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
:title="getOAuthRefreshButtonTitle(key)"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
@@ -1089,7 +1089,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import {
getProvider,
getProviderEndpoints,
@@ -1136,8 +1136,15 @@ import {
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
getOAuthStatusDisplay,
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {
@@ -1643,15 +1650,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
refreshingOAuthKeyId.value = key.id
try {
const result = await refreshProviderOAuth(key.id)
if (result.account_state_recheck_attempted) {
if (result.account_state_recheck_error) {
showWarning('Token 刷新成功,但账号状态复检失败')
} else {
showSuccess('Token 刷新成功,已复检账号状态')
}
} else {
showSuccess('Token 刷新成功')
}
let refreshedKey: EndpointAPIKey | null = null
// 更新本地数据
const keyInList = providerKeys.value.find(k => k.id === key.id)
if (keyInList) {
@@ -1663,8 +1662,19 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
if (freshKeys) {
providerKeys.value = freshKeys
syncCurrentSelections(endpoints.value, freshKeys)
refreshedKey = freshKeys.find(item => item.id === key.id) ?? null
}
}
const feedback = getOAuthRefreshFeedback({
accountStateRecheckAttempted: result.account_state_recheck_attempted,
accountStateRecheckError: result.account_state_recheck_error,
snapshot: refreshedKey,
})
if (feedback.tone === 'warning') {
showWarning(feedback.message)
} else {
showSuccess(feedback.message)
}
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
void autoRefreshQuotaInBackground()
@@ -1677,7 +1687,9 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
// 判断是否为账号级别的封禁(刷新 token 无法修复)
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
return isAccountLevelBlockReason(key.oauth_invalid_reason)
const account = getAccountStatusDisplay(key)
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
return account.blocked && !oauth?.isInvalid
}
// 清除 OAuth 失效标记
@@ -1701,6 +1713,27 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
if (keyInList) {
keyInList.oauth_invalid_at = null
keyInList.oauth_invalid_reason = null
if (keyInList.status_snapshot) {
keyInList.status_snapshot = {
...keyInList.status_snapshot,
oauth: {
...keyInList.status_snapshot.oauth,
code: 'none',
label: null,
reason: null,
invalid_at: null,
requires_reauth: false,
},
account: {
...keyInList.status_snapshot.account,
code: 'ok',
label: null,
reason: null,
blocked: false,
recoverable: false,
},
}
}
}
await loadEndpoints()
} catch (err: unknown) {
@@ -1832,8 +1865,11 @@ function shouldAutoRefreshCodexQuota(): boolean {
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
return key.oauth_invalid_at == null
&& typeof key.oauth_expires_at === 'number'
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
return false
}
return typeof key.oauth_expires_at === 'number'
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
}
@@ -2641,31 +2677,20 @@ function getOAuthPlanTypeClass(planType: string): string {
// OAuth 状态信息(包括失效和过期)
function getKeyOAuthExpires(key: EndpointAPIKey) {
if (key.auth_type !== 'oauth') return null
// 即使没有 expires_at也要检查 invalid_at
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
return getOAuthExpiresCountdown(
key.oauth_expires_at,
countdownTick.value,
key.oauth_invalid_at,
key.oauth_invalid_reason
)
return getOAuthStatusDisplay(key, countdownTick.value)
}
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
}
// OAuth 状态的 title 提示
function getOAuthStatusTitle(key: EndpointAPIKey): string {
const status = getKeyOAuthExpires(key)
if (!status) return ''
if (status.isInvalid) {
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
? cleanAccountBlockReason(status.invalidReason)
: status.invalidReason
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
const accountTitle = getAccountStatusTitle(key)
if (accountTitle && isAccountLevelBlock(key)) {
return accountTitle
}
if (status.isExpired) {
return 'Token 已过期,请重新授权'
}
return `Token 剩余有效期: ${status.text}`
return resolveOAuthStatusTitle(key, countdownTick.value)
}
// 健康度颜色

View File

@@ -1374,6 +1374,13 @@ function generateMockKeysForProvider(providerId: string, count: number = 2) {
oauth_expires_at: markInvalid ? null : nowSec + 6 * 3600,
oauth_invalid_at: markInvalid ? nowSec - 3600 : null,
oauth_invalid_reason: markInvalid ? '[ACCOUNT_BLOCK] Demo verification required' : null,
status_snapshot: {
oauth: { code: 'valid', label: '有效', reason: null, expires_at: nowSec + 6 * 3600, invalid_at: null, requires_reauth: false, expiring_soon: false },
account: markInvalid
? { code: 'account_verification', label: '需要验证', reason: 'Demo verification required', blocked: true, source: 'oauth_invalid', recoverable: false }
: { code: 'ok', label: null, reason: null, blocked: false, source: null, recoverable: false },
quota: { code: 'unknown', label: null, reason: null, exhausted: false, usage_ratio: null, updated_at: null, reset_seconds: null, plan_type: null }
},
oauth_plan_type: 'pro',
oauth_account_id: `acct-${providerId}`
} : { auth_type: 'api_key' }

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { cleanAccountBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
describe('accountBlock helpers', () => {
it('detects refresh failure markers even when account block is also present', () => {
expect(
isRefreshFailedReason(
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
),
).toBe(true)
})
it('keeps account block reason clean when refresh failure is appended', () => {
expect(
cleanAccountBlockReason(
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
),
).toBe('工作区已停用 (deactivated_workspace)')
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { parseApiError } from '@/utils/errorParser'
describe('errorParser', () => {
it('normalizes reused refresh token errors from legacy string details', () => {
const error = {
response: {
data: {
detail: "token refresh 失败: {'message': 'Your refresh token has already been used to generate a new access token. Please try signing in again.', 'type': 'invalid_request_error', 'param': None, 'code': 'refresh_token_reused'}",
},
},
}
expect(parseApiError(error, 'Token 刷新失败')).toBe(
'Token 刷新失败refresh_token 已被使用并轮换,请重新登录授权',
)
})
it('keeps normalized Chinese refresh failures intact', () => {
const error = {
response: {
data: {
detail: 'Token 刷新失败refresh_token 已被使用并轮换,请重新登录授权',
},
},
}
expect(parseApiError(error, 'Token 刷新失败')).toBe(
'Token 刷新失败refresh_token 已被使用并轮换,请重新登录授权',
)
})
})

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
describe('getOAuthExpiresCountdown', () => {
it('treats invalid reason as invalid even without invalid timestamp', () => {
expect(
getOAuthExpiresCountdown(
Math.floor(Date.now() / 1000) + 3600,
0,
null,
'[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
),
).toMatchObject({
text: '已失效',
isInvalid: true,
invalidReason: '[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
})
})
})

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
getOAuthRefreshFeedback,
resolveOAuthAccountBlockDisplay,
} from '@/utils/oauthRefreshFeedback'
describe('oauthRefreshFeedback', () => {
it('prefers explicit blocked account status from pool data', () => {
expect(
resolveOAuthAccountBlockDisplay({
status_snapshot: {
oauth: { code: 'valid' },
account: {
code: 'account_disabled',
label: '账号停用',
reason: 'account has been deactivated',
blocked: true,
},
quota: { code: 'unknown', exhausted: false },
},
}),
).toEqual({
label: '账号停用',
reason: 'account has been deactivated',
})
})
it('classifies account-level OAuth invalid reasons', () => {
expect(
resolveOAuthAccountBlockDisplay({
oauth_invalid_reason: '[ACCOUNT_BLOCK] account has been deactivated',
}),
).toEqual({
label: '账号停用',
reason: 'account has been deactivated',
})
})
it('ignores recoverable refresh failures', () => {
expect(
resolveOAuthAccountBlockDisplay({
oauth_invalid_reason: '[REFRESH_FAILED] Token 续期失败',
}),
).toEqual({
label: null,
reason: null,
})
})
it('reports blocked result after successful recheck', () => {
expect(
getOAuthRefreshFeedback({
accountStateRecheckAttempted: true,
snapshot: {
status_snapshot: {
oauth: { code: 'valid' },
account: {
code: 'account_disabled',
label: '账号停用',
reason: 'account has been deactivated',
blocked: true,
},
quota: { code: 'unknown', exhausted: false },
},
},
}),
).toEqual({
tone: 'warning',
message: 'Token 刷新成功,已重新检查额度/账号状态;当前状态仍是账号停用',
})
})
it('reports plain success when no blocked state remains', () => {
expect(
getOAuthRefreshFeedback({
accountStateRecheckAttempted: true,
accountStateRecheckError: null,
}),
).toEqual({
tone: 'success',
message: 'Token 刷新成功,已重新检查额度/账号状态',
})
})
})

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
getAccountStatusDisplay,
getOAuthStatusDisplay,
getOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
describe('providerKeyStatus', () => {
it('uses status_snapshot account state as the primary source', () => {
expect(
getAccountStatusDisplay({
status_snapshot: {
oauth: { code: 'valid' },
account: {
code: 'workspace_deactivated',
label: '工作区停用',
reason: 'deactivated_workspace',
blocked: true,
},
quota: { code: 'ok', exhausted: false },
},
}),
).toEqual({
code: 'workspace_deactivated',
label: '工作区停用',
reason: 'deactivated_workspace',
blocked: true,
})
})
it('shows oauth invalid when refresh failure exists beside account block', () => {
const status = getOAuthStatusDisplay(
{
auth_type: 'oauth',
oauth_expires_at: 2_000_000_000,
status_snapshot: {
oauth: {
code: 'invalid',
reason: 'Token 续期失败 (400): refresh_token_reused',
expires_at: 2_000_000_000,
requires_reauth: true,
},
account: {
code: 'workspace_deactivated',
label: '工作区停用',
reason: 'deactivated_workspace',
blocked: true,
},
quota: { code: 'ok', exhausted: false },
},
},
0,
)
expect(status).toEqual({
text: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: 'Token 续期失败 (400): refresh_token_reused',
})
})
it('falls back to countdown for account block without oauth invalidation', () => {
const status = getOAuthStatusDisplay(
{
auth_type: 'oauth',
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
status_snapshot: {
oauth: {
code: 'valid',
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
},
account: {
code: 'account_disabled',
label: '账号停用',
reason: 'account has been deactivated',
blocked: true,
},
quota: { code: 'ok', exhausted: false },
},
},
0,
)
expect(status?.isInvalid).toBe(false)
expect(status?.isExpired).toBe(false)
expect(getOAuthStatusTitle({
auth_type: 'oauth',
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
status_snapshot: {
oauth: {
code: 'valid',
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
},
account: {
code: 'account_disabled',
label: '账号停用',
reason: 'account has been deactivated',
blocked: true,
},
quota: { code: 'ok', exhausted: false },
},
}, 0)).toContain('Token 剩余有效期:')
})
})

View File

@@ -72,12 +72,15 @@ export function classifyAccountBlockLabel(reason: string): string {
}
export function cleanAccountBlockReason(reason: string): string {
return reason.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '').trim()
return reason
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
.trim()
}
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
if (!reason) return false
return reason.trim().startsWith('[REFRESH_FAILED]')
return reason.includes('[REFRESH_FAILED]')
}
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {

View File

@@ -133,6 +133,30 @@ function formatValidationError(error: ValidationError): string {
return `${fieldName}: ${error.msg}`
}
function normalizeKnownApiErrorMessage(message: string): string {
const text = message.trim()
if (!text) return text
const lowered = text.toLowerCase()
if (
lowered.includes('refresh_token_reused')
|| lowered.includes('already been used to generate a new access token')
) {
return 'Token 刷新失败refresh_token 已被使用并轮换,请重新登录授权'
}
if (
lowered.includes('token refresh 失败:')
|| lowered.includes('token refresh failed:')
) {
return text
.replace(/^token refresh 失败:\s*/i, 'Token 刷新失败:')
.replace(/^token refresh failed:\s*/i, 'Token 刷新失败:')
}
return text
}
/**
* 解析 API 错误响应
* @param err 错误对象
@@ -145,7 +169,7 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
// 处理网络错误
if (!isApiError(err) || !err.response) {
if (err instanceof Error) {
return err.message || defaultMessage
return normalizeKnownApiErrorMessage(err.message || defaultMessage)
}
return '无法连接到服务器,请检查网络连接'
}
@@ -154,14 +178,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
// 1. 处理 {error: {type, message}} 格式ProxyException 返回格式)
if (data?.error?.message) {
return data.error.message
return normalizeKnownApiErrorMessage(data.error.message)
}
const detail = data?.detail
// 如果没有 detail 字段
if (!detail) {
return data?.message || err.message || defaultMessage
return normalizeKnownApiErrorMessage(data?.message || err.message || defaultMessage)
}
// 1. 处理 Pydantic 验证错误(数组格式)
@@ -174,14 +198,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
// 2. 处理字符串错误
if (typeof detail === 'string') {
return detail
return normalizeKnownApiErrorMessage(detail)
}
// 3. 处理对象错误
if (typeof detail === 'object') {
// 可能是自定义错误对象
if ((detail as Record<string, unknown>).message) {
return String((detail as Record<string, unknown>).message)
return normalizeKnownApiErrorMessage(String((detail as Record<string, unknown>).message))
}
// 尝试 JSON 序列化
try {

View File

@@ -0,0 +1,74 @@
import {
getAccountStatusDisplay,
type ProviderKeyStatusCarrier,
} from './providerKeyStatus'
export interface OAuthAccountBlockDisplay {
label: string | null
reason: string | null
}
export interface OAuthRefreshFeedbackInput {
accountStateRecheckAttempted?: boolean | null
accountStateRecheckError?: string | null
snapshot?: ProviderKeyStatusCarrier | null
}
function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') return null
const text = value.trim()
return text || null
}
export function resolveOAuthAccountBlockDisplay(
snapshot: ProviderKeyStatusCarrier,
): OAuthAccountBlockDisplay {
const account = getAccountStatusDisplay(snapshot)
if (!account.blocked || !account.label) {
return { label: null, reason: null }
}
return {
label: account.label,
reason: account.reason,
}
}
export function getOAuthRefreshFeedback(
input: OAuthRefreshFeedbackInput,
): { tone: 'success' | 'warning'; message: string } {
const blockedLabel = normalizeText(
input.snapshot ? getAccountStatusDisplay(input.snapshot).label : null,
)
const recheckError = normalizeText(input.accountStateRecheckError)
if (input.accountStateRecheckAttempted) {
if (recheckError) {
return {
tone: 'warning',
message: 'Token 刷新成功,但额度/账号状态复检失败',
}
}
if (blockedLabel) {
return {
tone: 'warning',
message: `Token 刷新成功,已重新检查额度/账号状态;当前状态仍是${blockedLabel}`,
}
}
return {
tone: 'success',
message: 'Token 刷新成功,已重新检查额度/账号状态',
}
}
if (blockedLabel) {
return {
tone: 'warning',
message: `Token 刷新成功,但当前状态仍是${blockedLabel}`,
}
}
return {
tone: 'success',
message: 'Token 刷新成功',
}
}

View File

@@ -0,0 +1,165 @@
import type { ProviderKeyStatusSnapshot } from '@/api/endpoints/types/statusSnapshot'
import { getOAuthExpiresCountdown, type OAuthStatusInfo } from '@/composables/useCountdownTimer'
import {
classifyAccountBlockLabel,
cleanAccountBlockReason,
isAccountLevelBlockReason,
isRefreshFailedReason,
} from './accountBlock'
export interface ProviderKeyStatusCarrier {
auth_type?: string | null
oauth_expires_at?: number | null
oauth_invalid_at?: number | null // compatibility only
oauth_invalid_reason?: string | null // compatibility only
account_status_label?: string | null // compatibility only
account_status_reason?: string | null // compatibility only
account_status_blocked?: boolean | null // compatibility only
status_snapshot?: ProviderKeyStatusSnapshot | null
}
export interface AccountStatusDisplay {
code: string
label: string | null
reason: string | null
blocked: boolean
}
function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') return null
const text = value.trim()
return text || null
}
function buildLegacyAccountStatus(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
const explicitLabel = normalizeText(input.account_status_label)
if (input.account_status_blocked && explicitLabel) {
return {
code: 'legacy',
label: explicitLabel,
reason: normalizeText(input.account_status_reason),
blocked: true,
}
}
const invalidReason = normalizeText(input.oauth_invalid_reason)
if (!invalidReason || !isAccountLevelBlockReason(invalidReason)) {
return { code: 'ok', label: null, reason: null, blocked: false }
}
const cleaned = cleanAccountBlockReason(invalidReason) || invalidReason
return {
code: 'legacy',
label: classifyAccountBlockLabel(cleaned || invalidReason),
reason: normalizeText(cleaned),
blocked: true,
}
}
export function getAccountStatusDisplay(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
const snapshot = input.status_snapshot?.account
if (snapshot) {
return {
code: normalizeText(snapshot.code) || 'ok',
label: normalizeText(snapshot.label),
reason: normalizeText(snapshot.reason),
blocked: Boolean(snapshot.blocked),
}
}
return buildLegacyAccountStatus(input)
}
function getSnapshotOAuthState(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
const oauth = input.status_snapshot?.oauth
if (!oauth) return null
const code = normalizeText(oauth.code) || 'none'
const expiresAt = oauth.expires_at ?? input.oauth_expires_at ?? null
const reason = normalizeText(oauth.reason)
if (code === 'invalid') {
return {
text: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: reason || undefined,
}
}
if (code === 'expired') {
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
}
if (code === 'check_failed') {
if (expiresAt == null) return null
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
}
if (expiresAt == null) return null
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
}
function getLegacyOAuthState(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
if (normalizeText(input.auth_type) !== 'oauth') return null
if (!input.oauth_expires_at && !input.oauth_invalid_at && !input.oauth_invalid_reason) return null
const rawReason = normalizeText(input.oauth_invalid_reason)
if (rawReason && isAccountLevelBlockReason(rawReason) && !isRefreshFailedReason(rawReason)) {
if (!input.oauth_expires_at) return null
return getOAuthExpiresCountdown(input.oauth_expires_at, tick, null, null)
}
return getOAuthExpiresCountdown(
input.oauth_expires_at,
tick,
input.oauth_invalid_at,
input.oauth_invalid_reason,
)
}
export function getOAuthStatusDisplay(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
return getSnapshotOAuthState(input, tick) ?? getLegacyOAuthState(input, tick)
}
export function getOAuthStatusTitle(
input: ProviderKeyStatusCarrier,
tick: number,
): string {
const status = getOAuthStatusDisplay(input, tick)
if (!status) return ''
if (status.isInvalid) {
const reason = normalizeText(status.invalidReason)
return reason ? `Token 已失效: ${reason}` : 'Token 已失效'
}
if (status.isExpired) {
return 'Token 已过期,请重新授权'
}
return `Token 剩余有效期: ${status.text}`
}
export function getOAuthRefreshButtonTitle(
input: ProviderKeyStatusCarrier,
tick: number,
): string {
const status = getOAuthStatusDisplay(input, tick)
if (status?.isInvalid || status?.isExpired) {
return '重新授权'
}
return '刷新 Token'
}
export function getAccountStatusTitle(input: ProviderKeyStatusCarrier): string {
const account = getAccountStatusDisplay(input)
if (!account.blocked || !account.label) return ''
return account.reason ? `${account.label}: ${account.reason}` : account.label
}

View File

@@ -463,7 +463,7 @@
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id"
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
:title="getOAuthRefreshButtonTitle(key)"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
@@ -472,16 +472,16 @@
/>
</Button>
<span
v-if="getKeyOAuthExpires(key)"
v-if="getVisibleOAuthState(key)"
class="text-[10px]"
:class="{
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
'text-destructive': getVisibleOAuthState(key)?.isInvalid || getVisibleOAuthState(key)?.isExpired,
'text-warning': getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isInvalid,
'text-muted-foreground': !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isInvalid
}"
:title="getOAuthStatusTitle(key)"
>
{{ getKeyOAuthExpires(key)?.text }}
{{ getVisibleOAuthState(key)?.text }}
</span>
</template>
<Badge
@@ -773,7 +773,7 @@
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id"
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
:title="getOAuthRefreshButtonTitle(key)"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
@@ -782,16 +782,16 @@
/>
</Button>
<span
v-if="getKeyOAuthExpires(key)"
v-if="getVisibleOAuthState(key)"
class="text-[10px]"
:class="{
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
'text-destructive': getVisibleOAuthState(key)?.isInvalid || getVisibleOAuthState(key)?.isExpired,
'text-warning': getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isInvalid,
'text-muted-foreground': !getVisibleOAuthState(key)?.isExpired && !getVisibleOAuthState(key)?.isExpiringSoon && !getVisibleOAuthState(key)?.isInvalid
}"
:title="getOAuthStatusTitle(key)"
>
{{ getKeyOAuthExpires(key)?.text }}
{{ getVisibleOAuthState(key)?.text }}
</span>
</template>
<Badge
@@ -1153,7 +1153,7 @@ import {
import RefreshButton from '@/components/ui/refresh-button.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getOAuthExpiresCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import {
@@ -1194,8 +1194,15 @@ import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
getOAuthStatusDisplay,
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
const { success, error: showError, warning: showWarning } = useToast()
const { confirm } = useConfirm()
@@ -1747,6 +1754,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
oauth_organizations: key.oauth_organizations ?? [],
oauth_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
status_snapshot: key.status_snapshot ?? null,
proxy: key.proxy ?? null,
}
}
@@ -2007,16 +2015,18 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
if (target) {
target.oauth_expires_at = result.expires_at ?? null
}
if (result.account_state_recheck_attempted) {
if (result.account_state_recheck_error) {
showWarning('Token 刷新成功,但账号状态复检失败')
} else {
success('Token 刷新成功,已复检账号状态')
}
} else {
success('Token 刷新成功')
}
await loadKeys()
const refreshedKey = keyPage.value.keys.find(k => k.key_id === key.key_id) ?? null
const feedback = getOAuthRefreshFeedback({
accountStateRecheckAttempted: result.account_state_recheck_attempted,
accountStateRecheckError: result.account_state_recheck_error,
snapshot: refreshedKey,
})
if (feedback.tone === 'warning') {
showWarning(feedback.message)
} else {
success(feedback.message)
}
} catch (err) {
showError(parseApiError(err, 'Token 刷新失败'))
await loadKeys()
@@ -2350,35 +2360,16 @@ function getOAuthPlanTypeClass(planType: string): string {
return classes[planType.toLowerCase()] || ''
}
function getKeyOAuthExpires(key: PoolKeyDetail) {
if (key.auth_type !== 'oauth') return null
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
return getOAuthExpiresCountdown(
key.oauth_expires_at,
countdownTick.value,
key.oauth_invalid_at,
key.oauth_invalid_reason
)
function getVisibleOAuthState(key: PoolKeyDetail) {
return getOAuthStatusDisplay(key, countdownTick.value)
}
function getOAuthRefreshButtonTitle(key: PoolKeyDetail): string {
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
}
function getOAuthStatusTitle(key: PoolKeyDetail): string {
const status = getKeyOAuthExpires(key)
if (!status) return ''
if (status.isInvalid) {
const accountLabel = String(key.account_status_label || '').trim()
const accountReason = String(key.account_status_reason || '').trim()
if (accountLabel) {
return accountReason ? `${accountLabel}: ${accountReason}` : accountLabel
}
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
? cleanAccountBlockReason(status.invalidReason)
: status.invalidReason
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
}
if (status.isExpired) {
return 'Token 已过期,请重新授权'
}
return `Token 剩余有效期: ${status.text}`
return resolveOAuthStatusTitle(key, countdownTick.value)
}
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
@@ -2387,20 +2378,11 @@ function getAccountAlertLabel(key: PoolKeyDetail): string | null {
const cached = _accountAlertCache.get(key)
if (cached !== undefined) return cached
let result: string | null = null
const explicitLabel = String(key.account_status_label || '').trim()
if (key.account_status_blocked && explicitLabel) {
result = explicitLabel
}
let result: string | null = getAccountStatusDisplay(key).label
const quotaText = String(key.account_quota || '').trim()
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
if (!result && (quotaText === '账号已封禁' || quotaText === '封禁')) result = '账号封禁'
else if (!result && quotaText === '访问受限') result = '访问受限'
else if (!result && isAccountLevelBlockReason(key.oauth_invalid_reason)) {
const reason = String(key.oauth_invalid_reason || '').trim()
const cleaned = cleanAccountBlockReason(reason)
result = classifyAccountBlockLabel(cleaned || reason)
}
_accountAlertCache.set(key, result)
return result
@@ -2410,17 +2392,8 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
const label = getAccountAlertLabel(key)
if (!label) return ''
const explicitReason = String(key.account_status_reason || '').trim()
if (explicitReason) return `${label}: ${explicitReason}`
const reason = String(key.oauth_invalid_reason || '').trim()
if (reason) {
if (isAccountLevelBlockReason(reason)) {
const cleaned = cleanAccountBlockReason(reason)
return cleaned ? `${label}: ${cleaned}` : label
}
return `${label}: ${reason}`
}
const accountTitle = getAccountStatusTitle(key)
if (accountTitle) return accountTitle
const quotaText = String(key.account_quota || '').trim()
if (quotaText) return `${label}: ${quotaText}`