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

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