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

@@ -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',

View File

@@ -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"

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()
}) })
// 暴露刷新方法给父组件 // 暴露刷新方法给父组件

View File

@@ -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
} }

View File

@@ -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'

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) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'" :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 {

View File

@@ -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'

View File

@@ -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

View File

@@ -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 ' 探测中'
} }

View File

@@ -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

View File

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

View File

@@ -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'

View File

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

View File

@@ -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()
// 状态 // 状态

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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

View File

@@ -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为异步URLpostgresql:// -> 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("重置数据库...")

View File

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

View File

@@ -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
View 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