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

@@ -852,9 +852,9 @@ function editApiKey(apiKey: AdminApiKey) {
expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers || [],
allowed_api_formats: apiKey.allowed_api_formats || [],
allowed_models: apiKey.allowed_models || []
allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers],
allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats],
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models]
}
showKeyFormDialog.value = true

View File

@@ -493,28 +493,13 @@
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
:title="getOAuthOrgBadge(key)?.id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
</TableCell>
@@ -818,28 +803,13 @@
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
:title="getOAuthOrgBadge(key)?.id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 flex-wrap justify-end max-w-[210px]">
@@ -1225,7 +1195,7 @@ import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDial
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
const { success, error: showError, warning: showWarning } = useToast()
const { confirm } = useConfirm()

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()

View File

@@ -59,6 +59,7 @@
:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd"
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
:enable-registration="systemConfig.enable_registration"
:password-policy-level="systemConfig.password_policy_level"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:loading="basicConfigLoading"
@@ -67,6 +68,7 @@
@update:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd = $event"
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
@update:enable-registration="systemConfig.enable_registration = $event"
@update:password-policy-level="systemConfig.password_policy_level = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
/>

View File

@@ -1069,9 +1069,9 @@ function editUser(user: User) {
unlimited: user.unlimited,
role: user.role,
is_active: user.is_active,
allowed_providers: [...(user.allowed_providers || [])],
allowed_api_formats: [...(user.allowed_api_formats || [])],
allowed_models: [...(user.allowed_models || [])]
allowed_providers: user.allowed_providers == null ? null : [...user.allowed_providers],
allowed_api_formats: user.allowed_api_formats == null ? null : [...user.allowed_api_formats],
allowed_models: user.allowed_models == null ? null : [...user.allowed_models]
}
showUserFormDialog.value = true
}

View File

@@ -54,6 +54,40 @@
</p>
</div>
<div>
<Label
for="password-policy-level"
class="block text-sm font-medium mb-2"
>
密码策略
</Label>
<Select
:model-value="passwordPolicyLevel"
@update:model-value="$emit('update:passwordPolicyLevel', $event)"
>
<SelectTrigger
id="password-policy-level"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weak">
弱密码 - 至少 6 个字符
</SelectItem>
<SelectItem value="medium">
中等密码 - 至少 8 含字母和数字
</SelectItem>
<SelectItem value="strong">
强密码 - 至少 8 含大小写字母数字和特殊字符
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
影响注册创建用户重置/修改密码的校验规则
</p>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
@@ -125,12 +159,18 @@ import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { CardSection } from '@/components/layout'
defineProps<{
defaultUserInitialGiftUsd: number
rateLimitPerMinute: number
enableRegistration: boolean
passwordPolicyLevel: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
loading: boolean
@@ -142,6 +182,7 @@ defineEmits<{
'update:defaultUserInitialGiftUsd': [value: number]
'update:rateLimitPerMinute': [value: number]
'update:enableRegistration': [value: boolean]
'update:passwordPolicyLevel': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
}>()

View File

@@ -14,6 +14,7 @@ export interface SystemConfig {
default_user_initial_gift_usd: number
rate_limit_per_minute: number
enable_registration: boolean
password_policy_level: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -47,6 +48,7 @@ const CONFIG_KEYS = [
'default_user_initial_gift_usd',
'rate_limit_per_minute',
'enable_registration',
'password_policy_level',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -81,6 +83,7 @@ function createDefaultConfig(): SystemConfig {
default_user_initial_gift_usd: 10.0,
rate_limit_per_minute: 0,
enable_registration: false,
password_policy_level: 'weak',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -140,6 +143,7 @@ export function useSystemConfig() {
systemConfig.value.default_user_initial_gift_usd !== originalConfig.value.default_user_initial_gift_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.password_policy_level !== originalConfig.value.password_policy_level ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
)
@@ -296,6 +300,11 @@ export function useSystemConfig() {
value: systemConfig.value.enable_registration,
description: '是否开放用户注册',
},
{
key: 'password_policy_level',
value: systemConfig.value.password_policy_level,
description: '密码策略等级',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -317,6 +326,7 @@ export function useSystemConfig() {
originalConfig.value.default_user_initial_gift_usd = systemConfig.value.default_user_initial_gift_usd
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.password_policy_level = systemConfig.value.password_policy_level
originalConfig.value.auto_delete_expired_keys =
systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion =

View File

@@ -119,6 +119,18 @@
type="password"
class="mt-1"
/>
<p
v-if="passwordError"
class="mt-1 text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else
class="mt-1 text-xs text-muted-foreground"
>
{{ passwordPolicyHint }}
</p>
</div>
<div>
<Label for="confirm-password">确认{{ profile?.has_password ? '新' : '' }}密码</Label>
@@ -465,6 +477,12 @@ import { authApi } from '@/api/auth'
import { oauthApi, type OAuthLinkInfo, type OAuthProviderInfo } from '@/api/oauth'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
import {
getPasswordPolicyHint,
normalizePasswordPolicyLevel,
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
@@ -516,6 +534,7 @@ const preferencesForm = ref({
const savingProfile = ref(false)
const changingPassword = ref(false)
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const themeSelectOpen = ref(false)
const languageSelectOpen = ref(false)
@@ -539,6 +558,11 @@ const hasProfileChanges = computed(() => {
)
})
const passwordPolicyHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value))
const passwordError = computed(() =>
validatePasswordByPolicy(passwordForm.value.new_password, passwordPolicyLevel.value)
)
// 检测密码表单是否有内容
const hasPasswordChanges = computed(() => {
const hasPassword = profile.value?.has_password
@@ -577,8 +601,10 @@ async function loadEmailConfigured() {
try {
const settings = await authApi.getRegistrationSettings()
emailConfigured.value = !!settings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(settings.password_policy_level)
} catch {
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
}
}
@@ -766,8 +792,8 @@ async function changePassword() {
return
}
if (passwordForm.value.new_password.length < 6) {
showError('密码长度至少6位', '密码错误')
if (passwordError.value) {
showError(passwordError.value, '密码错误')
return
}