mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 级别代理)
|
||||
|
||||
36
frontend/src/api/endpoints/types/statusSnapshot.ts
Normal file
36
frontend/src/api/endpoints/types/statusSnapshot.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 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)
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
|
||||
@@ -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' }
|
||||
|
||||
21
frontend/src/utils/__tests__/accountBlock.spec.ts
Normal file
21
frontend/src/utils/__tests__/accountBlock.spec.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
33
frontend/src/utils/__tests__/errorParser.spec.ts
Normal file
33
frontend/src/utils/__tests__/errorParser.spec.ts
Normal 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 已被使用并轮换,请重新登录授权',
|
||||
)
|
||||
})
|
||||
})
|
||||
20
frontend/src/utils/__tests__/oauthCountdown.spec.ts
Normal file
20
frontend/src/utils/__tests__/oauthCountdown.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
85
frontend/src/utils/__tests__/oauthRefreshFeedback.spec.ts
Normal file
85
frontend/src/utils/__tests__/oauthRefreshFeedback.spec.ts
Normal 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 刷新成功,已重新检查额度/账号状态',
|
||||
})
|
||||
})
|
||||
})
|
||||
107
frontend/src/utils/__tests__/providerKeyStatus.spec.ts
Normal file
107
frontend/src/utils/__tests__/providerKeyStatus.spec.ts
Normal 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 剩余有效期:')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
frontend/src/utils/oauthRefreshFeedback.ts
Normal file
74
frontend/src/utils/oauthRefreshFeedback.ts
Normal 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 刷新成功',
|
||||
}
|
||||
}
|
||||
165
frontend/src/utils/providerKeyStatus.ts
Normal file
165
frontend/src/utils/providerKeyStatus.ts
Normal 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
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user