mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: body rules 支持嵌套路径并增强前端验证体验
- 后端 apply_body_rules 支持点号分隔的嵌套路径(如 metadata.user.name) - 支持 \. 转义字面量点号(如 config\.v1.enabled) - 配置导出导入升级至 v2.2,新增 SystemConfig 支持 - 前端请求体规则编辑器改用 JSON 格式输入,增加实时验证指示 - 用量记录表格新增移动端卡片视图,优化筛选器响应式布局
This commit is contained in:
@@ -120,17 +120,35 @@ export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
|||||||
* - drop: 删除字段
|
* - drop: 删除字段
|
||||||
* - rename: 重命名字段(保留原值)
|
* - rename: 重命名字段(保留原值)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 请求体规则 - 覆写字段
|
||||||
|
*
|
||||||
|
* - path 支持嵌套路径,如 "metadata.user.name"
|
||||||
|
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
|
||||||
|
*/
|
||||||
export interface BodyRuleSet {
|
export interface BodyRuleSet {
|
||||||
action: 'set'
|
action: 'set'
|
||||||
path: string
|
path: string
|
||||||
value: any
|
value: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体规则 - 删除字段
|
||||||
|
*
|
||||||
|
* - path 支持嵌套路径,如 "metadata.internal_flag"
|
||||||
|
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
|
||||||
|
*/
|
||||||
export interface BodyRuleDrop {
|
export interface BodyRuleDrop {
|
||||||
action: 'drop'
|
action: 'drop'
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体规则 - 重命名/移动字段
|
||||||
|
*
|
||||||
|
* - from/to 支持嵌套路径,如 "extra.old_config" -> "settings.new_config"
|
||||||
|
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
|
||||||
|
*/
|
||||||
export interface BodyRuleRename {
|
export interface BodyRuleRename {
|
||||||
action: 'rename'
|
action: 'rename'
|
||||||
from: string
|
from: string
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ const triggerClass = computed(() =>
|
|||||||
:class="triggerClass"
|
:class="triggerClass"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
<slot />
|
<span class="truncate">
|
||||||
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none" />
|
<slot />
|
||||||
|
</span>
|
||||||
|
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
||||||
</SelectTriggerPrimitive>
|
</SelectTriggerPrimitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
:disabled="savingEndpointId === endpoint.id"
|
:disabled="savingEndpointId === endpoint.id"
|
||||||
@click="saveEndpoint(endpoint)"
|
@click="saveEndpoint(endpoint)"
|
||||||
>
|
>
|
||||||
<Check class="w-3.5 h-3.5" />
|
<Save class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -292,6 +292,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
||||||
|
class="text-xs text-muted-foreground px-2"
|
||||||
|
>
|
||||||
|
用 <code class="bg-muted px-1 rounded">.</code> 访问嵌套字段;值为 JSON 格式,字符串需加引号如 <code class="bg-muted px-1 rounded">"text"</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 请求体规则列表 - 次要色边框 -->
|
<!-- 请求体规则列表 - 次要色边框 -->
|
||||||
<div
|
<div
|
||||||
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
|
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
|
||||||
@@ -326,7 +333,7 @@
|
|||||||
<template v-if="rule.action === 'set'">
|
<template v-if="rule.action === 'set'">
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.path"
|
:model-value="rule.path"
|
||||||
placeholder="字段名"
|
placeholder="字段路径(如 metadata.user_id)"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||||
@@ -334,16 +341,21 @@
|
|||||||
<span class="text-muted-foreground text-xs">=</span>
|
<span class="text-muted-foreground text-xs">=</span>
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.value"
|
:model-value="rule.value"
|
||||||
placeholder="值"
|
placeholder="123 / "text" / [1,2]"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||||
/>
|
/>
|
||||||
|
<CheckCircle
|
||||||
|
class="w-4 h-4 shrink-0"
|
||||||
|
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||||
|
:title="getBodySetValueValidationTip(rule)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="rule.action === 'drop'">
|
<template v-else-if="rule.action === 'drop'">
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.path"
|
:model-value="rule.path"
|
||||||
placeholder="要删除的字段名"
|
placeholder="要删除的字段路径"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||||
@@ -352,7 +364,7 @@
|
|||||||
<template v-else-if="rule.action === 'rename'">
|
<template v-else-if="rule.action === 'rename'">
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.from"
|
:model-value="rule.from"
|
||||||
placeholder="原名"
|
placeholder="原路径"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'from', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'from', v)"
|
||||||
@@ -360,7 +372,7 @@
|
|||||||
<span class="text-muted-foreground text-xs">→</span>
|
<span class="text-muted-foreground text-xs">→</span>
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.to"
|
:model-value="rule.to"
|
||||||
placeholder="新名"
|
placeholder="新路径"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
||||||
@@ -494,7 +506,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio } from 'lucide-vue-next'
|
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
@@ -523,7 +535,7 @@ interface EditableRule {
|
|||||||
interface EditableBodyRule {
|
interface EditableBodyRule {
|
||||||
action: 'set' | 'drop' | 'rename'
|
action: 'set' | 'drop' | 'rename'
|
||||||
path: string // set/drop 用
|
path: string // set/drop 用
|
||||||
value: string // set 用
|
value: string // set 用(JSON 格式)
|
||||||
from: string // rename 用
|
from: string // rename 用
|
||||||
to: string // rename 用
|
to: string // rename 用
|
||||||
}
|
}
|
||||||
@@ -650,6 +662,44 @@ const RESERVED_BODY_FIELDS = new Set([
|
|||||||
'stream',
|
'stream',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function parseBodyRulePathParts(path: string): string[] | null {
|
||||||
|
const raw = path.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const ch = raw[i]
|
||||||
|
// 支持 \. 转义字面量点号;其他反斜杠组合按字面量保留
|
||||||
|
if (ch === '\\' && i + 1 < raw.length && raw[i + 1] === '.') {
|
||||||
|
current += '.'
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '.') {
|
||||||
|
if (!current) return null // 禁止空段:.a / a. / a..b
|
||||||
|
parts.push(current)
|
||||||
|
current = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
if (!current) return null
|
||||||
|
parts.push(current)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function initBodyRuleSetValueForEditor(value: any): { value: string } {
|
||||||
|
if (value === undefined) return { value: '' }
|
||||||
|
|
||||||
|
// 所有值都用 JSON 格式回显
|
||||||
|
try {
|
||||||
|
return { value: JSON.stringify(value) }
|
||||||
|
} catch {
|
||||||
|
return { value: String(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 内部状态
|
// 内部状态
|
||||||
const internalOpen = computed(() => props.modelValue)
|
const internalOpen = computed(() => props.modelValue)
|
||||||
|
|
||||||
@@ -733,7 +783,8 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
|||||||
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||||
for (const rule of endpoint.body_rules) {
|
for (const rule of endpoint.body_rules) {
|
||||||
if (rule.action === 'set') {
|
if (rule.action === 'set') {
|
||||||
bodyRules.push({ action: 'set', path: rule.path, value: rule.value || '', from: '', to: '' })
|
const { value } = initBodyRuleSetValueForEditor((rule as any).value)
|
||||||
|
bodyRules.push({ action: 'set', path: rule.path, value, from: '', to: '' })
|
||||||
} else if (rule.action === 'drop') {
|
} else if (rule.action === 'drop') {
|
||||||
bodyRules.push({ action: 'drop', path: rule.path, value: '', from: '', to: '' })
|
bodyRules.push({ action: 'drop', path: rule.path, value: '', from: '', to: '' })
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
@@ -959,22 +1010,30 @@ function updateEndpointBodyRuleField(endpointId: string, index: number, field: '
|
|||||||
|
|
||||||
// 验证请求体规则 path(针对特定端点)
|
// 验证请求体规则 path(针对特定端点)
|
||||||
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
|
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
|
||||||
const trimmedPath = path.trim().toLowerCase()
|
const raw = path.trim()
|
||||||
if (!trimmedPath) return null
|
if (!raw) return null
|
||||||
|
|
||||||
if (RESERVED_BODY_FIELDS.has(trimmedPath)) {
|
const parts = parseBodyRulePathParts(raw)
|
||||||
return `"${path}" 是系统保留的字段`
|
if (!parts) {
|
||||||
|
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||||
|
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||||
|
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = raw.toLowerCase()
|
||||||
|
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index && (
|
(r, i) => i !== index && (
|
||||||
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === trimmedPath) ||
|
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === normalizedPath) ||
|
||||||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedPath)
|
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedPath)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (duplicate >= 0) {
|
if (duplicate >= 0) {
|
||||||
return '字段名重复'
|
return '字段路径重复'
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -982,18 +1041,30 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
|||||||
|
|
||||||
// 验证请求体 rename from
|
// 验证请求体 rename from
|
||||||
function validateBodyRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
|
function validateBodyRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
|
||||||
const trimmedFrom = from.trim().toLowerCase()
|
const raw = from.trim()
|
||||||
if (!trimmedFrom) return null
|
if (!raw) return null
|
||||||
|
|
||||||
|
const parts = parseBodyRulePathParts(raw)
|
||||||
|
if (!parts) {
|
||||||
|
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||||
|
}
|
||||||
|
|
||||||
|
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||||
|
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||||
|
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFrom = raw.toLowerCase()
|
||||||
|
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index &&
|
(r, i) => i !== index &&
|
||||||
((r.action === 'set' && r.path.trim().toLowerCase() === trimmedFrom) ||
|
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||||
(r.action === 'drop' && r.path.trim().toLowerCase() === trimmedFrom) ||
|
(r.action === 'drop' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
|
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
|
||||||
)
|
)
|
||||||
if (duplicate >= 0) {
|
if (duplicate >= 0) {
|
||||||
return '该字段已被其他规则处理'
|
return '该路径已被其他规则处理'
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -1001,26 +1072,78 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
|||||||
|
|
||||||
// 验证请求体 rename to
|
// 验证请求体 rename to
|
||||||
function validateBodyRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
|
function validateBodyRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
|
||||||
const trimmedTo = to.trim().toLowerCase()
|
const raw = to.trim()
|
||||||
if (!trimmedTo) return null
|
if (!raw) return null
|
||||||
|
|
||||||
if (RESERVED_BODY_FIELDS.has(trimmedTo)) {
|
const parts = parseBodyRulePathParts(raw)
|
||||||
return `"${to}" 是系统保留的字段`
|
if (!parts) {
|
||||||
|
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||||
|
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||||
|
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTo = raw.toLowerCase()
|
||||||
|
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index &&
|
(r, i) => i !== index &&
|
||||||
((r.action === 'set' && r.path.trim().toLowerCase() === trimmedTo) ||
|
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedTo) ||
|
||||||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
|
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
|
||||||
)
|
)
|
||||||
if (duplicate >= 0) {
|
if (duplicate >= 0) {
|
||||||
return '字段名重复'
|
return '字段路径重复'
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateBodySetValue(rule: EditableBodyRule): string | null {
|
||||||
|
if (rule.action !== 'set') return null
|
||||||
|
|
||||||
|
const raw = rule.value.trim()
|
||||||
|
if (!raw) return '值不能为空'
|
||||||
|
try {
|
||||||
|
JSON.parse(raw)
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return `JSON 格式错误:${msg}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取值验证状态:true=有效, false=无效, null=空
|
||||||
|
function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
||||||
|
if (rule.action !== 'set') return null
|
||||||
|
const raw = rule.value.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
JSON.parse(raw)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证提示
|
||||||
|
function getBodySetValueValidationTip(rule: EditableBodyRule): string {
|
||||||
|
const validation = getBodySetValueValidation(rule)
|
||||||
|
if (validation === null) return '点击验证 JSON'
|
||||||
|
if (validation === true) {
|
||||||
|
const parsed = JSON.parse(rule.value.trim())
|
||||||
|
const type = Array.isArray(parsed) ? '数组' : typeof parsed === 'object' && parsed !== null ? '对象' : typeof parsed === 'string' ? '字符串' : typeof parsed === 'number' ? '数字' : typeof parsed === 'boolean' ? '布尔' : 'null'
|
||||||
|
return `有效的 JSON (${type})`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(rule.value.trim())
|
||||||
|
return ''
|
||||||
|
} catch (err: any) {
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取端点的请求体规则数量(有效的规则)
|
// 获取端点的请求体规则数量(有效的规则)
|
||||||
function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
|
function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
|
||||||
const state = endpointEditStates.value[endpoint.id]
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
@@ -1096,7 +1219,9 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
if (!original) return true
|
if (!original) return true
|
||||||
if (edited.action !== original.action) return true
|
if (edited.action !== original.action) return true
|
||||||
if (edited.action === 'set' && original.action === 'set') {
|
if (edited.action === 'set' && original.action === 'set') {
|
||||||
if (edited.path !== original.path || edited.value !== (original.value || '')) return true
|
const baseline = initBodyRuleSetValueForEditor((original as any).value)
|
||||||
|
if (edited.path !== original.path) return true
|
||||||
|
if (edited.value !== baseline.value) return true
|
||||||
} else if (edited.action === 'drop' && original.action === 'drop') {
|
} else if (edited.action === 'drop' && original.action === 'drop') {
|
||||||
if (edited.path !== original.path) return true
|
if (edited.path !== original.path) return true
|
||||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||||
@@ -1112,7 +1237,14 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
|||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.action === 'set' && rule.path.trim()) {
|
if (rule.action === 'set' && rule.path.trim()) {
|
||||||
result.push({ action: 'set', path: rule.path.trim(), value: rule.value })
|
let value: any = rule.value
|
||||||
|
try {
|
||||||
|
value = JSON.parse(rule.value.trim())
|
||||||
|
} catch {
|
||||||
|
// 保存前会做校验;这里兜底避免 UI 崩溃
|
||||||
|
value = rule.value
|
||||||
|
}
|
||||||
|
result.push({ action: 'set', path: rule.path.trim(), value })
|
||||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||||
result.push({ action: 'drop', path: rule.path.trim() })
|
result.push({ action: 'drop', path: rule.path.trim() })
|
||||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||||
@@ -1123,19 +1255,32 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
|||||||
return result.length > 0 ? result : null
|
return result.length > 0 ? result : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查请求体规则是否有验证错误
|
function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
||||||
function hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
|
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
for (let i = 0; i < rules.length; i++) {
|
for (let i = 0; i < rules.length; i++) {
|
||||||
const rule = rules[i]
|
const rule = rules[i]
|
||||||
|
const prefix = `第 ${i + 1} 条请求体规则:`
|
||||||
|
|
||||||
if (rule.action === 'set' || rule.action === 'drop') {
|
if (rule.action === 'set' || rule.action === 'drop') {
|
||||||
if (validateBodyRulePathForEndpoint(endpointId, rule.path, i)) return true
|
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||||
|
if (pathErr) return `${prefix}${pathErr}`
|
||||||
|
if (rule.action === 'set') {
|
||||||
|
const valueErr = validateBodySetValue(rule)
|
||||||
|
if (valueErr) return `${prefix}${valueErr}`
|
||||||
|
}
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
if (validateBodyRenameFromForEndpoint(endpointId, rule.from, i)) return true
|
const fromErr = validateBodyRenameFromForEndpoint(endpointId, rule.from, i)
|
||||||
if (validateBodyRenameToForEndpoint(endpointId, rule.to, i)) return true
|
if (fromErr) return `${prefix}${fromErr}`
|
||||||
|
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
|
||||||
|
if (toErr) return `${prefix}${toErr}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查请求体规则是否有验证错误
|
||||||
|
function hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
|
||||||
|
return !!getBodyValidationErrorForEndpoint(endpointId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查端点 URL/路径是否有修改
|
// 检查端点 URL/路径是否有修改
|
||||||
@@ -1284,8 +1429,9 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查请求体规则是否有验证错误
|
// 检查请求体规则是否有验证错误
|
||||||
if (hasBodyValidationErrorsForEndpoint(endpoint.id)) {
|
const bodyErr = getBodyValidationErrorForEndpoint(endpoint.id)
|
||||||
showError('请修正请求体规则中的错误')
|
if (bodyErr) {
|
||||||
|
showError(bodyErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<Input
|
<Input
|
||||||
id="usage-records-search"
|
id="usage-records-search"
|
||||||
v-model="localSearch"
|
v-model="localSearch"
|
||||||
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
|
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
|
||||||
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
|
class="w-[7.5rem] sm:w-48 h-8 text-xs border-border/60 pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
:model-value="filterUser"
|
:model-value="filterUser"
|
||||||
@update:model-value="$emit('update:filterUser', $event)"
|
@update:model-value="$emit('update:filterUser', $event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-24 sm:w-36 h-8 text-xs border-border/60">
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="全部用户" />
|
<SelectValue placeholder="用户" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
:model-value="filterModel"
|
:model-value="filterModel"
|
||||||
@update:model-value="$emit('update:filterModel', $event)"
|
@update:model-value="$emit('update:filterModel', $event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-24 sm:w-40 h-8 text-xs border-border/60">
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="全部模型" />
|
<SelectValue placeholder="模型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
:model-value="filterProvider"
|
:model-value="filterProvider"
|
||||||
@update:model-value="$emit('update:filterProvider', $event)"
|
@update:model-value="$emit('update:filterProvider', $event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-24 sm:w-32 h-8 text-xs border-border/60">
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="全部提供商" />
|
<SelectValue placeholder="提供商" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
:model-value="filterApiFormat"
|
:model-value="filterApiFormat"
|
||||||
@update:model-value="$emit('update:filterApiFormat', $event)"
|
@update:model-value="$emit('update:filterApiFormat', $event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-24 sm:w-32 h-8 text-xs border-border/60">
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="全部格式" />
|
<SelectValue placeholder="格式" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
:model-value="filterStatus"
|
:model-value="filterStatus"
|
||||||
@update:model-value="$emit('update:filterStatus', $event)"
|
@update:model-value="$emit('update:filterStatus', $event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="全部状态" />
|
<SelectValue placeholder="状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
@@ -165,7 +165,117 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Table>
|
<!-- 移动端卡片视图 -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
<div
|
||||||
|
v-if="records.length === 0"
|
||||||
|
class="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无请求记录
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="record in records"
|
||||||
|
v-else
|
||||||
|
:key="record.id"
|
||||||
|
class="border-b border-border/40 py-2.5 px-2"
|
||||||
|
:class="isAdmin ? 'cursor-pointer active:bg-muted/30 transition-colors' : ''"
|
||||||
|
@click="isAdmin && emit('showDetail', record.id)"
|
||||||
|
>
|
||||||
|
<!-- 第一行:模型 + 费用 -->
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium truncate block">{{ record.model }}</span>
|
||||||
|
<span
|
||||||
|
v-if="getActualModel(record)"
|
||||||
|
class="text-[11px] text-muted-foreground truncate block"
|
||||||
|
>-> {{ getActualModel(record) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end flex-shrink-0">
|
||||||
|
<span class="text-xs text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="showActualCost && record.actual_cost !== undefined"
|
||||||
|
class="text-[10px] text-muted-foreground"
|
||||||
|
>{{ formatCurrency(record.actual_cost) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:状态 | 时间 | API格式 | 耗时 | Tokens -->
|
||||||
|
<div class="flex items-center justify-between text-[11px] text-muted-foreground mt-1 leading-4">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<!-- 状态 Badge -->
|
||||||
|
<Badge
|
||||||
|
v-if="record.status === 'failed' || (record.status_code && record.status_code >= 400) || record.error_message"
|
||||||
|
variant="destructive"
|
||||||
|
class="whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
失败
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="record.status === 'pending'"
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
等待
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="record.status === 'streaming'"
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-nowrap animate-pulse border-primary/50 text-primary text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
传输
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="record.status === 'cancelled'"
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="record.is_stream"
|
||||||
|
variant="secondary"
|
||||||
|
class="whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
流式
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
|
||||||
|
>
|
||||||
|
标准
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground/50">|</span>
|
||||||
|
<span>{{ formatDateTime(record.created_at) }}</span>
|
||||||
|
<template v-if="record.api_format">
|
||||||
|
<span class="text-muted-foreground/50">|</span>
|
||||||
|
<span>{{ formatApiFormat(record.api_format) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<!-- 耗时 -->
|
||||||
|
<span
|
||||||
|
v-if="record.status === 'pending' || record.status === 'streaming'"
|
||||||
|
class="text-primary tabular-nums"
|
||||||
|
>{{ getElapsedTime(record) }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="record.response_time_ms != null"
|
||||||
|
class="tabular-nums"
|
||||||
|
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ (record.response_time_ms / 1000).toFixed(1) }}s</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="tabular-nums"
|
||||||
|
>-</span>
|
||||||
|
<span class="text-muted-foreground/50">|</span>
|
||||||
|
<!-- Tokens -->
|
||||||
|
<span>{{ formatTokens(record.input_tokens || 0) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端表格视图 -->
|
||||||
|
<Table class="hidden md:table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||||
<TableHead class="h-12 font-semibold w-[70px]">
|
<TableHead class="h-12 font-semibold w-[70px]">
|
||||||
@@ -192,7 +302,7 @@
|
|||||||
>
|
>
|
||||||
提供商
|
提供商
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[80px]">
|
<TableHead class="h-12 font-semibold w-[120px]">
|
||||||
API格式
|
API格式
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[70px] text-center">
|
<TableHead class="h-12 font-semibold w-[70px] text-center">
|
||||||
@@ -354,7 +464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="py-4 w-[80px]"
|
class="py-4 w-[120px]"
|
||||||
:title="getApiFormatTooltip(record)"
|
:title="getApiFormatTooltip(record)"
|
||||||
>
|
>
|
||||||
<!-- 有格式转换或同族格式差异:两行显示 -->
|
<!-- 有格式转换或同族格式差异:两行显示 -->
|
||||||
@@ -362,7 +472,7 @@
|
|||||||
v-if="shouldShowFormatConversion(record)"
|
v-if="shouldShowFormatConversion(record)"
|
||||||
class="flex flex-col text-xs gap-0.5"
|
class="flex flex-col text-xs gap-0.5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||||
<span>{{ formatApiFormat(record.api_format!) }}</span>
|
<span>{{ formatApiFormat(record.api_format!) }}</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -377,12 +487,12 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted-foreground">{{ formatApiFormat(record.endpoint_api_format!) }}</span>
|
<span class="text-muted-foreground whitespace-nowrap">{{ formatApiFormat(record.endpoint_api_format!) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 无格式转换:单行显示 -->
|
<!-- 无格式转换:单行显示 -->
|
||||||
<span
|
<span
|
||||||
v-else-if="record.api_format"
|
v-else-if="record.api_format"
|
||||||
class="text-xs"
|
class="text-xs whitespace-nowrap"
|
||||||
>{{ formatApiFormat(record.api_format) }}</span>
|
>{{ formatApiFormat(record.api_format) }}</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -910,6 +910,8 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 导出 Provider Models
|
# 导出 Provider Models
|
||||||
|
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
||||||
|
# 导入时未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
||||||
models = db.query(Model).filter(Model.provider_id == provider.id).all()
|
models = db.query(Model).filter(Model.provider_id == provider.id).all()
|
||||||
models_data = []
|
models_data = []
|
||||||
for model in models:
|
for model in models:
|
||||||
@@ -988,6 +990,27 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
|||||||
"connect_timeout": ldap_config.connect_timeout,
|
"connect_timeout": ldap_config.connect_timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 导出 SystemConfig 配置
|
||||||
|
from src.models.database import SystemConfig
|
||||||
|
|
||||||
|
# 敏感配置项需要解密导出
|
||||||
|
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
||||||
|
system_configs = db.query(SystemConfig).all()
|
||||||
|
system_configs_data = []
|
||||||
|
for cfg in system_configs:
|
||||||
|
cfg_data = {
|
||||||
|
"key": cfg.key,
|
||||||
|
"value": cfg.value,
|
||||||
|
"description": cfg.description,
|
||||||
|
}
|
||||||
|
# 解密敏感配置
|
||||||
|
if cfg.key in SENSITIVE_CONFIG_KEYS and cfg.value:
|
||||||
|
try:
|
||||||
|
cfg_data["value"] = crypto_service.decrypt(cfg.value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"解密 SystemConfig '{cfg.key}' 失败: {e}")
|
||||||
|
system_configs_data.append(cfg_data)
|
||||||
|
|
||||||
# 导出 OAuth Providers 配置
|
# 导出 OAuth Providers 配置
|
||||||
from src.models.database import OAuthProvider
|
from src.models.database import OAuthProvider
|
||||||
|
|
||||||
@@ -1021,12 +1044,13 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": "2.1",
|
"version": "2.2",
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"global_models": global_models_data,
|
"global_models": global_models_data,
|
||||||
"providers": providers_data,
|
"providers": providers_data,
|
||||||
"ldap_config": ldap_data,
|
"ldap_config": ldap_data,
|
||||||
"oauth_providers": oauth_data,
|
"oauth_providers": oauth_data,
|
||||||
|
"system_configs": system_configs_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1086,9 +1110,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
db = context.db
|
db = context.db
|
||||||
payload = context.ensure_json_body()
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
# 验证配置版本(支持 2.0 和 2.1)
|
# 验证配置版本(支持 2.0、2.1 和 2.2)
|
||||||
version = payload.get("version")
|
version = payload.get("version")
|
||||||
if version not in ("2.0", "2.1"):
|
if version not in ("2.0", "2.1", "2.2"):
|
||||||
raise InvalidRequestException(f"不支持的配置版本: {version}")
|
raise InvalidRequestException(f"不支持的配置版本: {version}")
|
||||||
|
|
||||||
# 获取导入选项
|
# 获取导入选项
|
||||||
@@ -1097,6 +1121,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
providers_data = payload.get("providers", [])
|
providers_data = payload.get("providers", [])
|
||||||
ldap_data = payload.get("ldap_config") # 2.1 新增
|
ldap_data = payload.get("ldap_config") # 2.1 新增
|
||||||
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
|
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
|
||||||
|
system_configs_data = payload.get("system_configs", []) # 2.2 新增
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"global_models": {"created": 0, "updated": 0, "skipped": 0},
|
"global_models": {"created": 0, "updated": 0, "skipped": 0},
|
||||||
@@ -1106,6 +1131,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
"models": {"created": 0, "updated": 0, "skipped": 0},
|
"models": {"created": 0, "updated": 0, "skipped": 0},
|
||||||
"ldap": {"created": 0, "updated": 0, "skipped": 0},
|
"ldap": {"created": 0, "updated": 0, "skipped": 0},
|
||||||
"oauth": {"created": 0, "updated": 0, "skipped": 0},
|
"oauth": {"created": 0, "updated": 0, "skipped": 0},
|
||||||
|
"system_configs": {"created": 0, "updated": 0, "skipped": 0}, # 2.2 新增
|
||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1402,6 +1428,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
stats["keys_to_fetch"].append(new_key.id)
|
stats["keys_to_fetch"].append(new_key.id)
|
||||||
|
|
||||||
# 导入 Models
|
# 导入 Models
|
||||||
|
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
||||||
|
# 未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
||||||
for model_data in prov_data.get("models", []):
|
for model_data in prov_data.get("models", []):
|
||||||
global_model_name = model_data.get("global_model_name")
|
global_model_name = model_data.get("global_model_name")
|
||||||
if not global_model_name:
|
if not global_model_name:
|
||||||
@@ -1653,6 +1681,49 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
db.add(new_oauth)
|
db.add(new_oauth)
|
||||||
stats["oauth"]["created"] += 1
|
stats["oauth"]["created"] += 1
|
||||||
|
|
||||||
|
# 导入 SystemConfig(2.2 新增)
|
||||||
|
if system_configs_data:
|
||||||
|
from src.models.database import SystemConfig
|
||||||
|
|
||||||
|
# 敏感配置项需要加密存储
|
||||||
|
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
||||||
|
|
||||||
|
for cfg_item in system_configs_data:
|
||||||
|
cfg_key = cfg_item.get("key")
|
||||||
|
if not cfg_key:
|
||||||
|
stats["errors"].append("跳过无 key 的 SystemConfig 配置")
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_cfg = (
|
||||||
|
db.query(SystemConfig).filter(SystemConfig.key == cfg_key).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg_value = cfg_item.get("value")
|
||||||
|
# 加密敏感配置
|
||||||
|
if cfg_key in SENSITIVE_CONFIG_KEYS and cfg_value:
|
||||||
|
cfg_value = crypto_service.encrypt(cfg_value)
|
||||||
|
|
||||||
|
if existing_cfg:
|
||||||
|
if merge_mode == "skip":
|
||||||
|
stats["system_configs"]["skipped"] += 1
|
||||||
|
elif merge_mode == "error":
|
||||||
|
raise InvalidRequestException(f"SystemConfig '{cfg_key}' 已存在")
|
||||||
|
elif merge_mode == "overwrite":
|
||||||
|
existing_cfg.value = cfg_value
|
||||||
|
existing_cfg.description = cfg_item.get(
|
||||||
|
"description", existing_cfg.description
|
||||||
|
)
|
||||||
|
existing_cfg.updated_at = datetime.now(timezone.utc)
|
||||||
|
stats["system_configs"]["updated"] += 1
|
||||||
|
else:
|
||||||
|
new_cfg = SystemConfig(
|
||||||
|
key=cfg_key,
|
||||||
|
value=cfg_value,
|
||||||
|
description=cfg_item.get("description"),
|
||||||
|
)
|
||||||
|
db.add(new_cfg)
|
||||||
|
stats["system_configs"]["created"] += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 失效缓存
|
# 失效缓存
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -147,6 +148,150 @@ def build_test_request_body(
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_path(path: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
解析点号路径,支持转义(用 \\.
|
||||||
|
表示字面量点号)。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"metadata.user.name" -> ["metadata", "user", "name"]
|
||||||
|
"config\\.v1.enabled" -> ["config.v1", "enabled"]
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
|
||||||
|
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
|
||||||
|
"""
|
||||||
|
raw = (path or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(raw):
|
||||||
|
ch = raw[i]
|
||||||
|
if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".":
|
||||||
|
current.append(".")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == ".":
|
||||||
|
if not current:
|
||||||
|
return []
|
||||||
|
parts.append("".join(current))
|
||||||
|
current = []
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(ch)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parts.append("".join(current))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]:
|
||||||
|
"""
|
||||||
|
获取嵌套值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(found, value) - found 为 True 时 value 有效
|
||||||
|
"""
|
||||||
|
parts = _parse_path(path)
|
||||||
|
if not parts:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
current: Any = obj
|
||||||
|
for key in parts:
|
||||||
|
if isinstance(current, dict) and key in current:
|
||||||
|
current = current[key]
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
|
return True, current
|
||||||
|
|
||||||
|
|
||||||
|
def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
设置嵌套值,自动创建中间层级。
|
||||||
|
|
||||||
|
当中间层存在但不是 dict 时,会覆盖为 dict 后继续写入(覆写语义)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 写入成功
|
||||||
|
False: 路径无效(空/含空段等)
|
||||||
|
"""
|
||||||
|
parts = _parse_path(path)
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current: dict[str, Any] = obj
|
||||||
|
for key in parts[:-1]:
|
||||||
|
next_val = current.get(key)
|
||||||
|
if not isinstance(next_val, dict):
|
||||||
|
next_val = {}
|
||||||
|
current[key] = next_val
|
||||||
|
current = next_val
|
||||||
|
|
||||||
|
current[parts[-1]] = value
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除嵌套值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 删除成功
|
||||||
|
False: 路径不存在或无效
|
||||||
|
"""
|
||||||
|
parts = _parse_path(path)
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current: Any = obj
|
||||||
|
for key in parts[:-1]:
|
||||||
|
if isinstance(current, dict) and key in current:
|
||||||
|
current = current[key]
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(current, dict) and parts[-1] in current:
|
||||||
|
del current[parts[-1]]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
重命名嵌套值(移动到新路径)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 重命名成功
|
||||||
|
False: 源路径不存在或路径无效
|
||||||
|
"""
|
||||||
|
src = (from_path or "").strip()
|
||||||
|
dst = (to_path or "").strip()
|
||||||
|
if not src or not dst:
|
||||||
|
return False
|
||||||
|
if src == dst:
|
||||||
|
found, _ = _get_nested_value(obj, src)
|
||||||
|
return found
|
||||||
|
|
||||||
|
found, value = _get_nested_value(obj, src)
|
||||||
|
if not found:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_delete_nested_value(obj, src)
|
||||||
|
_set_nested_value(obj, dst, value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def apply_body_rules(
|
def apply_body_rules(
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
rules: list[dict[str, Any]],
|
rules: list[dict[str, Any]],
|
||||||
@@ -155,10 +300,14 @@ def apply_body_rules(
|
|||||||
"""
|
"""
|
||||||
应用请求体规则
|
应用请求体规则
|
||||||
|
|
||||||
|
路径语法:
|
||||||
|
- 使用点号分隔层级:metadata.user.name
|
||||||
|
- 转义字面量点号:config\\.v1.enabled -> key "config.v1" 下的 "enabled"
|
||||||
|
|
||||||
支持的规则类型:
|
支持的规则类型:
|
||||||
- set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
||||||
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
- rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body: 原始请求体
|
body: 原始请求体
|
||||||
@@ -171,32 +320,64 @@ def apply_body_rules(
|
|||||||
if not rules:
|
if not rules:
|
||||||
return body
|
return body
|
||||||
|
|
||||||
# 复制一份,避免修改原始数据
|
# 深拷贝,避免修改原始数据(尤其是嵌套 dict)
|
||||||
result = dict(body)
|
result = copy.deepcopy(body)
|
||||||
protected = protected_keys or PROTECTED_BODY_FIELDS
|
protected = protected_keys or PROTECTED_BODY_FIELDS
|
||||||
|
protected_lower = frozenset(str(k).lower() for k in protected)
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
action = rule.get("action")
|
action = rule.get("action")
|
||||||
|
if not isinstance(action, str):
|
||||||
|
continue
|
||||||
|
action = action.strip().lower()
|
||||||
|
|
||||||
if action == "set":
|
if action == "set":
|
||||||
path = rule.get("path", "")
|
raw_path = rule.get("path", "")
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
continue
|
||||||
|
path = raw_path.strip()
|
||||||
value = rule.get("value")
|
value = rule.get("value")
|
||||||
if path and path not in protected:
|
parts = _parse_path(path)
|
||||||
result[path] = value
|
if not parts:
|
||||||
|
continue
|
||||||
|
if parts[0].lower() in protected_lower:
|
||||||
|
continue
|
||||||
|
_set_nested_value(result, path, value)
|
||||||
|
|
||||||
elif action == "drop":
|
elif action == "drop":
|
||||||
path = rule.get("path", "")
|
raw_path = rule.get("path", "")
|
||||||
if path and path not in protected:
|
if not isinstance(raw_path, str):
|
||||||
result.pop(path, None)
|
continue
|
||||||
|
path = raw_path.strip()
|
||||||
|
parts = _parse_path(path)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if parts[0].lower() in protected_lower:
|
||||||
|
continue
|
||||||
|
_delete_nested_value(result, path)
|
||||||
|
|
||||||
elif action == "rename":
|
elif action == "rename":
|
||||||
from_key = rule.get("from", "")
|
raw_from = rule.get("from", "")
|
||||||
to_key = rule.get("to", "")
|
raw_to = rule.get("to", "")
|
||||||
if from_key and to_key:
|
if not isinstance(raw_from, str) or not isinstance(raw_to, str):
|
||||||
# 两个 key 都不能是受保护的
|
continue
|
||||||
if from_key not in protected and to_key not in protected:
|
from_path = raw_from.strip()
|
||||||
if from_key in result:
|
to_path = raw_to.strip()
|
||||||
result[to_key] = result.pop(from_key)
|
if not from_path or not to_path:
|
||||||
|
continue
|
||||||
|
from_parts = _parse_path(from_path)
|
||||||
|
to_parts = _parse_path(to_path)
|
||||||
|
if not from_parts or not to_parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 受保护字段只检查顶层 key
|
||||||
|
if from_parts[0].lower() in protected_lower or to_parts[0].lower() in protected_lower:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_rename_nested_value(result, from_path, to_path)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
95
tests/unit/test_body_rules.py
Normal file
95
tests/unit/test_body_rules.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from src.api.handlers.base.request_builder import apply_body_rules
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyBodyRulesNestedPaths:
|
||||||
|
def test_set_nested_value(self) -> None:
|
||||||
|
body = {"a": 1}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "b.c.d", "value": 42},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"a": 1, "b": {"c": {"d": 42}}}
|
||||||
|
|
||||||
|
def test_drop_nested_value(self) -> None:
|
||||||
|
body = {"a": {"b": {"c": 1, "d": 2}}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "drop", "path": "a.b.c"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"a": {"b": {"d": 2}}}
|
||||||
|
|
||||||
|
def test_rename_nested_value(self) -> None:
|
||||||
|
body = {"old": {"nested": "value"}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "rename", "from": "old.nested", "to": "new.path"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"old": {}, "new": {"path": "value"}}
|
||||||
|
|
||||||
|
def test_protected_top_level_key(self) -> None:
|
||||||
|
body = {"model": "gpt-4", "extra": {"model": "ignored"}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "model.sub", "value": "x"}, # 应被忽略
|
||||||
|
{"action": "set", "path": "extra.model", "value": "y"}, # 应生效
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result["model"] == "gpt-4" # 顶层受保护,不变
|
||||||
|
assert result["extra"]["model"] == "y" # extra 不受保护
|
||||||
|
|
||||||
|
def test_escaped_dot(self) -> None:
|
||||||
|
body = {}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "config\\.v1.enabled", "value": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"config.v1": {"enabled": True}}
|
||||||
|
|
||||||
|
def test_invalid_paths_are_ignored(self) -> None:
|
||||||
|
body = {"a": 1}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": ".b", "value": 2},
|
||||||
|
{"action": "set", "path": "c..d", "value": 3},
|
||||||
|
{"action": "drop", "path": "e.", "value": None},
|
||||||
|
{"action": "rename", "from": "x..y", "to": "z", "value": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"a": 1}
|
||||||
|
|
||||||
|
def test_non_dict_intermediate_is_overwritten(self) -> None:
|
||||||
|
body = {"a": 1}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "a.b", "value": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"a": {"b": 2}}
|
||||||
|
|
||||||
|
def test_set_complex_value(self) -> None:
|
||||||
|
body = {}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "metadata.tags", "value": [1, 2]},
|
||||||
|
{"action": "set", "path": "metadata.obj", "value": {"a": 1}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result == {"metadata": {"tags": [1, 2], "obj": {"a": 1}}}
|
||||||
|
|
||||||
|
def test_does_not_mutate_original_body(self) -> None:
|
||||||
|
body = {"a": {"b": 1}}
|
||||||
|
result = apply_body_rules(body, [{"action": "set", "path": "a.c", "value": 2}])
|
||||||
|
assert result == {"a": {"b": 1, "c": 2}}
|
||||||
|
assert body == {"a": {"b": 1}}
|
||||||
Reference in New Issue
Block a user