mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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:
@@ -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)
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
|
||||
Reference in New Issue
Block a user