refactor: 提取正则工具模块并清理废弃代码

前端:
- 新增 model-mapping-regex.ts 工具模块,统一正则验证和 LRU 缓存
- ModelMappingsTab/RoutingTab 重构为使用 computed 缓存匹配结果
- 移除多个组件中未使用的变量和函数
- 添加 HTMLImageElement/HTMLIFrameElement 到 ESLint 全局类型
- 修复 vitest 需要 --experimental-require-module 的问题

后端:
- 移除废弃的异步数据库支持 (get_async_db, AsyncSession)
- 将 async_utils 从 database/ 迁移到 utils/
- 改进 database.py 类型标注
- 修复 email 模块的 aiosmtplib 可选导入类型问题
This commit is contained in:
fawney19
2026-01-15 17:03:19 +08:00
parent bc16c0eec8
commit a223819dd7
25 changed files with 526 additions and 688 deletions

View File

@@ -338,7 +338,7 @@ onMounted(async () => {
} else {
authType.value = 'local'
}
} catch (error) {
} catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
requireEmailVerification.value = false

View File

@@ -470,7 +470,6 @@ import {
BarChart3
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
@@ -506,7 +505,6 @@ const emit = defineEmits<{
'linkProvider': [providerId: string]
'linkProviders': [providerIds: string[]]
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props {

View File

@@ -4,7 +4,9 @@
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between">
<div class="flex items-baseline gap-2">
<h4 class="text-sm font-semibold">映射规则</h4>
<h4 class="text-sm font-semibold">
映射规则
</h4>
<span class="text-xs text-muted-foreground">
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
</span>
@@ -28,14 +30,20 @@
:disabled="props.loading"
@click="$emit('refresh')"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': props.loading }" />
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': props.loading }"
/>
</Button>
</div>
</div>
</div>
<!-- 规则列表 -->
<div v-if="localMappings.length > 0" class="divide-y">
<div
v-if="localMappings.length > 0"
class="divide-y"
>
<div
v-for="(mapping, index) in localMappings"
:key="index"
@@ -53,29 +61,29 @@
<Input
v-model="localMappings[index]"
placeholder="例如: claude-haiku-.*"
:class="`font-mono text-sm ${mapping.trim() && !getMappingValidation(mapping).valid ? 'border-destructive' : ''}`"
:class="`font-mono text-sm ${normalizedMappings[index] && !mappingValidations[index].valid ? 'border-destructive' : ''}`"
@click.stop
@input="markDirty"
/>
<!-- 验证错误提示 -->
<div
v-if="mapping.trim() && !getMappingValidation(mapping).valid"
v-if="normalizedMappings[index] && !mappingValidations[index].valid"
class="flex items-center gap-1 mt-1 text-xs text-destructive"
>
<AlertCircle class="w-3 h-3" />
<span>{{ getMappingValidation(mapping).error }}</span>
<span>{{ mappingValidations[index].error }}</span>
</div>
</div>
<!-- 匹配统计 -->
<Badge
v-if="getMappingValidation(mapping).valid && getMatchCount(mapping) > 0"
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
variant="secondary"
class="text-xs flex-shrink-0 h-6 leading-none"
>
{{ getMatchCount(mapping) }} 匹配
{{ mappingMatchCounts[index] }} 匹配
</Badge>
<Badge
v-else-if="mapping.trim() && getMappingValidation(mapping).valid"
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
variant="outline"
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
>
@@ -92,8 +100,14 @@
:disabled="saving || hasValidationErrors"
@click.stop="saveMappings"
>
<Save v-if="!saving" class="w-4 h-4" />
<RefreshCw v-else class="w-4 h-4 animate-spin" />
<Save
v-if="!saving"
class="w-4 h-4"
/>
<RefreshCw
v-else
class="w-4 h-4 animate-spin"
/>
</Button>
<Button
variant="ghost"
@@ -113,20 +127,29 @@
v-if="expandedIndex === index"
class="border-t bg-muted/10 px-4 py-3"
>
<div v-if="loadingPreview" class="flex items-center justify-center py-4">
<div
v-if="loadingPreview"
class="flex items-center justify-center py-4"
>
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="getMatchedKeysGroupedByProvider(mapping).length === 0" class="text-center py-4">
<div
v-else-if="expandedGroups.length === 0"
class="text-center py-4"
>
<p class="text-sm text-muted-foreground">
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
</p>
</div>
<div v-else class="space-y-3">
<div
v-else
class="space-y-3"
>
<!-- 按提供商分组 -->
<div
v-for="group in getMatchedKeysGroupedByProvider(mapping)"
v-for="group in expandedGroups"
:key="group.providerId"
class="bg-background rounded-md border overflow-hidden"
>
@@ -204,6 +227,14 @@ import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from
import type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import { log } from '@/utils/logger'
import { useToast } from '@/composables/useToast'
import {
MAX_MAPPINGS_PER_MODEL,
MAX_MODEL_NAME_LENGTH,
createLRURegexCache,
getCompiledModelMappingRegex,
validateModelMappingPattern,
type ValidationResult,
} from '@/features/models/utils/model-mapping-regex'
const props = defineProps<{
globalModelId: string
@@ -217,37 +248,6 @@ const emit = defineEmits<{
linkProvider: [providerId: string]
linkProviders: [providerIds: string[]] // 批量关联
}>()
// 安全限制常量(与后端保持一致)
const MAX_MAPPINGS_PER_MODEL = 50
const MAX_MAPPING_LENGTH = 200
// 危险的正则模式(可能导致 ReDoS与后端 model_permissions.py 保持一致)
// 注意:这些是用于检测用户输入字符串中的危险正则构造
const DANGEROUS_REGEX_PATTERNS = [
/\([^)]*[+*]\)[+*]/, // (x+)+, (x*)*, (x+)*, (x*)+
/\([^)]*\)\{[0-9]+,\}/, // (x){n,} 无上限
/\(\.\*\)\{[0-9]+,\}/, // (.*){n,} 贪婪量词 + 高重复
/\(\.\+\)\{[0-9]+,\}/, // (.+){n,} 贪婪量词 + 高重复
/\([^)]*\|[^)]*\)[+*]/, // (a|b)+ 选择分支 + 量词
/\(\.\*\)\+/, // (.*)+
/\(\.\+\)\+/, // (.+)+
/\([^)]*\*\)[+*]/, // 嵌套量词: (a*)+
/\(\\w\+\)\+/, // (\w+)+ - 检测字面量 \w
/\(\.\*\)\*/, // (.*)*
/\(.*\+.*\)\+/, // (a+b)+ 更通用的嵌套量词检测
/\[.*\]\{[0-9]+,\}\{/, // [x]{n,}{m,} 嵌套量词
/\.{2,}\*/, // ..* 连续通配
/\([^)]*\|[^)]*\)\*/, // (a|a)* 选择分支 + 星号
/\{[0-9]{2,},\}/, // {10,} 高重复次数无上限
/\(\[.*\]\+\)\+/, // ([x]+)+ 字符类嵌套量词
// 补充的危险模式(与后端保持一致)
/\([^)]*[+*]\)\{[0-9]+,/, // (a+){n,} 量词后跟大括号量词
/\(\([^)]*[+*]\)[+*]\)/, // ((a+)+) 三层嵌套量词
/\(\?:[^)]*[+*]\)[+*]/, // (?:a+)+ 非捕获组嵌套量词
]
// 正则匹配安全限制(与后端保持一致)
const REGEX_MATCH_MAX_INPUT_LENGTH = 200
const { success: toastSuccess, error: toastError } = useToast()
@@ -258,54 +258,22 @@ const isDirty = ref(false)
const saving = ref(false)
const expandedIndex = ref<number | null>(null)
// 统一以 trim 后的规则做预览/校验(保存时也会 trim避免前后端行为不一致
const normalizedMappings = computed(() => localMappings.value.map(m => m.trim()))
const mappingValidations = computed<ValidationResult[]>(() => {
return normalizedMappings.value.map(pattern => {
if (!pattern) return { valid: true }
return validateModelMappingPattern(pattern)
})
})
// 匹配预览状态
const loadingPreview = ref(false)
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
// 正则编译缓存(简单的 LRU 实现)
const REGEX_CACHE_MAX_SIZE = 100
class LRURegexCache {
private cache = new Map<string, RegExp | null>()
private maxSize: number
constructor(maxSize: number) {
this.maxSize = maxSize
}
get(key: string): RegExp | null | undefined {
if (!this.cache.has(key)) return undefined
// 移到最后LRU
const value = this.cache.get(key)!
this.cache.delete(key)
this.cache.set(key, value)
return value
}
set(key: string, value: RegExp | null): void {
// 如果已存在,先删除(会重新添加到最后)
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// 缓存已满,删除最早的条目
const firstKey = this.cache.keys().next().value as string | undefined
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
clear(): void {
this.cache.clear()
}
get size(): number {
return this.cache.size
}
}
const regexCache = new LRURegexCache(REGEX_CACHE_MAX_SIZE)
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
const matchCountCache = new Map<string, number>()
interface MatchedKeyForMapping {
keyId: string
@@ -323,109 +291,75 @@ interface ProviderGroup {
isLinked: boolean // 是否已关联到当前模型
}
interface ValidationResult {
valid: boolean
error?: string
}
/**
* 验证映射规则是否安全
*/
function validateMappingPattern(pattern: string): ValidationResult {
if (!pattern || !pattern.trim()) {
return { valid: false, error: '规则不能为空' }
}
if (pattern.length > MAX_MAPPING_LENGTH) {
return { valid: false, error: `规则过长 (最大 ${MAX_MAPPING_LENGTH} 字符)` }
}
// 检查危险模式
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
if (dangerous.test(pattern)) {
return { valid: false, error: '规则包含潜在危险的正则构造' }
}
}
// 尝试编译验证语法
try {
new RegExp(`^${pattern}$`, 'i')
} catch {
return { valid: false, error: `正则表达式语法错误` }
}
return { valid: true }
}
/**
* 获取映射的验证状态
*/
function getMappingValidation(mapping: string): ValidationResult {
if (!mapping.trim()) {
return { valid: true } // 空值暂不报错,保存时过滤
}
return validateMappingPattern(mapping)
}
/**
* 检查是否有验证错误
*/
const hasValidationErrors = computed(() => {
return localMappings.value.some(mapping => {
if (!mapping.trim()) return false
return !validateMappingPattern(mapping).valid
return mappingValidations.value.some((result, index) => {
return normalizedMappings.value[index] !== '' && !result.valid
})
})
/**
* 安全的正则匹配(带缓存和保护)
*/
function matchPattern(pattern: string, text: string): boolean {
// 快速路径:精确匹配
if (pattern.toLowerCase() === text.toLowerCase()) {
return true
function computeMatchCount(pattern: string): number {
if (!routingData.value) return 0
const cached = matchCountCache.get(pattern)
if (cached !== undefined) {
return cached
}
// 长度检查
if (pattern.length > MAX_MAPPING_LENGTH) {
return false
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) {
matchCountCache.set(pattern, 0)
return 0
}
// 危险模式检查
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
if (dangerous.test(pattern)) {
return false
const keyToMatchedModels = new Map<string, Set<string>>()
for (const keyItem of routingData.value.all_keys_whitelist || []) {
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
for (const allowedModel of keyItem.allowed_models) {
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (!regex.test(allowedModel)) continue
let modelSet = keyToMatchedModels.get(keyItem.key_id)
if (!modelSet) {
modelSet = new Set()
keyToMatchedModels.set(keyItem.key_id, modelSet)
}
modelSet.add(allowedModel)
}
}
// 使用 LRU 缓存
let regex = regexCache.get(pattern)
if (regex === undefined) {
try {
regex = new RegExp(`^${pattern}$`, 'i')
regexCache.set(pattern, regex)
} catch {
regexCache.set(pattern, null)
return false
}
}
if (regex === null) {
return false
}
try {
// 额外保护:限制正则匹配的输入长度(与后端保持一致)
const matchInput = text.slice(0, REGEX_MATCH_MAX_INPUT_LENGTH)
return regex.test(matchInput)
} catch {
return false
let total = 0
for (const models of keyToMatchedModels.values()) {
total += models.size
}
matchCountCache.set(pattern, total)
return total
}
const mappingMatchCounts = computed(() => {
if (!routingData.value) {
return normalizedMappings.value.map(() => 0)
}
return normalizedMappings.value.map((pattern, index) => {
if (!pattern) return 0
if (!mappingValidations.value[index]?.valid) return 0
return computeMatchCount(pattern)
})
})
// 获取指定映射匹配的 Key 列表(使用全局 Key 白名单数据做实时匹配)
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
if (!routingData.value || !mapping.trim()) return []
if (!routingData.value) return []
const pattern = mapping.trim()
if (!pattern) return []
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) return []
const keyMap = new Map<string, MatchedKeyForMapping>()
@@ -435,9 +369,8 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
const matchedModels: string[] = []
for (const allowedModel of keyItem.allowed_models) {
if (matchPattern(mapping, allowedModel)) {
matchedModels.push(allowedModel)
}
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
}
if (matchedModels.length > 0) {
@@ -488,15 +421,22 @@ function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
return Array.from(providerMap.values())
}
// 获取指定映射的匹配数量
function getMatchCount(mapping: string): number {
return getMatchedKeysForMapping(mapping).reduce((sum, item) => sum + item.matchedModels.length, 0)
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
const expandedGroups = computed<ProviderGroup[]>(() => {
if (expandedIndex.value === null) return []
const pattern = normalizedMappings.value[expandedIndex.value] || ''
if (!pattern) return []
const validation = mappingValidations.value[expandedIndex.value]
if (validation && !validation.valid) return []
return getMatchedKeysGroupedByProvider(pattern)
})
watch(() => props.mappings, (newAliases) => {
localMappings.value = [...newAliases]
originalMappings.value = [...newAliases]
@@ -530,11 +470,21 @@ async function removeMapping(index: number) {
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
expandedIndex.value--
}
// 删除后自动保存
// 删除后自动保存(仅在当前无校验错误时)
if (hasValidationErrors.value) {
toastError('存在无效映射规则,请修正后再保存')
isDirty.value = true
return
}
await saveMappings()
}
async function saveMappings() {
if (hasValidationErrors.value) {
toastError('存在无效映射规则,无法保存')
return
}
const cleanedMappings = localMappings.value
.map(a => a.trim())
.filter(a => a.length > 0)
@@ -595,6 +545,7 @@ async function saveMappings() {
async function loadMatchPreview() {
// 清空正则缓存,确保使用最新数据
regexCache.clear()
matchCountCache.clear()
loadingPreview.value = true
try {
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
@@ -612,6 +563,7 @@ onMounted(() => {
// 组件卸载时清理缓存,防止内存泄漏
onUnmounted(() => {
regexCache.clear()
matchCountCache.clear()
})
// 暴露刷新方法给父组件

View File

@@ -607,6 +607,7 @@ import { API_FORMAT_ORDER } from '@/api/endpoints/types'
import { recoverKeyHealth } from '@/api/endpoints/health'
import { useToast } from '@/composables/useToast'
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
import { MAX_MODEL_NAME_LENGTH, createLRURegexCache, getCompiledModelMappingRegex } from '@/features/models/utils/model-mapping-regex'
const props = defineProps<{
globalModelId: string
@@ -626,6 +627,9 @@ const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
const loading = ref(false)
const error = ref<string | null>(null)
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
const modelMappingRegexCache = createLRURegexCache(200)
const keyMatchedModelsCache = new Map<string, string[]>()
const compiledGlobalModelMappingRegexes = ref<RegExp[]>([])
// 是否为全局 Key 优先模式
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
@@ -804,11 +808,24 @@ function toggleProviderInFormat(format: string, providerId: string, endpointId?:
async function loadRoutingData() {
if (!props.globalModelId) return
modelMappingRegexCache.clear()
keyMatchedModelsCache.clear()
compiledGlobalModelMappingRegexes.value = []
loading.value = true
error.value = null
try {
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
const data = await getGlobalModelRoutingPreview(props.globalModelId)
const compiled: RegExp[] = []
for (const pattern of data.global_model_mappings || []) {
const regex = getCompiledModelMappingRegex(pattern, modelMappingRegexCache)
if (regex) compiled.push(regex)
}
routingData.value = data
compiledGlobalModelMappingRegexes.value = compiled
} catch (err: any) {
error.value = err.response?.data?.detail || '加载失败'
} finally {
@@ -843,12 +860,24 @@ function hasModelMapping(provider: RoutingProviderInfo): boolean {
// 获取 Key 的 allowed_models 中匹配当前 GlobalModel 的所有模型名
// 逻辑:用 GlobalModel 的 model_mappings正则模式去匹配 Key 的 allowed_models 中的值
function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
const cached = keyMatchedModelsCache.get(key.id)
if (cached !== undefined) {
return cached
}
if (!key.allowed_models || key.allowed_models.length === 0) {
keyMatchedModelsCache.set(key.id, [])
return []
}
const globalModelName = routingData.value?.global_model_name
const globalModelMappings = routingData.value?.global_model_mappings || []
if (!globalModelName) {
keyMatchedModelsCache.set(key.id, [])
return []
}
const patterns = compiledGlobalModelMappingRegexes.value
if (patterns.length === 0) {
keyMatchedModelsCache.set(key.id, [])
return []
}
@@ -859,18 +888,17 @@ function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
if (allowedModel === globalModelName) {
continue
}
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
// 用 GlobalModel 的映射模式匹配白名单中的模型名
for (const pattern of globalModelMappings) {
try {
if (new RegExp(`^${pattern}$`, 'i').test(allowedModel)) {
matched.push(allowedModel)
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
}
} catch {
// 正则无效,跳过
for (const regex of patterns) {
if (regex.test(allowedModel)) {
matched.push(allowedModel)
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
}
}
}
keyMatchedModelsCache.set(key.id, matched)
return matched
}

View File

@@ -2,3 +2,4 @@ export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
export { default as ModelMappingsTab } from './ModelMappingsTab.vue'
export { default as RoutingTab } from './RoutingTab.vue'

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import {
MAX_MAPPING_LENGTH,
MAX_MODEL_NAME_LENGTH,
createLRURegexCache,
safeTestModelMappingPattern,
validateModelMappingPattern,
} from '@/features/models/utils/model-mapping-regex'
describe('model-mapping-regex', () => {
it('validateModelMappingPattern: rejects empty', () => {
expect(validateModelMappingPattern('').valid).toBe(false)
expect(validateModelMappingPattern(' ').valid).toBe(false)
})
it('validateModelMappingPattern: rejects too long', () => {
const tooLong = 'a'.repeat(MAX_MAPPING_LENGTH + 1)
const result = validateModelMappingPattern(tooLong)
expect(result.valid).toBe(false)
})
it('validateModelMappingPattern: rejects potentially dangerous patterns', () => {
const result = validateModelMappingPattern('(a+)+')
expect(result.valid).toBe(false)
})
it('validateModelMappingPattern: accepts basic patterns', () => {
expect(validateModelMappingPattern('claude-haiku-.*').valid).toBe(true)
expect(validateModelMappingPattern('gpt-4o').valid).toBe(true)
})
it('safeTestModelMappingPattern: matches case-insensitively and anchors', () => {
const cache = createLRURegexCache(10)
expect(safeTestModelMappingPattern('gpt-4o', 'GPT-4O', cache)).toBe(true)
expect(safeTestModelMappingPattern('gpt-4o', 'gpt-4o-mini', cache)).toBe(false)
})
it('safeTestModelMappingPattern: rejects overly long model names', () => {
const cache = createLRURegexCache(10)
const longName = 'a'.repeat(MAX_MODEL_NAME_LENGTH + 1)
expect(safeTestModelMappingPattern('a.*', longName, cache)).toBe(false)
})
})

View File

@@ -0,0 +1,150 @@
export const MAX_MAPPINGS_PER_MODEL = 50
export const MAX_MAPPING_LENGTH = 200
export const MAX_MODEL_NAME_LENGTH = 200
export interface ValidationResult {
valid: boolean
error?: string
}
// 危险的正则模式(可能导致 ReDoS
// 注意:后端使用 regex 库的 timeout 做强制保护;前端无法中断 JS 正则执行,只能做启发式拦截。
const DANGEROUS_REGEX_PATTERNS: RegExp[] = [
/\([^)]*[+*]\)[+*]/, // (x+)+, (x*)*, (x+)*, (x*)+
/\([^)]*\)\{[0-9]+,\}/, // (x){n,} 无上限
/\(\.\*\)\{[0-9]+,\}/, // (.*){n,} 贪婪量词 + 高重复
/\(\.\+\)\{[0-9]+,\}/, // (.+){n,} 贪婪量词 + 高重复
/\([^)]*\|[^)]*\)[+*]/, // (a|b)+ 选择分支 + 量词
/\(\.\*\)\+/, // (.*)+
/\(\.\+\)\+/, // (.+)+
/\([^)]*\*\)[+*]/, // 嵌套量词: (a*)+
/\(\\w\+\)\+/, // (\w+)+ - 检测字面量 \w
/\(\.\*\)\*/, // (.*)*
/\(.*\+.*\)\+/, // (a+b)+ 更通用的嵌套量词检测
/\[.*\]\{[0-9]+,\}\{/, // [x]{n,}{m,} 嵌套量词
/\.{2,}\*/, // ..* 连续通配
/\([^)]*\|[^)]*\)\*/, // (a|a)* 选择分支 + 星号
/\{[0-9]{2,},\}/, // {10,} 高重复次数无上限
/\(\[.*\]\+\)\+/, // ([x]+)+ 字符类嵌套量词
// 补充的危险模式
/\([^)]*[+*]\)\{[0-9]+,/, // (a+){n,} 量词后跟大括号量词
/\(\([^)]*[+*]\)[+*]\)/, // ((a+)+) 三层嵌套量词
/\(\?:[^)]*[+*]\)[+*]/, // (?:a+)+ 非捕获组嵌套量词
]
function isPotentiallyDangerousRegex(pattern: string): boolean {
return DANGEROUS_REGEX_PATTERNS.some(re => re.test(pattern))
}
export interface LRURegexCache {
get: (key: string) => RegExp | null | undefined
set: (key: string, value: RegExp | null) => void
clear: () => void
}
export function createLRURegexCache(maxSize: number): LRURegexCache {
const cache = new Map<string, RegExp | null>()
return {
get: (key: string) => {
if (!cache.has(key)) return undefined
const value = cache.get(key)!
cache.delete(key)
cache.set(key, value)
return value
},
set: (key: string, value: RegExp | null) => {
if (cache.has(key)) {
cache.delete(key)
} else if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value as string | undefined
if (firstKey !== undefined) {
cache.delete(firstKey)
}
}
cache.set(key, value)
},
clear: () => {
cache.clear()
},
}
}
export function validateModelMappingPattern(pattern: string): ValidationResult {
if (!pattern || !pattern.trim()) {
return { valid: false, error: '规则不能为空' }
}
if (pattern.length > MAX_MAPPING_LENGTH) {
return { valid: false, error: `规则过长 (最大 ${MAX_MAPPING_LENGTH} 字符)` }
}
if (isPotentiallyDangerousRegex(pattern)) {
return { valid: false, error: '规则包含潜在危险的正则构造' }
}
try {
new RegExp(`^${pattern}$`, 'i')
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return { valid: false, error: `正则表达式语法错误: ${message}` }
}
return { valid: true }
}
export function getCompiledModelMappingRegex(
pattern: string,
cache: LRURegexCache,
): RegExp | null {
const normalized = pattern.trim()
if (!normalized) return null
if (normalized.length > MAX_MAPPING_LENGTH) {
return null
}
if (isPotentiallyDangerousRegex(normalized)) {
return null
}
let regex = cache.get(normalized)
if (regex === undefined) {
try {
regex = new RegExp(`^${normalized}$`, 'i')
cache.set(normalized, regex)
} catch {
cache.set(normalized, null)
return null
}
}
return regex
}
export function safeTestModelMappingPattern(
pattern: string,
modelName: string,
cache: LRURegexCache,
): boolean {
if (!pattern) return false
if (pattern.toLowerCase() === modelName.toLowerCase()) {
return true
}
if (pattern.length > MAX_MAPPING_LENGTH || modelName.length > MAX_MODEL_NAME_LENGTH) {
return false
}
const regex = getCompiledModelMappingRegex(pattern, cache)
if (regex === null) {
return false
}
try {
return regex.test(modelName)
} catch {
return false
}
}

View File

@@ -116,8 +116,14 @@
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
<Lock
v-if="isLocked(model)"
class="w-3.5 h-3.5"
/>
<LockOpen
v-else
class="w-3.5 h-3.5"
/>
</button>
</div>
</div>
@@ -181,8 +187,14 @@
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model.name, $event)"
>
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
<Lock
v-if="isLocked(model.name)"
class="w-3.5 h-3.5"
/>
<LockOpen
v-else
class="w-3.5 h-3.5"
/>
</button>
</div>
</div>
@@ -243,8 +255,14 @@
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
<Lock
v-if="isLocked(model)"
class="w-3.5 h-3.5"
/>
<LockOpen
v-else
class="w-3.5 h-3.5"
/>
</button>
</div>
</div>
@@ -600,7 +618,7 @@ async function loadGlobalModels() {
name: m.name,
display_name: m.display_name
}))
} catch (err) {
} catch {
if (loadingCancelled) return
showError('加载全局模型失败', '错误')
} finally {

View File

@@ -256,7 +256,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'

View File

@@ -259,10 +259,7 @@ function handleDrop(targetIndex: number) {
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1
// 找到被拖动项在原数组中的索引对应的原始优先级
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
items.forEach((alias, newIdx) => {
items.forEach(alias => {
// 找到这个映射在原数组中的索引
const originalIdx = aliases.value.findIndex(a => a === alias)
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority

View File

@@ -416,20 +416,6 @@
@endpoint-updated="handleEndpointChanged"
/>
<!-- 删除端点确认对话框 -->
<AlertDialog
v-if="open"
:model-value="deleteEndpointConfirmOpen"
title="删除端点"
:description="`确定要删除端点 ${endpointToDelete?.api_format} 吗?这将同时删除其所有密钥。`"
confirm-text="删除"
cancel-text="取消"
type="danger"
@update:model-value="deleteEndpointConfirmOpen = $event"
@confirm="confirmDeleteEndpoint"
@cancel="deleteEndpointConfirmOpen = false"
/>
<!-- 密钥编辑对话框 -->
<KeyFormDialog
v-if="open"
@@ -535,11 +521,9 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import AlertDialog from '@/components/common/AlertDialog.vue'
import {
deleteEndpoint as deleteEndpointAPI,
deleteEndpointKey,
recoverKeyHealth,
getProviderKeys,
updateEndpoint,
updateProviderKey,
revealEndpointKey,
type ProviderEndpoint,
@@ -579,12 +563,9 @@ const loading = ref(false)
const provider = ref<any>(null)
const endpoints = ref<ProviderEndpointWithKeys[]>([])
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
const expandedEndpoints = ref<Set<string>>(new Set())
// 端点相关状态
const endpointDialogOpen = ref(false)
const deleteEndpointConfirmOpen = ref(false)
const endpointToDelete = ref<ProviderEndpoint | null>(null)
// 密钥相关状态
const keyFormDialogOpen = ref(false)
@@ -593,13 +574,10 @@ const currentEndpoint = ref<ProviderEndpoint | null>(null)
const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false)
const keyToDelete = ref<EndpointAPIKey | null>(null)
const recoveringEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
const revealingKeyId = ref<string | null>(null)
// 模型相关状态
const modelFormDialogOpen = ref(false)
@@ -609,14 +587,6 @@ const modelToDelete = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false)
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
const dragState = ref({
isDragging: false,
draggedKeyId: null as string | null,
targetKeyId: null as string | null,
dragEndpointId: null as string | null
})
// 密钥列表拖拽排序状态
const keyDragState = ref({
isDragging: false,
@@ -640,7 +610,6 @@ const multiplierSaving = ref(false)
// 任意模态窗口打开时,阻止抽屉被误关闭
const hasBlockingDialogOpen = computed(() =>
endpointDialogOpen.value ||
deleteEndpointConfirmOpen.value ||
keyFormDialogOpen.value ||
keyPermissionsDialogOpen.value ||
deleteKeyConfirmOpen.value ||
@@ -702,18 +671,15 @@ watch(() => props.open, (newOpen) => {
provider.value = null
endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys
expandedEndpoints.value.clear()
// 重置所有对话框状态
endpointDialogOpen.value = false
deleteEndpointConfirmOpen.value = false
keyFormDialogOpen.value = false
keyPermissionsDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
// 重置临时数据
endpointToDelete.value = null
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
@@ -737,15 +703,6 @@ function handleClose() {
}
}
// 切换端点展开/收起
function toggleEndpoint(endpointId: string) {
if (expandedEndpoints.value.has(endpointId)) {
expandedEndpoints.value.delete(endpointId)
} else {
expandedEndpoints.value.add(endpointId)
}
}
// 显示端点管理对话框
function showAddEndpointDialog() {
endpointDialogOpen.value = true
@@ -757,27 +714,6 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
endpointDialogOpen.value = true
}
function handleDeleteEndpoint(endpoint: ProviderEndpoint) {
endpointToDelete.value = endpoint
deleteEndpointConfirmOpen.value = true
}
async function confirmDeleteEndpoint() {
if (!endpointToDelete.value) return
try {
await deleteEndpointAPI(endpointToDelete.value.id)
showSuccess('端点已删除')
await loadEndpoints()
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '删除失败', '错误')
} finally {
deleteEndpointConfirmOpen.value = false
endpointToDelete.value = null
}
}
async function handleEndpointChanged() {
await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
@@ -808,37 +744,18 @@ function handleKeyPermissions(key: EndpointAPIKey) {
keyPermissionsDialogOpen.value = true
}
// 切换密钥显示/隐藏
async function toggleKeyReveal(key: EndpointAPIKey) {
if (revealedKeys.value.has(key.id)) {
// 已显示,隐藏它
revealedKeys.value.delete(key.id)
return
}
// 未显示,调用 API 获取完整密钥
revealingKeyId.value = key.id
try {
const result = await revealEndpointKey(key.id)
revealedKeys.value.set(key.id, result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
} finally {
revealingKeyId.value = null
}
}
// 复制完整密钥
async function copyFullKey(key: EndpointAPIKey) {
// 如果已经显示了,直接复制
if (revealedKeys.value.has(key.id)) {
copyToClipboard(revealedKeys.value.get(key.id)!)
const cached = revealedKeys.value.get(key.id)
if (cached) {
copyToClipboard(cached)
return
}
// 否则先获取再复制
try {
const result = await revealEndpointKey(key.id)
revealedKeys.value.set(key.id, result.api_key)
copyToClipboard(result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
@@ -878,79 +795,11 @@ async function handleRecoverKey(key: EndpointAPIKey) {
}
}
// 检查端点是否有不健康的密钥
function hasUnhealthyKeys(endpoint: ProviderEndpointWithKeys): boolean {
if (!endpoint.keys || endpoint.keys.length === 0) return false
return endpoint.keys.some(key =>
key.circuit_breaker_open ||
(key.health_score !== undefined && key.health_score < 1)
)
}
// 批量恢复端点下所有密钥的健康状态
async function handleRecoverAllKeys(endpoint: ProviderEndpointWithKeys) {
if (!endpoint.keys || endpoint.keys.length === 0) return
const keysToRecover = endpoint.keys.filter(key =>
key.circuit_breaker_open ||
(key.health_score !== undefined && key.health_score < 1)
)
if (keysToRecover.length === 0) {
showSuccess('所有密钥已处于健康状态')
return
}
recoveringEndpointId.value = endpoint.id
let successCount = 0
let failCount = 0
try {
for (const key of keysToRecover) {
try {
await recoverKeyHealth(key.id)
successCount++
} catch {
failCount++
}
}
if (failCount === 0) {
showSuccess(`已恢复 ${successCount} 个密钥的健康状态`)
} else {
showSuccess(`恢复完成: ${successCount} 成功, ${failCount} 失败`)
}
await loadEndpoints()
emit('refresh')
} finally {
recoveringEndpointId.value = null
}
}
async function handleKeyChanged() {
await loadEndpoints()
emit('refresh')
}
// 切换端点启用状态
async function toggleEndpointActive(endpoint: ProviderEndpointWithKeys) {
if (togglingEndpointId.value) return
togglingEndpointId.value = endpoint.id
try {
const newStatus = !endpoint.is_active
await updateEndpoint(endpoint.id, { is_active: newStatus })
endpoint.is_active = newStatus
showSuccess(newStatus ? '端点已启用' : '端点已停用')
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
} finally {
togglingEndpointId.value = null
}
}
// 切换密钥启用状态
async function toggleKeyActive(key: EndpointAPIKey) {
if (togglingKeyId.value) return
@@ -1022,123 +871,6 @@ async function confirmDeleteModel() {
}
}
// ===== 拖动排序处理 =====
function handleDragStart(event: DragEvent, key: EndpointAPIKey, endpoint: ProviderEndpointWithKeys) {
dragState.value.isDragging = true
dragState.value.draggedKeyId = key.id
dragState.value.dragEndpointId = endpoint.id
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleDragEnd() {
dragState.value.isDragging = false
dragState.value.draggedKeyId = null
dragState.value.targetKeyId = null
dragState.value.dragEndpointId = null
}
function handleDragOver(event: DragEvent, targetKey: EndpointAPIKey) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
if (dragState.value.draggedKeyId !== targetKey.id) {
dragState.value.targetKeyId = targetKey.id
}
}
function handleDragLeave() {
dragState.value.targetKeyId = null
}
async function handleDrop(event: DragEvent, targetKey: EndpointAPIKey, endpoint: ProviderEndpointWithKeys) {
event.preventDefault()
const draggedKeyId = dragState.value.draggedKeyId
if (!draggedKeyId || !endpoint.keys || draggedKeyId === targetKey.id) {
handleDragEnd()
return
}
// 只允许在同一端点内拖动
if (dragState.value.dragEndpointId !== endpoint.id) {
showError('不能跨端点拖动密钥')
handleDragEnd()
return
}
const keys = [...endpoint.keys]
const draggedIndex = keys.findIndex(k => k.id === draggedKeyId)
const targetIndex = keys.findIndex(k => k.id === targetKey.id)
if (draggedIndex === -1 || targetIndex === -1) {
handleDragEnd()
return
}
// 记录原始优先级分组(排除被拖动的密钥)
// key: 原始优先级值, value: 密钥ID数组
const originalGroups = new Map<number, string[]>()
for (const key of keys) {
if (key.id === draggedKeyId) continue // 被拖动的密钥离开原组
const priority = key.internal_priority ?? 0
if (!originalGroups.has(priority)) {
originalGroups.set(priority, [])
}
originalGroups.get(priority)!.push(key.id)
}
// 重排数组
const [removed] = keys.splice(draggedIndex, 1)
keys.splice(targetIndex, 0, removed)
endpoint.keys = keys
// 按新顺序为每个组分配新的优先级
// 同组的密钥保持相同的优先级
const priorities: { key_id: string; internal_priority: number }[] = []
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 0
for (const key of keys) {
if (key.id === draggedKeyId) {
// 被拖动的密钥是独立的新组,获得当前优先级
priorities.push({ key_id: key.id, internal_priority: currentPriority })
currentPriority++
} else {
const originalPriority = key.internal_priority ?? 0
if (groupNewPriority.has(originalPriority)) {
// 这个组已经分配过优先级,使用相同的值
priorities.push({ key_id: key.id, internal_priority: groupNewPriority.get(originalPriority)! })
} else {
// 这个组第一次出现,分配新优先级
groupNewPriority.set(originalPriority, currentPriority)
priorities.push({ key_id: key.id, internal_priority: currentPriority })
currentPriority++
}
}
}
handleDragEnd()
// 调用 API 批量更新(使用循环调用 updateProviderKey 替代已废弃的 batchUpdateKeyPriority
try {
await Promise.all(
priorities.map(p => updateProviderKey(p.key_id, { internal_priority: p.internal_priority }))
)
showSuccess('优先级已更新')
// 重新加载以获取更新后的数据
await loadEndpoints()
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '更新优先级失败', '错误')
// 回滚 - 重新加载
await loadEndpoints()
}
}
// ===== 点击编辑优先级 =====
function startEditPriority(key: EndpointAPIKey) {
editingPriorityKey.value = key.id
@@ -1386,25 +1118,6 @@ async function handleKeyDrop(event: DragEvent, targetIndex: number) {
}
}
// 格式化探测时间
function formatProbeTime(probeTime: string): string {
if (!probeTime) return '-'
const now = new Date()
const probe = new Date(probeTime)
const diffMs = probe.getTime() - now.getTime()
if (diffMs < 0) return '待探测'
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) return `${diffDays}天后`
if (diffHours > 0) return `${diffHours}小时后`
if (diffMinutes > 0) return `${diffMinutes}分钟后`
return '即将探测'
}
// 获取密钥的 API 格式列表(按指定顺序排序)
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
let formats: string[] = []
@@ -1491,7 +1204,7 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
const now = new Date()
const diffMs = nextProbe.getTime() - now.getTime()
if (diffMs > 0) {
return ' ' + formatCountdown(diffMs)
return ` ${formatCountdown(diffMs)}`
} else {
return ' 探测中'
}

View File

@@ -129,8 +129,14 @@
:disabled="testingModelId === model.id"
@click="handleTestClick(model)"
>
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
<Loader2
v-if="testingModelId === model.id"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button>
<!-- 格式选择下拉菜单 -->
<div