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:
@@ -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