mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix provider pool quota status handling
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,8 @@ export interface OAuthStatusInfo {
|
||||
isExpiringSoon: boolean
|
||||
isInvalid: boolean // Token 已失效(账号被封、授权撤销等)
|
||||
invalidReason?: string // 失效原因
|
||||
requiresReauth?: boolean
|
||||
usableUntilExpiry?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user