feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进

- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用
- 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整
- aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s
- OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突
- ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环
- 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化
- 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义
- 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化
- 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用
- 补充相关测试用例
This commit is contained in:
fawney19
2026-03-12 01:11:35 +08:00
parent 0d770d1c4d
commit 6e51a3f45d
55 changed files with 3219 additions and 862 deletions

View File

@@ -1,5 +1,72 @@
<template>
<div class="space-y-4">
<Card
v-if="providerDeleteProgress"
class="border-primary/30 bg-primary/5"
>
<div class="px-5 py-4 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-semibold text-foreground">
正在删除提供商{{ providerDeleteProgress.providerName }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ providerDeleteStageLabel }} · {{ providerDeleteProgress.message || '后台处理中' }}
</div>
</div>
<div class="shrink-0 text-right">
<div class="text-xs font-medium text-primary">
{{ providerDeleteOverallPercent }}%
</div>
<div class="text-[11px] text-muted-foreground">
{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>总体进度</span>
<span>{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300"
:style="{ width: `${providerDeleteOverallPercent}%` }"
/>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>账号删除</span>
<span>{{ providerDeleteProgress.deletedKeys }}/{{ providerDeleteProgress.totalKeys || '...' }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary/80 transition-all duration-300"
:style="{ width: `${providerDeleteKeysPercent}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>端点删除</span>
<span>{{ providerDeleteProgress.deletedEndpoints }}/{{ providerDeleteProgress.totalEndpoints || '...' }}</span>
</div>
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
<div
class="h-full bg-primary/60 transition-all duration-300"
:style="{ width: `${providerDeleteEndpointsPercent}%` }"
/>
</div>
</div>
</div>
</div>
</Card>
<!-- 提供商表格 -->
<Card
variant="default"
@@ -212,6 +279,7 @@ import {
getProvidersSummary,
getProvider,
deleteProvider,
getProviderDeleteTask,
updateProvider,
getGlobalModels,
type ProviderWithEndpointsSummary,
@@ -219,7 +287,20 @@ import {
import { adminApi } from '@/api/admin'
import { parseApiError } from '@/utils/errorParser'
const { error: showError, success: showSuccess } = useToast()
interface ProviderDeleteProgressState {
providerId: string
providerName: string
taskId: string
status: string
stage: string
totalKeys: number
deletedKeys: number
totalEndpoints: number
deletedEndpoints: number
message: string
}
const { error: showError, success: showSuccess, info: showInfo } = useToast()
const { confirmDanger } = useConfirm()
// 状态
@@ -232,6 +313,113 @@ const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
const providerDeleteProgress = ref<ProviderDeleteProgressState | null>(null)
let deletePollAbort: AbortController | null = null
const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3
async function pollProviderDeleteTask(providerId: string, taskId: string) {
deletePollAbort?.abort()
const abort = new AbortController()
deletePollAbort = abort
const deadline = Date.now() + DELETE_POLL_MAX_MS
let consecutiveFailures = 0
while (Date.now() < deadline) {
if (abort.signal.aborted) return null
try {
const task = await getProviderDeleteTask(providerId, taskId)
consecutiveFailures = 0
if (providerDeleteProgress.value?.taskId === taskId) {
providerDeleteProgress.value = {
...providerDeleteProgress.value,
status: task.status,
stage: task.stage,
totalKeys: task.total_keys,
deletedKeys: task.deleted_keys,
totalEndpoints: task.total_endpoints,
deletedEndpoints: task.deleted_endpoints,
message: task.message,
}
}
if (task.status === 'completed' || task.status === 'failed') {
return task
}
} catch {
consecutiveFailures += 1
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
throw new Error('provider delete task polling failed')
}
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, DELETE_POLL_INTERVAL_MS)
abort.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(undefined) }, { once: true })
})
}
throw new Error('provider delete task timeout')
}
const providerDeleteStageLabel = computed(() => {
switch (providerDeleteProgress.value?.stage) {
case 'preparing':
return '准备删除'
case 'disabling':
return '停用提供商'
case 'cleaning_restrictions':
return '清理访问限制'
case 'cleaning_provider_refs':
return '清理历史引用'
case 'deleting_keys':
return '删除号池账号'
case 'deleting_endpoints':
return '删除端点'
case 'completed':
return '删除完成'
case 'failed':
return '删除失败'
default:
return '等待执行'
}
})
const providerDeleteTotalUnits = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
return progress.totalKeys + progress.totalEndpoints
})
const providerDeleteCompletedUnits = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
return Math.min(progress.deletedKeys + progress.deletedEndpoints, providerDeleteTotalUnits.value)
})
const providerDeleteOverallPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress) return 0
if (progress.status === 'completed') return 100
if (providerDeleteTotalUnits.value <= 0) return 0
return Math.min(
100,
Math.round((providerDeleteCompletedUnits.value / providerDeleteTotalUnits.value) * 100),
)
})
const providerDeleteKeysPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress?.totalKeys) return 0
return Math.min(100, Math.round((progress.deletedKeys / progress.totalKeys) * 100))
})
const providerDeleteEndpointsPercent = computed(() => {
const progress = providerDeleteProgress.value
if (!progress?.totalEndpoints) return 0
return Math.min(100, Math.round((progress.deletedEndpoints / progress.totalEndpoints) * 100))
})
// 全局模型数据(用于模型筛选下拉)
const globalModels = ref<{ id: string; name: string }[]>([])
@@ -477,10 +665,32 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
if (!confirmed) return
try {
await deleteProvider(provider.id)
const result = await deleteProvider(provider.id)
providerDeleteProgress.value = {
providerId: provider.id,
providerName: provider.name,
taskId: result.task_id,
status: result.status,
stage: 'queued',
totalKeys: provider.total_keys || 0,
deletedKeys: 0,
totalEndpoints: provider.total_endpoints || 0,
deletedEndpoints: 0,
message: result.message || '删除任务已提交,后台处理中',
}
showInfo(result.message || '删除任务已提交,后台处理中')
const task = await pollProviderDeleteTask(provider.id, result.task_id)
if (!task) return // aborted
if (task.status === 'failed') {
throw new Error(task.message || 'provider delete task failed')
}
showSuccess('提供商已删除')
providerDeleteProgress.value = null
loadProviders()
} catch (err: unknown) {
providerDeleteProgress.value = null
showError(parseApiError(err, '删除提供商失败'), '错误')
}
}
@@ -525,6 +735,7 @@ onMounted(() => {
})
onUnmounted(() => {
deletePollAbort?.abort()
if (debounceTimer) clearTimeout(debounceTimer)
document.removeEventListener('click', handleGlobalClick, true)
stopTick()