Files
Aether/frontend/src/utils/providerKeyStatus.ts
T

299 lines
8.9 KiB
TypeScript

import type { ProviderKeyStatusSnapshot } from '@/api/endpoints/types/statusSnapshot'
import { getOAuthExpiresCountdown, type OAuthStatusInfo } from '@/composables/useCountdownTimer'
import {
classifyAccountBlockLabel,
cleanAccountBlockReason,
isAccountLevelBlockReason,
isRefreshFailedReason,
} from './accountBlock'
import {
canRefreshOAuthCredential,
isOAuthManagedCredential,
type ProviderKeyAuthCarrier,
} from './providerKeyAuth'
export interface ProviderKeyStatusCarrier extends ProviderKeyAuthCarrier {
oauth_expires_at?: number | null
oauth_invalid_at?: number | null // compatibility only
oauth_invalid_reason?: string | null // compatibility only
account_status_label?: string | null // compatibility only
account_status_reason?: string | null // compatibility only
account_status_blocked?: boolean | null // compatibility only
status_snapshot?: ProviderKeyStatusSnapshot | null
}
export interface AccountStatusDisplay {
code: string
label: string | null
reason: string | null
blocked: boolean
}
function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') return null
const text = value.trim()
return text || null
}
function buildLegacyAccountStatus(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
const explicitLabel = normalizeText(input.account_status_label)
if (input.account_status_blocked && explicitLabel) {
return {
code: 'legacy',
label: explicitLabel,
reason: normalizeText(input.account_status_reason),
blocked: true,
}
}
const invalidReason = normalizeText(input.oauth_invalid_reason)
if (!invalidReason || !isAccountLevelBlockReason(invalidReason)) {
return { code: 'ok', label: null, reason: null, blocked: false }
}
const cleaned = cleanAccountBlockReason(invalidReason) || invalidReason
return {
code: 'legacy',
label: classifyAccountBlockLabel(cleaned || invalidReason),
reason: normalizeText(cleaned),
blocked: true,
}
}
export function getAccountStatusDisplay(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
const snapshot = input.status_snapshot?.account
if (snapshot) {
return {
code: normalizeText(snapshot.code) || 'ok',
label: normalizeText(snapshot.label),
reason: normalizeText(snapshot.reason),
blocked: Boolean(snapshot.blocked),
}
}
return buildLegacyAccountStatus(input)
}
function getSnapshotOAuthState(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
const oauth = input.status_snapshot?.oauth
if (!oauth) return null
const code = normalizeText(oauth.code) || 'none'
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: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: reason || undefined,
}
}
if (code === 'expired') {
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
}
if (code === 'check_failed') {
if (expiresAt == null) return null
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
}
if (expiresAt == null) return null
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,
): OAuthStatusInfo | null {
if (!isOAuthManagedCredential(input)) return null
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)
}
return getOAuthExpiresCountdown(
input.oauth_expires_at,
tick,
input.oauth_invalid_at,
input.oauth_invalid_reason,
)
}
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
}
function mergeOAuthStatusDisplay(
snapshotStatus: OAuthStatusInfo | null,
legacyStatus: OAuthStatusInfo | null,
): OAuthStatusInfo | null {
if (getOAuthStatusSeverity(legacyStatus) > getOAuthStatusSeverity(snapshotStatus)) {
return legacyStatus
}
if (
snapshotStatus?.isInvalid
&& !snapshotStatus.invalidReason
&& legacyStatus?.isInvalid
&& legacyStatus.invalidReason
) {
return {
...snapshotStatus,
invalidReason: legacyStatus.invalidReason,
}
}
return snapshotStatus ?? legacyStatus
}
function isOAuthCredentialWithoutRefreshToken(input: ProviderKeyStatusCarrier): boolean {
return isOAuthManagedCredential(input) && input.oauth_temporary === true
}
function getMissingRefreshTokenStatus(): OAuthStatusInfo {
return {
text: '未添加',
isExpired: false,
isExpiringSoon: false,
isInvalid: false,
}
}
export function getOAuthStatusDisplay(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
return mergeOAuthStatusDisplay(
getSnapshotOAuthState(input, tick),
getLegacyOAuthState(input, tick),
)
}
export function getOAuthStatusDisplayWithFallback(
input: ProviderKeyStatusCarrier,
tick: number,
): OAuthStatusInfo | null {
if (isOAuthCredentialWithoutRefreshToken(input)) {
return getMissingRefreshTokenStatus()
}
const status = getOAuthStatusDisplay(input, tick)
if (status) return status
if (!isOAuthManagedCredential(input)) return null
return {
text: '有效期未知',
isExpired: false,
isExpiringSoon: false,
isInvalid: false,
}
}
export function getOAuthStatusTitle(
input: ProviderKeyStatusCarrier,
tick: number,
): string {
if (isOAuthCredentialWithoutRefreshToken(input)) {
return 'Refresh Token 未添加,无法自动刷新'
}
const status = getOAuthStatusDisplay(input, tick)
if (!status) {
return isOAuthManagedCredential(input) ? 'Token 有效期未知' : ''
}
if (status.isInvalid) {
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 'Access Token 已过期,等待自动续期'
}
return `Token 剩余有效期: ${status.text}`
}
export function getOAuthRefreshButtonTitle(
input: ProviderKeyStatusCarrier,
tick: number,
): string {
if (isOAuthManagedCredential(input) && !canRefreshOAuthCredential(input)) {
if (input.oauth_temporary === true) {
return '仅 Access Token 导入,无法自动刷新,到期后需要重新导入'
}
return '当前 OAuth 凭据无法自动刷新,到期后需要重新导入'
}
const status = getOAuthStatusDisplay(input, tick)
if (status?.isInvalid || status?.isExpired || status?.requiresReauth) {
return '重新授权'
}
return '刷新 Token'
}
export function getAccountStatusTitle(input: ProviderKeyStatusCarrier): string {
const account = getAccountStatusDisplay(input)
if (!account.blocked || !account.label) return ''
return account.reason ? `${account.label}: ${account.reason}` : account.label
}