mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -41,6 +41,8 @@ export default [
|
|||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
HTMLInputElement: 'readonly',
|
HTMLInputElement: 'readonly',
|
||||||
HTMLSelectElement: 'readonly',
|
HTMLSelectElement: 'readonly',
|
||||||
|
HTMLImageElement: 'readonly',
|
||||||
|
HTMLIFrameElement: 'readonly',
|
||||||
MouseEvent: 'readonly',
|
MouseEvent: 'readonly',
|
||||||
KeyboardEvent: 'readonly',
|
KeyboardEvent: 'readonly',
|
||||||
Event: 'readonly',
|
Event: 'readonly',
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest run",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"version": "git describe --tags --always"
|
"version": "git describe --tags --always"
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ onMounted(async () => {
|
|||||||
} else {
|
} else {
|
||||||
authType.value = 'local'
|
authType.value = 'local'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
||||||
allowRegistration.value = false
|
allowRegistration.value = false
|
||||||
requireEmailVerification.value = false
|
requireEmailVerification.value = false
|
||||||
|
|||||||
@@ -470,7 +470,6 @@ import {
|
|||||||
BarChart3
|
BarChart3
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
@@ -506,7 +505,6 @@ const emit = defineEmits<{
|
|||||||
'linkProvider': [providerId: string]
|
'linkProvider': [providerId: string]
|
||||||
'linkProviders': [providerIds: string[]]
|
'linkProviders': [providerIds: string[]]
|
||||||
}>()
|
}>()
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<div class="px-4 py-3 border-b border-border/60">
|
<div class="px-4 py-3 border-b border-border/60">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-baseline gap-2">
|
<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">
|
<span class="text-xs text-muted-foreground">
|
||||||
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
|
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
|
||||||
</span>
|
</span>
|
||||||
@@ -28,14 +30,20 @@
|
|||||||
:disabled="props.loading"
|
:disabled="props.loading"
|
||||||
@click="$emit('refresh')"
|
@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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 规则列表 -->
|
<!-- 规则列表 -->
|
||||||
<div v-if="localMappings.length > 0" class="divide-y">
|
<div
|
||||||
|
v-if="localMappings.length > 0"
|
||||||
|
class="divide-y"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(mapping, index) in localMappings"
|
v-for="(mapping, index) in localMappings"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -53,29 +61,29 @@
|
|||||||
<Input
|
<Input
|
||||||
v-model="localMappings[index]"
|
v-model="localMappings[index]"
|
||||||
placeholder="例如: claude-haiku-.*"
|
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
|
@click.stop
|
||||||
@input="markDirty"
|
@input="markDirty"
|
||||||
/>
|
/>
|
||||||
<!-- 验证错误提示 -->
|
<!-- 验证错误提示 -->
|
||||||
<div
|
<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"
|
class="flex items-center gap-1 mt-1 text-xs text-destructive"
|
||||||
>
|
>
|
||||||
<AlertCircle class="w-3 h-3" />
|
<AlertCircle class="w-3 h-3" />
|
||||||
<span>{{ getMappingValidation(mapping).error }}</span>
|
<span>{{ mappingValidations[index].error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 匹配统计 -->
|
<!-- 匹配统计 -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="getMappingValidation(mapping).valid && getMatchCount(mapping) > 0"
|
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-xs flex-shrink-0 h-6 leading-none"
|
class="text-xs flex-shrink-0 h-6 leading-none"
|
||||||
>
|
>
|
||||||
{{ getMatchCount(mapping) }} 匹配
|
{{ mappingMatchCounts[index] }} 匹配
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="mapping.trim() && getMappingValidation(mapping).valid"
|
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
|
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
|
||||||
>
|
>
|
||||||
@@ -92,8 +100,14 @@
|
|||||||
:disabled="saving || hasValidationErrors"
|
:disabled="saving || hasValidationErrors"
|
||||||
@click.stop="saveMappings"
|
@click.stop="saveMappings"
|
||||||
>
|
>
|
||||||
<Save v-if="!saving" class="w-4 h-4" />
|
<Save
|
||||||
<RefreshCw v-else class="w-4 h-4 animate-spin" />
|
v-if="!saving"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<RefreshCw
|
||||||
|
v-else
|
||||||
|
class="w-4 h-4 animate-spin"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -113,20 +127,29 @@
|
|||||||
v-if="expandedIndex === index"
|
v-if="expandedIndex === index"
|
||||||
class="border-t bg-muted/10 px-4 py-3"
|
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" />
|
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</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">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
<!-- 按提供商分组 -->
|
<!-- 按提供商分组 -->
|
||||||
<div
|
<div
|
||||||
v-for="group in getMatchedKeysGroupedByProvider(mapping)"
|
v-for="group in expandedGroups"
|
||||||
:key="group.providerId"
|
:key="group.providerId"
|
||||||
class="bg-background rounded-md border overflow-hidden"
|
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 type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import { useToast } from '@/composables/useToast'
|
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<{
|
const props = defineProps<{
|
||||||
globalModelId: string
|
globalModelId: string
|
||||||
@@ -217,37 +248,6 @@ const emit = defineEmits<{
|
|||||||
linkProvider: [providerId: string]
|
linkProvider: [providerId: string]
|
||||||
linkProviders: [providerIds: 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()
|
const { success: toastSuccess, error: toastError } = useToast()
|
||||||
|
|
||||||
@@ -258,54 +258,22 @@ const isDirty = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const expandedIndex = ref<number | null>(null)
|
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 loadingPreview = ref(false)
|
||||||
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
||||||
|
|
||||||
// 正则编译缓存(简单的 LRU 实现)
|
|
||||||
const REGEX_CACHE_MAX_SIZE = 100
|
const REGEX_CACHE_MAX_SIZE = 100
|
||||||
|
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
|
||||||
class LRURegexCache {
|
const matchCountCache = new Map<string, number>()
|
||||||
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)
|
|
||||||
|
|
||||||
interface MatchedKeyForMapping {
|
interface MatchedKeyForMapping {
|
||||||
keyId: string
|
keyId: string
|
||||||
@@ -323,109 +291,75 @@ interface ProviderGroup {
|
|||||||
isLinked: boolean // 是否已关联到当前模型
|
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(() => {
|
const hasValidationErrors = computed(() => {
|
||||||
return localMappings.value.some(mapping => {
|
return mappingValidations.value.some((result, index) => {
|
||||||
if (!mapping.trim()) return false
|
return normalizedMappings.value[index] !== '' && !result.valid
|
||||||
return !validateMappingPattern(mapping).valid
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
function computeMatchCount(pattern: string): number {
|
||||||
* 安全的正则匹配(带缓存和保护)
|
if (!routingData.value) return 0
|
||||||
*/
|
|
||||||
function matchPattern(pattern: string, text: string): boolean {
|
const cached = matchCountCache.get(pattern)
|
||||||
// 快速路径:精确匹配
|
if (cached !== undefined) {
|
||||||
if (pattern.toLowerCase() === text.toLowerCase()) {
|
return cached
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 长度检查
|
const regex = getCompiledModelMappingRegex(pattern, regexCache)
|
||||||
if (pattern.length > MAX_MAPPING_LENGTH) {
|
if (!regex) {
|
||||||
return false
|
matchCountCache.set(pattern, 0)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 危险模式检查
|
const keyToMatchedModels = new Map<string, Set<string>>()
|
||||||
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
|
|
||||||
if (dangerous.test(pattern)) {
|
for (const keyItem of routingData.value.all_keys_whitelist || []) {
|
||||||
return false
|
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 total = 0
|
||||||
let regex = regexCache.get(pattern)
|
for (const models of keyToMatchedModels.values()) {
|
||||||
if (regex === undefined) {
|
total += models.size
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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 白名单数据做实时匹配)
|
// 获取指定映射匹配的 Key 列表(使用全局 Key 白名单数据做实时匹配)
|
||||||
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
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>()
|
const keyMap = new Map<string, MatchedKeyForMapping>()
|
||||||
|
|
||||||
@@ -435,9 +369,8 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
|||||||
|
|
||||||
const matchedModels: string[] = []
|
const matchedModels: string[] = []
|
||||||
for (const allowedModel of keyItem.allowed_models) {
|
for (const allowedModel of keyItem.allowed_models) {
|
||||||
if (matchPattern(mapping, allowedModel)) {
|
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
||||||
matchedModels.push(allowedModel)
|
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedModels.length > 0) {
|
if (matchedModels.length > 0) {
|
||||||
@@ -488,15 +421,22 @@ function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
|
|||||||
return Array.from(providerMap.values())
|
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) {
|
function toggleExpand(index: number) {
|
||||||
expandedIndex.value = expandedIndex.value === index ? null : index
|
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) => {
|
watch(() => props.mappings, (newAliases) => {
|
||||||
localMappings.value = [...newAliases]
|
localMappings.value = [...newAliases]
|
||||||
originalMappings.value = [...newAliases]
|
originalMappings.value = [...newAliases]
|
||||||
@@ -530,11 +470,21 @@ async function removeMapping(index: number) {
|
|||||||
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
|
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
|
||||||
expandedIndex.value--
|
expandedIndex.value--
|
||||||
}
|
}
|
||||||
// 删除后自动保存
|
// 删除后自动保存(仅在当前无校验错误时)
|
||||||
|
if (hasValidationErrors.value) {
|
||||||
|
toastError('存在无效映射规则,请修正后再保存')
|
||||||
|
isDirty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
await saveMappings()
|
await saveMappings()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveMappings() {
|
async function saveMappings() {
|
||||||
|
if (hasValidationErrors.value) {
|
||||||
|
toastError('存在无效映射规则,无法保存')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const cleanedMappings = localMappings.value
|
const cleanedMappings = localMappings.value
|
||||||
.map(a => a.trim())
|
.map(a => a.trim())
|
||||||
.filter(a => a.length > 0)
|
.filter(a => a.length > 0)
|
||||||
@@ -595,6 +545,7 @@ async function saveMappings() {
|
|||||||
async function loadMatchPreview() {
|
async function loadMatchPreview() {
|
||||||
// 清空正则缓存,确保使用最新数据
|
// 清空正则缓存,确保使用最新数据
|
||||||
regexCache.clear()
|
regexCache.clear()
|
||||||
|
matchCountCache.clear()
|
||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
try {
|
try {
|
||||||
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
||||||
@@ -612,6 +563,7 @@ onMounted(() => {
|
|||||||
// 组件卸载时清理缓存,防止内存泄漏
|
// 组件卸载时清理缓存,防止内存泄漏
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
regexCache.clear()
|
regexCache.clear()
|
||||||
|
matchCountCache.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露刷新方法给父组件
|
// 暴露刷新方法给父组件
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ import { API_FORMAT_ORDER } from '@/api/endpoints/types'
|
|||||||
import { recoverKeyHealth } from '@/api/endpoints/health'
|
import { recoverKeyHealth } from '@/api/endpoints/health'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
|
||||||
|
import { MAX_MODEL_NAME_LENGTH, createLRURegexCache, getCompiledModelMappingRegex } from '@/features/models/utils/model-mapping-regex'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
globalModelId: string
|
globalModelId: string
|
||||||
@@ -626,6 +627,9 @@ const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
||||||
|
const modelMappingRegexCache = createLRURegexCache(200)
|
||||||
|
const keyMatchedModelsCache = new Map<string, string[]>()
|
||||||
|
const compiledGlobalModelMappingRegexes = ref<RegExp[]>([])
|
||||||
|
|
||||||
// 是否为全局 Key 优先模式
|
// 是否为全局 Key 优先模式
|
||||||
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
|
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
|
||||||
@@ -804,11 +808,24 @@ function toggleProviderInFormat(format: string, providerId: string, endpointId?:
|
|||||||
async function loadRoutingData() {
|
async function loadRoutingData() {
|
||||||
if (!props.globalModelId) return
|
if (!props.globalModelId) return
|
||||||
|
|
||||||
|
modelMappingRegexCache.clear()
|
||||||
|
keyMatchedModelsCache.clear()
|
||||||
|
compiledGlobalModelMappingRegexes.value = []
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '加载失败'
|
error.value = err.response?.data?.detail || '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -843,12 +860,24 @@ function hasModelMapping(provider: RoutingProviderInfo): boolean {
|
|||||||
// 获取 Key 的 allowed_models 中匹配当前 GlobalModel 的所有模型名
|
// 获取 Key 的 allowed_models 中匹配当前 GlobalModel 的所有模型名
|
||||||
// 逻辑:用 GlobalModel 的 model_mappings(正则模式)去匹配 Key 的 allowed_models 中的值
|
// 逻辑:用 GlobalModel 的 model_mappings(正则模式)去匹配 Key 的 allowed_models 中的值
|
||||||
function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
|
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) {
|
if (!key.allowed_models || key.allowed_models.length === 0) {
|
||||||
|
keyMatchedModelsCache.set(key.id, [])
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const globalModelName = routingData.value?.global_model_name
|
const globalModelName = routingData.value?.global_model_name
|
||||||
const globalModelMappings = routingData.value?.global_model_mappings || []
|
|
||||||
if (!globalModelName) {
|
if (!globalModelName) {
|
||||||
|
keyMatchedModelsCache.set(key.id, [])
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = compiledGlobalModelMappingRegexes.value
|
||||||
|
if (patterns.length === 0) {
|
||||||
|
keyMatchedModelsCache.set(key.id, [])
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,18 +888,17 @@ function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
|
|||||||
if (allowedModel === globalModelName) {
|
if (allowedModel === globalModelName) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
||||||
|
|
||||||
// 用 GlobalModel 的映射模式匹配白名单中的模型名
|
// 用 GlobalModel 的映射模式匹配白名单中的模型名
|
||||||
for (const pattern of globalModelMappings) {
|
for (const regex of patterns) {
|
||||||
try {
|
if (regex.test(allowedModel)) {
|
||||||
if (new RegExp(`^${pattern}$`, 'i').test(allowedModel)) {
|
matched.push(allowedModel)
|
||||||
matched.push(allowedModel)
|
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
|
||||||
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 正则无效,跳过
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
keyMatchedModelsCache.set(key.id, matched)
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
|||||||
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
||||||
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
||||||
export { default as ModelMappingsTab } from './ModelMappingsTab.vue'
|
export { default as ModelMappingsTab } from './ModelMappingsTab.vue'
|
||||||
|
export { default as RoutingTab } from './RoutingTab.vue'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
150
frontend/src/features/models/utils/model-mapping-regex.ts
Normal file
150
frontend/src/features/models/utils/model-mapping-regex.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,14 @@
|
|||||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||||
@click="toggleLock(model, $event)"
|
@click="toggleLock(model, $event)"
|
||||||
>
|
>
|
||||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
<Lock
|
||||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
v-if="isLocked(model)"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
<LockOpen
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,8 +187,14 @@
|
|||||||
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||||
@click="toggleLock(model.name, $event)"
|
@click="toggleLock(model.name, $event)"
|
||||||
>
|
>
|
||||||
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
|
<Lock
|
||||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
v-if="isLocked(model.name)"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
<LockOpen
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,8 +255,14 @@
|
|||||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||||
@click="toggleLock(model, $event)"
|
@click="toggleLock(model, $event)"
|
||||||
>
|
>
|
||||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
<Lock
|
||||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
v-if="isLocked(model)"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
<LockOpen
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,7 +618,7 @@ async function loadGlobalModels() {
|
|||||||
name: m.name,
|
name: m.name,
|
||||||
display_name: m.display_name
|
display_name: m.display_name
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (loadingCancelled) return
|
if (loadingCancelled) return
|
||||||
showError('加载全局模型失败', '错误')
|
showError('加载全局模型失败', '错误')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -256,7 +256,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||||
import { Key, SquarePen } from 'lucide-vue-next'
|
import { Key, SquarePen } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|||||||
@@ -259,10 +259,7 @@ function handleDrop(targetIndex: number) {
|
|||||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||||
let currentPriority = 1
|
let currentPriority = 1
|
||||||
|
|
||||||
// 找到被拖动项在原数组中的索引对应的原始优先级
|
items.forEach(alias => {
|
||||||
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
|
||||||
|
|
||||||
items.forEach((alias, newIdx) => {
|
|
||||||
// 找到这个映射在原数组中的索引
|
// 找到这个映射在原数组中的索引
|
||||||
const originalIdx = aliases.value.findIndex(a => a === alias)
|
const originalIdx = aliases.value.findIndex(a => a === alias)
|
||||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||||
|
|||||||
@@ -416,20 +416,6 @@
|
|||||||
@endpoint-updated="handleEndpointChanged"
|
@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
|
<KeyFormDialog
|
||||||
v-if="open"
|
v-if="open"
|
||||||
@@ -535,11 +521,9 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
|
|||||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
import {
|
import {
|
||||||
deleteEndpoint as deleteEndpointAPI,
|
|
||||||
deleteEndpointKey,
|
deleteEndpointKey,
|
||||||
recoverKeyHealth,
|
recoverKeyHealth,
|
||||||
getProviderKeys,
|
getProviderKeys,
|
||||||
updateEndpoint,
|
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
revealEndpointKey,
|
revealEndpointKey,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
@@ -579,12 +563,9 @@ const loading = ref(false)
|
|||||||
const provider = ref<any>(null)
|
const provider = ref<any>(null)
|
||||||
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||||||
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||||||
const expandedEndpoints = ref<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// 端点相关状态
|
// 端点相关状态
|
||||||
const endpointDialogOpen = ref(false)
|
const endpointDialogOpen = ref(false)
|
||||||
const deleteEndpointConfirmOpen = ref(false)
|
|
||||||
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
|
||||||
|
|
||||||
// 密钥相关状态
|
// 密钥相关状态
|
||||||
const keyFormDialogOpen = ref(false)
|
const keyFormDialogOpen = ref(false)
|
||||||
@@ -593,13 +574,10 @@ const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
|||||||
const editingKey = ref<EndpointAPIKey | null>(null)
|
const editingKey = ref<EndpointAPIKey | null>(null)
|
||||||
const deleteKeyConfirmOpen = ref(false)
|
const deleteKeyConfirmOpen = ref(false)
|
||||||
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
||||||
const recoveringEndpointId = ref<string | null>(null)
|
|
||||||
const togglingEndpointId = ref<string | null>(null)
|
|
||||||
const togglingKeyId = ref<string | null>(null)
|
const togglingKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
// 密钥显示状态:key_id -> 完整密钥
|
// 密钥显示状态:key_id -> 完整密钥
|
||||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||||
const revealingKeyId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 模型相关状态
|
// 模型相关状态
|
||||||
const modelFormDialogOpen = ref(false)
|
const modelFormDialogOpen = ref(false)
|
||||||
@@ -609,14 +587,6 @@ const modelToDelete = ref<Model | null>(null)
|
|||||||
const batchAssignDialogOpen = ref(false)
|
const batchAssignDialogOpen = ref(false)
|
||||||
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
|
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({
|
const keyDragState = ref({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -640,7 +610,6 @@ const multiplierSaving = ref(false)
|
|||||||
// 任意模态窗口打开时,阻止抽屉被误关闭
|
// 任意模态窗口打开时,阻止抽屉被误关闭
|
||||||
const hasBlockingDialogOpen = computed(() =>
|
const hasBlockingDialogOpen = computed(() =>
|
||||||
endpointDialogOpen.value ||
|
endpointDialogOpen.value ||
|
||||||
deleteEndpointConfirmOpen.value ||
|
|
||||||
keyFormDialogOpen.value ||
|
keyFormDialogOpen.value ||
|
||||||
keyPermissionsDialogOpen.value ||
|
keyPermissionsDialogOpen.value ||
|
||||||
deleteKeyConfirmOpen.value ||
|
deleteKeyConfirmOpen.value ||
|
||||||
@@ -702,18 +671,15 @@ watch(() => props.open, (newOpen) => {
|
|||||||
provider.value = null
|
provider.value = null
|
||||||
endpoints.value = []
|
endpoints.value = []
|
||||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||||
expandedEndpoints.value.clear()
|
|
||||||
|
|
||||||
// 重置所有对话框状态
|
// 重置所有对话框状态
|
||||||
endpointDialogOpen.value = false
|
endpointDialogOpen.value = false
|
||||||
deleteEndpointConfirmOpen.value = false
|
|
||||||
keyFormDialogOpen.value = false
|
keyFormDialogOpen.value = false
|
||||||
keyPermissionsDialogOpen.value = false
|
keyPermissionsDialogOpen.value = false
|
||||||
deleteKeyConfirmOpen.value = false
|
deleteKeyConfirmOpen.value = false
|
||||||
batchAssignDialogOpen.value = false
|
batchAssignDialogOpen.value = false
|
||||||
|
|
||||||
// 重置临时数据
|
// 重置临时数据
|
||||||
endpointToDelete.value = null
|
|
||||||
currentEndpoint.value = null
|
currentEndpoint.value = null
|
||||||
editingKey.value = null
|
editingKey.value = null
|
||||||
keyToDelete.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() {
|
function showAddEndpointDialog() {
|
||||||
endpointDialogOpen.value = true
|
endpointDialogOpen.value = true
|
||||||
@@ -757,27 +714,6 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
|||||||
endpointDialogOpen.value = true
|
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() {
|
async function handleEndpointChanged() {
|
||||||
await Promise.all([loadProvider(), loadEndpoints()])
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -808,37 +744,18 @@ function handleKeyPermissions(key: EndpointAPIKey) {
|
|||||||
keyPermissionsDialogOpen.value = true
|
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) {
|
async function copyFullKey(key: EndpointAPIKey) {
|
||||||
// 如果已经显示了,直接复制
|
const cached = revealedKeys.value.get(key.id)
|
||||||
if (revealedKeys.value.has(key.id)) {
|
if (cached) {
|
||||||
copyToClipboard(revealedKeys.value.get(key.id)!)
|
copyToClipboard(cached)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则先获取再复制
|
// 否则先获取再复制
|
||||||
try {
|
try {
|
||||||
const result = await revealEndpointKey(key.id)
|
const result = await revealEndpointKey(key.id)
|
||||||
|
revealedKeys.value.set(key.id, result.api_key)
|
||||||
copyToClipboard(result.api_key)
|
copyToClipboard(result.api_key)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
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() {
|
async function handleKeyChanged() {
|
||||||
await loadEndpoints()
|
await loadEndpoints()
|
||||||
emit('refresh')
|
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) {
|
async function toggleKeyActive(key: EndpointAPIKey) {
|
||||||
if (togglingKeyId.value) return
|
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) {
|
function startEditPriority(key: EndpointAPIKey) {
|
||||||
editingPriorityKey.value = key.id
|
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 格式列表(按指定顺序排序)
|
// 获取密钥的 API 格式列表(按指定顺序排序)
|
||||||
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
||||||
let formats: string[] = []
|
let formats: string[] = []
|
||||||
@@ -1491,7 +1204,7 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = nextProbe.getTime() - now.getTime()
|
const diffMs = nextProbe.getTime() - now.getTime()
|
||||||
if (diffMs > 0) {
|
if (diffMs > 0) {
|
||||||
return ' ' + formatCountdown(diffMs)
|
return ` ${formatCountdown(diffMs)}`
|
||||||
} else {
|
} else {
|
||||||
return ' 探测中'
|
return ' 探测中'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,8 +129,14 @@
|
|||||||
:disabled="testingModelId === model.id"
|
:disabled="testingModelId === model.id"
|
||||||
@click="handleTestClick(model)"
|
@click="handleTestClick(model)"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
|
<Loader2
|
||||||
<Play v-else class="w-3.5 h-3.5" />
|
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>
|
</Button>
|
||||||
<!-- 格式选择下拉菜单 -->
|
<!-- 格式选择下拉菜单 -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -479,7 +479,6 @@ const filters = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filtersDaysString = ref('7')
|
const filtersDaysString = ref('7')
|
||||||
const filtersLimitString = ref('50')
|
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
|||||||
@@ -808,7 +808,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Download, Upload } from 'lucide-vue-next'
|
import { Download, Upload } from 'lucide-vue-next'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
|
|||||||
@@ -868,11 +868,6 @@ function formatNumber(value?: number | null): string {
|
|||||||
return numericValue.toLocaleString()
|
return numericValue.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value?: number | null, fractionDigits = 4): string {
|
|
||||||
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
||||||
return numericValue.toFixed(fractionDigits)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleUserStatus(user: any) {
|
async function toggleUserStatus(user: any) {
|
||||||
const action = user.is_active ? '禁用' : '启用'
|
const action = user.is_active ? '禁用' : '启用'
|
||||||
const confirmed = await confirmDanger(
|
const confirmed = await confirmDanger(
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
|||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { error: showError } = useToast()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
|
|||||||
@@ -351,7 +351,6 @@ import {
|
|||||||
Image as ImageIcon
|
Image as ImageIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
@@ -375,7 +374,6 @@ const emit = defineEmits<{
|
|||||||
'toggleCapability': [modelName: string, capName: string]
|
'toggleCapability': [modelName: string, capName: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from ..models.database import ApiKey, Base, Usage, User, UserQuota
|
from ..models.database import ApiKey, Base, Usage, User, UserQuota
|
||||||
from .database import create_session, get_async_db, get_db, get_db_url, init_db, log_pool_status
|
from .database import create_session, get_db, get_db_url, init_db, log_pool_status
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -12,7 +12,6 @@ __all__ = [
|
|||||||
"Usage",
|
"Usage",
|
||||||
"UserQuota",
|
"UserQuota",
|
||||||
"get_db",
|
"get_db",
|
||||||
"get_async_db",
|
|
||||||
"init_db",
|
"init_db",
|
||||||
"create_session",
|
"create_session",
|
||||||
"get_db_url",
|
"get_db_url",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""
|
|
||||||
异步数据库工具
|
|
||||||
提供在异步上下文中安全使用同步数据库操作的工具
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable, Coroutine, TypeVar
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_in_executor(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
||||||
"""
|
|
||||||
在线程池中运行同步函数,避免阻塞事件循环
|
|
||||||
|
|
||||||
用法:
|
|
||||||
result = await run_in_executor(some_sync_function, arg1, arg2)
|
|
||||||
"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
|
||||||
|
|
||||||
|
|
||||||
def async_wrap_sync_db(func: Callable[..., T]) -> Callable[..., Coroutine[Any, Any, T]]:
|
|
||||||
"""
|
|
||||||
装饰器:包装同步数据库函数为异步函数
|
|
||||||
|
|
||||||
用法:
|
|
||||||
@async_wrap_sync_db
|
|
||||||
def get_user(db: Session, user_id: int):
|
|
||||||
return db.query(User).filter(User.id == user_id).first()
|
|
||||||
|
|
||||||
# 现在可以在异步上下文中调用
|
|
||||||
user = await get_user(db, 123)
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
||||||
return await run_in_executor(func, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
@@ -3,19 +3,13 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import AsyncGenerator, Generator, Optional
|
from typing import Any, Generator, Optional, cast
|
||||||
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.ext.asyncio import (
|
|
||||||
AsyncEngine,
|
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import Pool, QueuePool
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -24,29 +18,27 @@ from ..models.database import Base, SystemConfig, User, UserRole
|
|||||||
|
|
||||||
# 延迟初始化的数据库引擎和会话工厂
|
# 延迟初始化的数据库引擎和会话工厂
|
||||||
_engine: Optional[Engine] = None
|
_engine: Optional[Engine] = None
|
||||||
_SessionLocal: Optional[sessionmaker] = None
|
_SessionLocal: Optional[sessionmaker[Session]] = None
|
||||||
_async_engine: Optional[AsyncEngine] = None
|
|
||||||
_AsyncSessionLocal: Optional[async_sessionmaker] = None
|
|
||||||
|
|
||||||
# 连接池监控
|
# 连接池监控
|
||||||
_last_pool_warning: float = 0.0
|
_last_pool_warning: float = 0.0
|
||||||
POOL_WARNING_INTERVAL = 60 # 每60秒最多警告一次
|
POOL_WARNING_INTERVAL = 60 # 每60秒最多警告一次
|
||||||
|
|
||||||
|
|
||||||
def _setup_pool_monitoring(engine: Engine):
|
def _setup_pool_monitoring(engine: Engine) -> None:
|
||||||
"""设置连接池监控事件"""
|
"""设置连接池监控事件"""
|
||||||
|
|
||||||
@event.listens_for(engine, "connect")
|
@event.listens_for(engine, "connect")
|
||||||
def receive_connect(dbapi_conn, connection_record):
|
def receive_connect(dbapi_conn: Any, connection_record: Any) -> None:
|
||||||
"""连接创建时的监控"""
|
"""连接创建时的监控"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@event.listens_for(engine, "checkout")
|
@event.listens_for(engine, "checkout")
|
||||||
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
|
def receive_checkout(dbapi_conn: Any, connection_record: Any, connection_proxy: Any) -> None:
|
||||||
"""从连接池检出连接时的监控"""
|
"""从连接池检出连接时的监控"""
|
||||||
global _last_pool_warning
|
global _last_pool_warning
|
||||||
|
|
||||||
pool = engine.pool
|
pool = cast(QueuePool, engine.pool)
|
||||||
# 获取连接池状态
|
# 获取连接池状态
|
||||||
checked_out = pool.checkedout()
|
checked_out = pool.checkedout()
|
||||||
pool_size = pool.size()
|
pool_size = pool.size()
|
||||||
@@ -70,10 +62,10 @@ def _setup_pool_monitoring(engine: Engine):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_pool_status() -> dict:
|
def get_pool_status() -> dict[str, Any]:
|
||||||
"""获取连接池状态"""
|
"""获取连接池状态"""
|
||||||
engine = _ensure_engine()
|
engine = _ensure_engine()
|
||||||
pool = engine.pool
|
pool = cast(QueuePool, engine.pool)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"checked_out": pool.checkedout(),
|
"checked_out": pool.checkedout(),
|
||||||
@@ -84,7 +76,7 @@ def get_pool_status() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def log_pool_status():
|
def log_pool_status() -> None:
|
||||||
"""记录连接池状态到日志(用于监控)"""
|
"""记录连接池状态到日志(用于监控)"""
|
||||||
try:
|
try:
|
||||||
status = get_pool_status()
|
status = get_pool_status()
|
||||||
@@ -147,7 +139,7 @@ def _ensure_engine() -> Engine:
|
|||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
def _log_pool_capacity():
|
def _log_pool_capacity() -> None:
|
||||||
theoretical = config.db_pool_size + config.db_max_overflow
|
theoretical = config.db_pool_size + config.db_max_overflow
|
||||||
workers = max(1, config.worker_processes)
|
workers = max(1, config.worker_processes)
|
||||||
total_estimated = theoretical * workers
|
total_estimated = theoretical * workers
|
||||||
@@ -169,82 +161,6 @@ def _log_pool_capacity():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_async_engine() -> AsyncEngine:
|
|
||||||
"""
|
|
||||||
确保异步数据库引擎已创建(延迟加载)
|
|
||||||
|
|
||||||
这允许异步路由使用非阻塞的数据库访问
|
|
||||||
"""
|
|
||||||
global _async_engine, _AsyncSessionLocal
|
|
||||||
|
|
||||||
if _async_engine is not None:
|
|
||||||
return _async_engine
|
|
||||||
|
|
||||||
# 获取数据库配置并转换为异步URL
|
|
||||||
DATABASE_URL = config.database_url
|
|
||||||
|
|
||||||
# 转换同步URL为异步URL(postgresql:// -> postgresql+asyncpg://)
|
|
||||||
if DATABASE_URL.startswith("postgresql://"):
|
|
||||||
ASYNC_DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
||||||
elif DATABASE_URL.startswith("sqlite:///"):
|
|
||||||
ASYNC_DATABASE_URL = DATABASE_URL.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"不支持的数据库类型: {DATABASE_URL}")
|
|
||||||
|
|
||||||
# 验证数据库类型(生产环境要求 PostgreSQL)
|
|
||||||
is_production = config.environment == "production"
|
|
||||||
if is_production and not ASYNC_DATABASE_URL.startswith("postgresql+asyncpg://"):
|
|
||||||
raise ValueError("生产环境只支持 PostgreSQL 数据库,请配置正确的 DATABASE_URL")
|
|
||||||
|
|
||||||
# 创建异步引擎
|
|
||||||
_async_engine = create_async_engine(
|
|
||||||
ASYNC_DATABASE_URL,
|
|
||||||
# AsyncEngine 不能使用 QueuePool;默认使用 AsyncAdaptedQueuePool
|
|
||||||
pool_size=config.db_pool_size,
|
|
||||||
max_overflow=config.db_max_overflow,
|
|
||||||
pool_timeout=config.db_pool_timeout,
|
|
||||||
pool_recycle=config.db_pool_recycle,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
echo=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 创建异步会话工厂
|
|
||||||
_AsyncSessionLocal = async_sessionmaker(
|
|
||||||
_async_engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autocommit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"异步数据库引擎已初始化: {ASYNC_DATABASE_URL.split('@')[-1] if '@' in ASYNC_DATABASE_URL else 'local'}")
|
|
||||||
|
|
||||||
return _async_engine
|
|
||||||
|
|
||||||
|
|
||||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""获取异步数据库会话
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
此方法已废弃,项目统一使用同步 Session。
|
|
||||||
未来版本可能移除此方法。请使用 get_db() 代替。
|
|
||||||
"""
|
|
||||||
import warnings
|
|
||||||
warnings.warn(
|
|
||||||
"get_async_db() 已废弃,项目统一使用同步 Session。请使用 get_db() 代替。",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
# 确保异步引擎已初始化
|
|
||||||
_ensure_async_engine()
|
|
||||||
|
|
||||||
async with _AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db(request: Request = None) -> Generator[Session, None, None]: # type: ignore[assignment]
|
def get_db(request: Request = None) -> Generator[Session, None, None]: # type: ignore[assignment]
|
||||||
"""获取数据库会话
|
"""获取数据库会话
|
||||||
|
|
||||||
@@ -298,6 +214,7 @@ def get_db(request: Request = None) -> Generator[Session, None, None]: # type:
|
|||||||
|
|
||||||
# 确保引擎已初始化
|
# 确保引擎已初始化
|
||||||
_ensure_engine()
|
_ensure_engine()
|
||||||
|
assert _SessionLocal is not None
|
||||||
|
|
||||||
db = _SessionLocal()
|
db = _SessionLocal()
|
||||||
|
|
||||||
@@ -347,6 +264,7 @@ def create_session() -> Session:
|
|||||||
db.close()
|
db.close()
|
||||||
"""
|
"""
|
||||||
_ensure_engine()
|
_ensure_engine()
|
||||||
|
assert _SessionLocal is not None
|
||||||
return _SessionLocal()
|
return _SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
@@ -355,7 +273,7 @@ def get_db_url() -> str:
|
|||||||
return config.database_url
|
return config.database_url
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db() -> None:
|
||||||
"""初始化数据库
|
"""初始化数据库
|
||||||
|
|
||||||
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
|
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
|
||||||
@@ -367,6 +285,7 @@ def init_db():
|
|||||||
|
|
||||||
# 确保引擎已创建
|
# 确保引擎已创建
|
||||||
_ensure_engine()
|
_ensure_engine()
|
||||||
|
assert _SessionLocal is not None
|
||||||
|
|
||||||
# 数据库表结构由 Alembic 迁移管理
|
# 数据库表结构由 Alembic 迁移管理
|
||||||
|
|
||||||
@@ -424,7 +343,7 @@ def init_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def init_admin_user(db: Session):
|
def init_admin_user(db: Session) -> None:
|
||||||
"""从环境变量创建管理员账户"""
|
"""从环境变量创建管理员账户"""
|
||||||
# 检查是否使用默认凭据
|
# 检查是否使用默认凭据
|
||||||
if config.admin_email == "admin@localhost" and config.admin_password == "admin123":
|
if config.admin_email == "admin@localhost" and config.admin_password == "admin123":
|
||||||
@@ -447,9 +366,9 @@ def init_admin_user(db: Session):
|
|||||||
email=config.admin_email,
|
email=config.admin_email,
|
||||||
username=config.admin_username,
|
username=config.admin_username,
|
||||||
role=UserRole.ADMIN,
|
role=UserRole.ADMIN,
|
||||||
quota_usd=1000.0,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
admin.quota_usd = cast(Any, 1000.0)
|
||||||
admin.set_password(config.admin_password)
|
admin.set_password(config.admin_password)
|
||||||
|
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
@@ -461,7 +380,7 @@ def init_admin_user(db: Session):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def init_default_models(db: Session):
|
def init_default_models(db: Session) -> None:
|
||||||
"""初始化默认模型配置"""
|
"""初始化默认模型配置"""
|
||||||
|
|
||||||
# 注意:作为中转代理服务,不再预设模型配置
|
# 注意:作为中转代理服务,不再预设模型配置
|
||||||
@@ -470,10 +389,10 @@ def init_default_models(db: Session):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def init_system_configs(db: Session):
|
def init_system_configs(db: Session) -> None:
|
||||||
"""初始化系统配置"""
|
"""初始化系统配置"""
|
||||||
|
|
||||||
configs = [
|
configs: list[dict[str, Any]] = [
|
||||||
{"key": "default_user_quota_usd", "value": 10.0, "description": "新用户默认美元配额"},
|
{"key": "default_user_quota_usd", "value": 10.0, "description": "新用户默认美元配额"},
|
||||||
{"key": "rate_limit_per_minute", "value": 60, "description": "每分钟请求限制"},
|
{"key": "rate_limit_per_minute", "value": 60, "description": "每分钟请求限制"},
|
||||||
{"key": "enable_registration", "value": False, "description": "是否开放用户注册"},
|
{"key": "enable_registration", "value": False, "description": "是否开放用户注册"},
|
||||||
@@ -484,12 +403,15 @@ def init_system_configs(db: Session):
|
|||||||
for config_data in configs:
|
for config_data in configs:
|
||||||
existing = db.query(SystemConfig).filter_by(key=config_data["key"]).first()
|
existing = db.query(SystemConfig).filter_by(key=config_data["key"]).first()
|
||||||
if not existing:
|
if not existing:
|
||||||
config = SystemConfig(**config_data)
|
row = SystemConfig()
|
||||||
db.add(config)
|
row.key = config_data["key"]
|
||||||
|
row.value = config_data["value"]
|
||||||
|
row.description = config_data["description"]
|
||||||
|
db.add(row)
|
||||||
logger.info(f"添加系统配置: {config_data['key']}")
|
logger.info(f"添加系统配置: {config_data['key']}")
|
||||||
|
|
||||||
|
|
||||||
def reset_db():
|
def reset_db() -> None:
|
||||||
"""重置数据库(仅用于开发)"""
|
"""重置数据库(仅用于开发)"""
|
||||||
logger.warning("重置数据库...")
|
logger.warning("重置数据库...")
|
||||||
|
|
||||||
|
|||||||
@@ -11,28 +11,30 @@ from email.mime.multipart import MIMEMultipart
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
aiosmtplib: Any
|
||||||
try:
|
try:
|
||||||
import aiosmtplib
|
import aiosmtplib as _aiosmtplib
|
||||||
|
|
||||||
AIOSMTPLIB_AVAILABLE = True
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
AIOSMTPLIB_AVAILABLE = False
|
AIOSMTPLIB_AVAILABLE = False
|
||||||
aiosmtplib = None
|
aiosmtplib = None
|
||||||
|
else:
|
||||||
|
AIOSMTPLIB_AVAILABLE = True
|
||||||
|
aiosmtplib = _aiosmtplib
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.utils.async_utils import run_in_executor
|
||||||
|
|
||||||
from .base import Notification, NotificationLevel, NotificationPlugin
|
from .base import Notification, NotificationLevel, NotificationPlugin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class EmailNotificationPlugin(NotificationPlugin):
|
class EmailNotificationPlugin(NotificationPlugin):
|
||||||
"""
|
"""
|
||||||
邮件通知插件
|
邮件通知插件
|
||||||
支持HTML和纯文本邮件
|
支持HTML和纯文本邮件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str = "email", config: Dict[str, Any] = None):
|
def __init__(self, name: str = "email", config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(name, config)
|
super().__init__(name, config or {})
|
||||||
|
|
||||||
# SMTP配置
|
# SMTP配置
|
||||||
self.smtp_host = config.get("smtp_host") if config else None
|
self.smtp_host = config.get("smtp_host") if config else None
|
||||||
@@ -60,7 +62,7 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
# 缓冲配置
|
# 缓冲配置
|
||||||
self._buffer: List[Notification] = []
|
self._buffer: List[Notification] = []
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._flush_task = None
|
self._flush_task: Optional[asyncio.Task[None]] = None
|
||||||
|
|
||||||
# 验证配置
|
# 验证配置
|
||||||
config_errors = []
|
config_errors = []
|
||||||
@@ -97,10 +99,10 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _start_flush_task(self):
|
def _start_flush_task(self) -> None:
|
||||||
"""启动定时刷新任务"""
|
"""启动定时刷新任务"""
|
||||||
|
|
||||||
async def flush_loop():
|
async def flush_loop() -> None:
|
||||||
while self.enabled:
|
while self.enabled:
|
||||||
await asyncio.sleep(self.flush_interval)
|
await asyncio.sleep(self.flush_interval)
|
||||||
await self.flush()
|
await self.flush()
|
||||||
@@ -235,9 +237,7 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
# 使用同步SMTP(在线程中运行)
|
# 使用同步SMTP(在线程中运行)
|
||||||
return await asyncio.get_event_loop().run_in_executor(
|
return await run_in_executor(self._send_email_sync, subject, body, is_html)
|
||||||
None, self._send_email_sync, subject, body, is_html
|
|
||||||
)
|
|
||||||
|
|
||||||
def _send_email_sync(self, subject: str, body: str, is_html: bool = True) -> bool:
|
def _send_email_sync(self, subject: str, body: str, is_html: bool = True) -> bool:
|
||||||
"""同步发送邮件"""
|
"""同步发送邮件"""
|
||||||
@@ -256,11 +256,15 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
message.attach(MIMEText(body, "plain"))
|
message.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
smtp_host = self.smtp_host
|
||||||
|
assert smtp_host is not None
|
||||||
|
|
||||||
# 连接SMTP服务器
|
# 连接SMTP服务器
|
||||||
|
server: smtplib.SMTP
|
||||||
if self.use_ssl:
|
if self.use_ssl:
|
||||||
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
|
server = smtplib.SMTP_SSL(smtp_host, self.smtp_port)
|
||||||
else:
|
else:
|
||||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
server = smtplib.SMTP(smtp_host, self.smtp_port)
|
||||||
if self.use_tls:
|
if self.use_tls:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
|
|
||||||
@@ -299,7 +303,7 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _do_send_batch(self, notifications: List[Notification]) -> Dict[str, Any]:
|
async def _do_send_batch(self, notifications: List[Notification]) -> Dict[str, int]:
|
||||||
"""实际批量发送通知"""
|
"""实际批量发送通知"""
|
||||||
if not notifications:
|
if not notifications:
|
||||||
return {"total": 0, "sent": 0, "failed": 0}
|
return {"total": 0, "sent": 0, "failed": 0}
|
||||||
@@ -357,7 +361,7 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
"aiosmtplib_available": AIOSMTPLIB_AVAILABLE,
|
"aiosmtplib_available": AIOSMTPLIB_AVAILABLE,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
"""关闭插件"""
|
"""关闭插件"""
|
||||||
# 刷新缓冲
|
# 刷新缓冲
|
||||||
await self.flush()
|
await self.flush()
|
||||||
@@ -366,7 +370,7 @@ class EmailNotificationPlugin(NotificationPlugin):
|
|||||||
if self._flush_task:
|
if self._flush_task:
|
||||||
self._flush_task.cancel()
|
self._flush_task.cancel()
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self) -> None:
|
||||||
"""清理资源"""
|
"""清理资源"""
|
||||||
try:
|
try:
|
||||||
asyncio.create_task(self.close())
|
asyncio.create_task(self.close())
|
||||||
|
|||||||
@@ -3,20 +3,21 @@
|
|||||||
提供 SMTP 邮件发送功能
|
提供 SMTP 邮件发送功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Optional, Tuple
|
from typing import Any, Optional, Tuple, Union
|
||||||
|
|
||||||
|
aiosmtplib: Any
|
||||||
try:
|
try:
|
||||||
import aiosmtplib
|
import aiosmtplib as _aiosmtplib
|
||||||
|
|
||||||
AIOSMTPLIB_AVAILABLE = True
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
AIOSMTPLIB_AVAILABLE = False
|
AIOSMTPLIB_AVAILABLE = False
|
||||||
aiosmtplib = None
|
aiosmtplib = None
|
||||||
|
else:
|
||||||
|
AIOSMTPLIB_AVAILABLE = True
|
||||||
|
aiosmtplib = _aiosmtplib
|
||||||
|
|
||||||
|
|
||||||
def _create_ssl_context() -> ssl.SSLContext:
|
def _create_ssl_context() -> ssl.SSLContext:
|
||||||
@@ -34,6 +35,7 @@ from sqlalchemy.orm import Session
|
|||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
|
from src.utils.async_utils import run_in_executor
|
||||||
|
|
||||||
from .email_template import EmailTemplate
|
from .email_template import EmailTemplate
|
||||||
|
|
||||||
@@ -260,9 +262,13 @@ class EmailSenderService:
|
|||||||
Returns:
|
Returns:
|
||||||
(是否发送成功, 错误信息)
|
(是否发送成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_event_loop()
|
return await run_in_executor(
|
||||||
return await loop.run_in_executor(
|
EmailSenderService._send_email_sync,
|
||||||
None, EmailSenderService._send_email_sync, config, to_email, subject, html_body, text_body
|
config,
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
html_body,
|
||||||
|
text_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -302,7 +308,7 @@ class EmailSenderService:
|
|||||||
message.attach(MIMEText(html_body, "html", "utf-8"))
|
message.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
|
||||||
# 连接 SMTP 服务器
|
# 连接 SMTP 服务器
|
||||||
server = None
|
server: Optional[smtplib.SMTP] = None
|
||||||
ssl_context = _create_ssl_context()
|
ssl_context = _create_ssl_context()
|
||||||
try:
|
try:
|
||||||
if config["smtp_use_ssl"]:
|
if config["smtp_use_ssl"]:
|
||||||
@@ -321,6 +327,8 @@ class EmailSenderService:
|
|||||||
if config["smtp_use_tls"]:
|
if config["smtp_use_tls"]:
|
||||||
server.starttls(context=ssl_context)
|
server.starttls(context=ssl_context)
|
||||||
|
|
||||||
|
assert server is not None
|
||||||
|
|
||||||
# 登录
|
# 登录
|
||||||
if config["smtp_user"] and config["smtp_password"]:
|
if config["smtp_user"] and config["smtp_password"]:
|
||||||
server.login(config["smtp_user"], config["smtp_password"])
|
server.login(config["smtp_user"], config["smtp_password"])
|
||||||
@@ -393,6 +401,7 @@ class EmailSenderService:
|
|||||||
await smtp.quit()
|
await smtp.quit()
|
||||||
else:
|
else:
|
||||||
# 使用同步方式测试
|
# 使用同步方式测试
|
||||||
|
server: Union[smtplib.SMTP, smtplib.SMTP_SSL]
|
||||||
if config["smtp_use_ssl"]:
|
if config["smtp_use_ssl"]:
|
||||||
server = smtplib.SMTP_SSL(
|
server = smtplib.SMTP_SSL(
|
||||||
config["smtp_host"],
|
config["smtp_host"],
|
||||||
|
|||||||
44
src/utils/async_utils.py
Normal file
44
src/utils/async_utils.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
异步工具函数
|
||||||
|
|
||||||
|
提供在异步上下文中安全执行同步函数的工具,避免阻塞事件循环。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from functools import partial, wraps
|
||||||
|
from typing import Any, Callable, Coroutine, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_in_executor(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||||
|
"""
|
||||||
|
在线程池中运行同步函数,避免阻塞事件循环。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
result = await run_in_executor(some_sync_function, arg1, arg2)
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
bound = partial(func, *args, **kwargs)
|
||||||
|
return await loop.run_in_executor(None, bound)
|
||||||
|
|
||||||
|
|
||||||
|
def async_wrap_sync(func: Callable[..., T]) -> Callable[..., Coroutine[Any, Any, T]]:
|
||||||
|
"""
|
||||||
|
装饰器:将同步函数包装成异步函数(在线程池中执行)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
@async_wrap_sync
|
||||||
|
def do_sync(...): ...
|
||||||
|
|
||||||
|
result = await do_sync(...)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||||
|
return await run_in_executor(func, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
Reference in New Issue
Block a user