fix provider pool quota status handling

This commit is contained in:
fawney19
2026-05-07 11:28:44 +08:00
parent 01c9f9be49
commit fd44906bb7
37 changed files with 1844 additions and 500 deletions

View File

@@ -1,11 +1,12 @@
export interface OAuthStatusSnapshot {
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'reauth_required' | 'check_failed'
label?: string | null
reason?: string | null
expires_at?: number | null
invalid_at?: number | null
source?: string | null
requires_reauth?: boolean
usable_until_expiry?: boolean
expiring_soon?: boolean
}

View File

@@ -157,6 +157,8 @@ export interface OAuthStatusInfo {
isExpiringSoon: boolean
isInvalid: boolean // Token 已失效(账号被封、授权撤销等)
invalidReason?: string // 失效原因
requiresReauth?: boolean
usableUntilExpiry?: boolean
}
/**

View File

@@ -486,14 +486,27 @@ function normalizeAuthTypeLabel(key: PoolKeyDetail | PoolKeySelectionItem): stri
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
const account = getAccountStatusDisplay(key)
if (account.blocked && account.label) return account.label
if (account.blocked && account.label) return compactStatusBadgeLabel(account.label)
const oauth = getOAuthStatusDisplay(key, 0)
if (oauth?.isInvalid) return 'Token 失效'
if (oauth?.isExpired) return 'Token 过期'
if (oauth?.requiresReauth) return '续期失败'
if (oauth?.isInvalid) return '已失效'
if (oauth?.isExpired) return '已过期'
return null
}
function compactStatusBadgeLabel(label: string): string {
const normalized = label.trim()
const mapped: Record<string, string> = {
'Token 失效': '已失效',
'Token 过期': '已过期',
账号已封禁: '账号封禁',
工作区已停用: '工作区停用',
账号访问受限: '访问受限',
}
return Array.from(mapped[normalized] || normalized).slice(0, 5).join('')
}
function getStatusBadgeTitle(key: PoolKeyDetail): string {
const label = getStatusBadgeLabel(key)
if (!label) return ''

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { cleanAccountBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
import {
cleanAccountBlockReason,
isAccountLevelBlockReason,
isRefreshFailedReason,
} from '@/utils/accountBlock'
describe('accountBlock helpers', () => {
it('detects refresh failure markers even when account block is also present', () => {
@@ -18,4 +22,12 @@ describe('accountBlock helpers', () => {
),
).toBe('工作区已停用 (deactivated_workspace)')
})
it('does not treat refresh failure text as an account block by itself', () => {
expect(
isAccountLevelBlockReason(
'[REFRESH_FAILED] Token 续期失败 (401): token has been invalidated',
),
).toBe(false)
})
})

View File

@@ -87,11 +87,59 @@ describe('providerKeyStatus', () => {
)
expect(status).toEqual({
text: '已失效',
text: expect.stringMatching(/^续期失败 /),
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
isExpiringSoon: expect.any(Boolean),
isInvalid: false,
invalidReason: '[REFRESH_FAILED] Token 续期失败 (401): refresh_token_reused',
requiresReauth: true,
usableUntilExpiry: true,
})
expect(status?.requiresReauth).toBe(true)
expect(getOAuthStatusTitle({
auth_type: 'oauth',
oauth_expires_at: future,
oauth_invalid_reason: '[REFRESH_FAILED] Token 续期失败 (401): refresh_token_reused',
}, 0)).toContain('当前 Access Token 未到期仍可使用')
expect(getOAuthRefreshButtonTitle({
auth_type: 'oauth',
oauth_expires_at: future,
oauth_invalid_reason: '[REFRESH_FAILED] Token 续期失败 (401): refresh_token_reused',
}, 0)).toBe('重新授权')
})
it('shows refresh failure as reauth required while access token is still usable', () => {
const future = Math.floor(Date.now() / 1000) + 2 * 24 * 3600
const status = getOAuthStatusDisplay(
{
auth_type: 'oauth',
oauth_expires_at: future,
status_snapshot: {
oauth: {
code: 'reauth_required',
reason: 'Token 续期失败 (400): refresh_token_reused',
expires_at: future,
requires_reauth: true,
usable_until_expiry: true,
},
account: {
code: 'ok',
blocked: false,
},
quota: { code: 'ok', exhausted: false },
},
},
0,
)
expect(status).toEqual({
text: expect.stringMatching(/^续期失败 /),
isExpired: false,
isExpiringSoon: expect.any(Boolean),
isInvalid: false,
invalidReason: 'Token 续期失败 (400): refresh_token_reused',
requiresReauth: true,
usableUntilExpiry: true,
})
})

View File

@@ -56,6 +56,7 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
if (!text) return false
if (text.startsWith('[ACCOUNT_BLOCK]')) return true
if (text.startsWith('[OAUTH_EXPIRED]')) return true
if (text.startsWith('[REFRESH_FAILED]')) return false
const lowered = text.toLowerCase()
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
}

View File

@@ -84,6 +84,19 @@ function getSnapshotOAuthState(
const expiresAt = oauth.expires_at ?? input.oauth_expires_at ?? null
const reason = normalizeText(oauth.reason)
if (code === 'reauth_required') {
const countdown = expiresAt == null ? null : getOAuthExpiresCountdown(expiresAt, tick, null, null)
return {
text: countdown?.text ? `续期失败 ${countdown.text}` : '续期失败',
isExpired: false,
isExpiringSoon: countdown?.isExpiringSoon ?? false,
isInvalid: false,
invalidReason: reason || undefined,
requiresReauth: true,
usableUntilExpiry: true,
}
}
if (code === 'invalid') {
return {
text: '已失效',
@@ -107,6 +120,27 @@ function getSnapshotOAuthState(
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
}
function refreshFailureAccessTokenStillUsable(expiresAt: number | null | undefined): boolean {
return typeof expiresAt === 'number' && expiresAt > Math.floor(Date.now() / 1000)
}
function getReauthRequiredOAuthState(
expiresAt: number | null | undefined,
tick: number,
reason: string,
): OAuthStatusInfo {
const countdown = expiresAt == null ? null : getOAuthExpiresCountdown(expiresAt, tick, null, null)
return {
text: countdown?.text ? `续期失败 ${countdown.text}` : '续期失败',
isExpired: false,
isExpiringSoon: countdown?.isExpiringSoon ?? false,
isInvalid: false,
invalidReason: reason,
requiresReauth: true,
usableUntilExpiry: true,
}
}
function getLegacyOAuthState(
input: ProviderKeyStatusCarrier,
tick: number,
@@ -115,6 +149,14 @@ function getLegacyOAuthState(
if (!input.oauth_expires_at && !input.oauth_invalid_at && !input.oauth_invalid_reason) return null
const rawReason = normalizeText(input.oauth_invalid_reason)
if (
rawReason
&& isRefreshFailedReason(rawReason)
&& refreshFailureAccessTokenStillUsable(input.oauth_expires_at)
) {
return getReauthRequiredOAuthState(input.oauth_expires_at, tick, rawReason)
}
if (rawReason && isAccountLevelBlockReason(rawReason) && !isRefreshFailedReason(rawReason)) {
if (!input.oauth_expires_at) return null
return getOAuthExpiresCountdown(input.oauth_expires_at, tick, null, null)
@@ -132,6 +174,7 @@ function getOAuthStatusSeverity(status: OAuthStatusInfo | null): number {
if (!status) return 0
if (status.isInvalid) return 3
if (status.isExpired) return 2
if (status.requiresReauth) return 2
return 1
}
@@ -217,8 +260,15 @@ export function getOAuthStatusTitle(
const reason = normalizeText(status.invalidReason)
return reason ? `Token 已失效: ${reason}` : 'Token 已失效'
}
const snapshotCode = normalizeText(input.status_snapshot?.oauth?.code)
if (snapshotCode === 'reauth_required' || status.requiresReauth) {
const reason = normalizeText(status.invalidReason)
return reason
? `Refresh Token 续期失败,当前 Access Token 未到期仍可使用: ${reason}`
: 'Refresh Token 续期失败,当前 Access Token 未到期仍可使用'
}
if (status.isExpired) {
return 'Token 已过期,请重新授权'
return 'Access Token 已过期,等待自动续期'
}
return `Token 剩余有效期: ${status.text}`
}
@@ -235,7 +285,7 @@ export function getOAuthRefreshButtonTitle(
}
const status = getOAuthStatusDisplay(input, tick)
if (status?.isInvalid || status?.isExpired) {
if (status?.isInvalid || status?.isExpired || status?.requiresReauth) {
return '重新授权'
}
return '刷新 Token'

View File

@@ -2873,9 +2873,38 @@ function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'bl
return 'available'
}
function compactPoolStatusLabel(label: string | null | undefined): string | null {
const normalized = String(label || '').trim()
if (!normalized) return null
const mapped: Record<string, string> = {
'Token 失效': '已失效',
'Token 过期': '已过期',
Token失效: '已失效',
Token过期: '已过期',
账号已封禁: '账号封禁',
工作区已停用: '工作区停用',
账号访问受限: '访问受限',
健康度较低: '健康低',
}
const labelText = mapped[normalized] || normalized
return Array.from(labelText).slice(0, 5).join('')
}
function getOAuthStatusBadgeLabel(status: ReturnType<typeof getVisibleOAuthState>): string | null {
if (!status) return null
if (status.requiresReauth) return '续期失败'
if (status.isInvalid) return '已失效'
if (status.isExpired) return '已过期'
if (status.text === '未添加') return '未添加'
if (status.text === '有效期未知') return '未知'
if (status.isExpiringSoon) return '将过期'
return '有效'
}
function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
const accountAlert = getAccountAlertLabel(key)
if (accountAlert) return accountAlert
if (accountAlert) return compactPoolStatusLabel(accountAlert) || accountAlert
const rawLabel = String(key.scheduling_label || '').trim()
if (
@@ -2884,7 +2913,7 @@ function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
&& !isHealthDerivedSchedulingLabel(rawLabel)
) {
if (rawLabel === '禁用' || rawLabel === '停用') return '禁用'
return rawLabel
return compactPoolStatusLabel(rawLabel) || rawLabel
}
if (!key.is_active) return '禁用'
@@ -2961,9 +2990,9 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
const orgBadge = getOAuthOrgBadge(key)
return buildPoolMobileTagItems({
accountStatusLabel: accountAlert,
accountStatusLabel: compactPoolStatusLabel(accountAlert),
accountStatusTone: accountAlert ? 'danger' : null,
oauthStatusLabel: oauthState?.text ?? null,
oauthStatusLabel: getOAuthStatusBadgeLabel(oauthState),
oauthStatusTone: getMobileOAuthTone(key),
priorityLabel: `P${key.internal_priority ?? 50}`,
authLabel: getAuthTypeChipLabel(key),