feat(pool): 账号停用检测、OAuth refresh 失效标记与号池管理性能优化

- 新增 account_deactivated 关键词检测,覆盖 error_handler / health_policy / account_state / 前端
- 401 健康策略分级冷却:账号永久停用 1h,临时认证失败 60s
- OAuth refresh token 失败时标记 oauth_invalid(不停用 key),成功时清除非账号级标记
- 号池管理 API 使用 load_only + SQL 聚合替代全量拉取,减少查询开销
- Redis 冷却统计改用 batch_count_provider_cooldowns (SCAN) 替代逐 key 检查
- 前端时间线移除 executableTimeline 过滤层,Provider 选择改为非阻塞加载
This commit is contained in:
fawney19
2026-03-05 16:23:58 +08:00
parent e86c8edd4b
commit a7697032a4
9 changed files with 265 additions and 70 deletions

View File

@@ -642,10 +642,6 @@ const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
return 'failed'
}
const isPlannedOnlyStatus = (status: CandidateRecord['status']): boolean => {
return status === 'available'
}
const extractPoolGroupId = (candidate: CandidateRecord): string | null => {
const extra = candidate.extra_data
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) return null
@@ -671,22 +667,6 @@ const rawTimeline = computed<CandidateRecord[]>(() => {
})
})
// 仅保留”已进入执行链路”的节点,过滤预创建但未执行的 available 占位记录。
// unused 候选:代表因前序候选成功而未被执行的备选项。
// - 号池内 unused key不显示号池只展示实际参与调度的 key
// - 非号池 unused每个 candidate_index 保留 retry_index=0代表该候选存在但未执行
const executableTimeline = computed<CandidateRecord[]>(() => {
return rawTimeline.value.filter(candidate => {
if (isPlannedOnlyStatus(candidate.status)) return false
if (candidate.status === 'unused') {
// 号池内 unused key 不需要展示
if (extractPoolGroupId(candidate) !== null) return false
// 非号池的 unused retry slot 只保留首个
return candidate.retry_index === 0
}
return true
})
})
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
const metadata = props.requestMetadata
@@ -698,7 +678,7 @@ const schedulingAudit = computed<Record<string, unknown> | null>(() => {
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
// 新链路:优先使用后端写入的 extra_data.pool_group_id。
const fromTrace = executableTimeline.value.filter((candidate) => extractPoolGroupId(candidate) !== null)
const fromTrace = rawTimeline.value.filter((candidate) => extractPoolGroupId(candidate) !== null)
if (fromTrace.length > 0) {
return fromTrace
}
@@ -823,8 +803,8 @@ const poolAttemptKeySet = computed<Set<string>>(() => {
})
const timeline = computed<CandidateRecord[]>(() => {
if (poolAttemptCandidates.value.length === 0) return executableTimeline.value
return executableTimeline.value.filter(
if (poolAttemptCandidates.value.length === 0) return rawTimeline.value
return rawTimeline.value.filter(
(candidate) => !poolAttemptKeySet.value.has(makeAttemptKey(candidate.candidate_index, candidate.retry_index)),
)
})

View File

@@ -4,11 +4,15 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS = [
'account blocked',
'account has been disabled',
'account disabled',
'account has been deactivated',
'account_deactivated',
'account deactivated',
'organization has been disabled',
'organization_disabled',
'validation_required',
'verify your account',
'suspended',
'deactivated',
// Kiro quota refresher 写入的确切文本
'账户已封禁',
// Antigravity quota refresher 写入的确切文本

View File

@@ -1426,7 +1426,10 @@ async function selectProvider(id: string) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
await Promise.all([loadKeys(), loadProviderData(id)])
const keysTask = loadKeys()
// Provider summary is non-blocking for key list rendering.
void loadProviderData(id)
await keysTask
if (requestId !== selectProviderRequestId) return
}