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

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