mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat(rules): 条件系统增强,支持 all/any 组合条件和 original/current 数据源切换
- evaluate_condition 支持递归 all/any 组合节点和 source 字段 - header_rules 支持 condition 条件触发,HeaderBuilder.apply_rules 透传 body/original_body - 提取 EndpointConditionEditor 组件统一请求头/请求体规则的条件编辑 UI - header_rules 新增服务端结构校验(action/key/from/to/condition) - 新增组合条件、source 切换、fail-closed 等测试用例
This commit is contained in:
@@ -42,8 +42,6 @@ export interface HeaderRuleRename {
|
|||||||
to: string
|
to: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求体规则类型
|
* 请求体规则类型
|
||||||
* - set: 设置/覆盖字段
|
* - set: 设置/覆盖字段
|
||||||
@@ -136,10 +134,28 @@ export type BodyRuleConditionOp =
|
|||||||
| 'exists' | 'not_exists'
|
| 'exists' | 'not_exists'
|
||||||
| 'in' | 'type_is'
|
| 'in' | 'type_is'
|
||||||
|
|
||||||
export interface BodyRuleCondition {
|
export interface BodyRuleConditionLeaf {
|
||||||
path: string
|
path: string
|
||||||
op: BodyRuleConditionOp
|
op: BodyRuleConditionOp
|
||||||
value?: unknown // exists / not_exists 不需要 value
|
value?: unknown // exists / not_exists 不需要 value
|
||||||
|
source?: 'original' | 'current'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleConditionAll {
|
||||||
|
all: BodyRuleCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleConditionAny {
|
||||||
|
any: BodyRuleCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyRuleCondition =
|
||||||
|
| BodyRuleConditionLeaf
|
||||||
|
| BodyRuleConditionAll
|
||||||
|
| BodyRuleConditionAny
|
||||||
|
|
||||||
|
export type HeaderRule = (HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename) & {
|
||||||
|
condition?: BodyRuleCondition
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Group node -->
|
||||||
|
<div
|
||||||
|
v-if="modelValue.kind === 'group'"
|
||||||
|
class="rounded-md border p-2"
|
||||||
|
:class="[
|
||||||
|
nested ? 'bg-muted/30 border-dashed' : 'bg-muted/10 border-border',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Group header: mode selector + actions -->
|
||||||
|
<div class="flex items-center gap-1.5 mb-2">
|
||||||
|
<Select
|
||||||
|
:model-value="modelValue.mode"
|
||||||
|
@update:model-value="(value: string) => updateGroupMode(value as ConditionGroupMode)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-[86px] h-6 text-xs font-medium shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
AND
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="any">
|
||||||
|
OR
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-1.5 text-xs text-muted-foreground"
|
||||||
|
title="添加条件"
|
||||||
|
@click="addLeafChild"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3 mr-0.5" />
|
||||||
|
条件
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-1.5 text-xs text-muted-foreground"
|
||||||
|
title="添加嵌套条件组"
|
||||||
|
@click="addGroupChild(modelValue.mode === 'all' ? 'any' : 'all')"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3 mr-0.5" />
|
||||||
|
子组
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="removable && nested"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children with logic connector labels between them -->
|
||||||
|
<div class="space-y-0">
|
||||||
|
<template
|
||||||
|
v-for="(child, index) in modelValue.children"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<!-- Logic connector label between children -->
|
||||||
|
<div
|
||||||
|
v-if="index > 0"
|
||||||
|
class="flex items-center gap-2 py-0.5 pl-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-semibold px-1.5 py-0.5 rounded"
|
||||||
|
:class="modelValue.mode === 'all'
|
||||||
|
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ modelValue.mode === 'all' ? 'AND' : 'OR' }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 border-t border-dashed border-muted-foreground/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EndpointConditionEditor
|
||||||
|
:model-value="child"
|
||||||
|
:path-hint="pathHint"
|
||||||
|
nested
|
||||||
|
removable
|
||||||
|
@update:model-value="(next) => updateChild(index, next)"
|
||||||
|
@remove="removeChild(index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaf node -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-wrap items-center gap-1.5 rounded-md px-2 py-1.5"
|
||||||
|
:class="nested ? 'bg-background/60' : 'bg-muted/10 border border-border'"
|
||||||
|
>
|
||||||
|
<!-- Toggle to group mode (icon button at the start) -->
|
||||||
|
<Button
|
||||||
|
v-if="!nested"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0 text-muted-foreground"
|
||||||
|
title="转为组合条件 (AND/OR)"
|
||||||
|
@click="convertToGroup('all')"
|
||||||
|
>
|
||||||
|
<ListFilter class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Select
|
||||||
|
:model-value="modelValue.source"
|
||||||
|
@update:model-value="(value: string) => updateLeafField('source', value)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="current">
|
||||||
|
Current
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="original">
|
||||||
|
Original
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
:model-value="modelValue.path"
|
||||||
|
:placeholder="pathHint || '字段路径'"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 min-w-[120px] h-7 text-xs"
|
||||||
|
@update:model-value="(value) => updateLeafField('path', value)"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
:model-value="modelValue.op"
|
||||||
|
@update:model-value="(value: string) => updateLeafField('op', value)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-[110px] h-7 text-xs shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in CONDITION_OP_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
v-if="isConditionValueRequired(modelValue.op)"
|
||||||
|
:model-value="modelValue.value"
|
||||||
|
:placeholder="getConditionValuePlaceholder(modelValue.op)"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 min-w-[120px] h-7 text-xs"
|
||||||
|
@update:model-value="(value) => updateLeafField('value', value)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="removable"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||||
|
import { ListFilter, Plus, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import type { BodyRuleConditionOp } from '@/api/endpoints'
|
||||||
|
import {
|
||||||
|
CONDITION_OP_OPTIONS,
|
||||||
|
cloneEditableCondition,
|
||||||
|
createConditionGroup,
|
||||||
|
createEmptyConditionLeaf,
|
||||||
|
getConditionValuePlaceholder,
|
||||||
|
isConditionValueRequired,
|
||||||
|
type ConditionGroupMode,
|
||||||
|
type ConditionSource,
|
||||||
|
type EditableConditionGroup,
|
||||||
|
type EditableConditionLeaf,
|
||||||
|
type EditableConditionNode,
|
||||||
|
} from './endpoint-rule-condition'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: EditableConditionNode
|
||||||
|
pathHint?: string
|
||||||
|
nested?: boolean
|
||||||
|
removable?: boolean
|
||||||
|
}>(), {
|
||||||
|
pathHint: '',
|
||||||
|
nested: false,
|
||||||
|
removable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: EditableConditionNode]
|
||||||
|
remove: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineOptions({ name: 'EndpointConditionEditor' })
|
||||||
|
|
||||||
|
function updateLeafField(field: keyof EditableConditionLeaf, rawValue: string): void {
|
||||||
|
if (props.modelValue.kind !== 'leaf') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionLeaf
|
||||||
|
|
||||||
|
if (field === 'op') {
|
||||||
|
next.op = rawValue as BodyRuleConditionOp
|
||||||
|
if (!isConditionValueRequired(next.op)) next.value = ''
|
||||||
|
} else if (field === 'source') {
|
||||||
|
next.source = rawValue as ConditionSource
|
||||||
|
} else if (field === 'path' || field === 'value') {
|
||||||
|
next[field] = rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroupMode(mode: ConditionGroupMode): void {
|
||||||
|
if (props.modelValue.kind !== 'group') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||||
|
next.mode = mode
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToGroup(mode: ConditionGroupMode): void {
|
||||||
|
const seed = props.modelValue.kind === 'leaf'
|
||||||
|
? cloneEditableCondition(props.modelValue)
|
||||||
|
: createEmptyConditionLeaf()
|
||||||
|
emit('update:modelValue', createConditionGroup(mode, [seed]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLeafChild(): void {
|
||||||
|
if (props.modelValue.kind !== 'group') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||||
|
next.children.push(createEmptyConditionLeaf())
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroupChild(mode: ConditionGroupMode): void {
|
||||||
|
if (props.modelValue.kind !== 'group') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||||
|
next.children.push(createConditionGroup(mode))
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChild(index: number, child: EditableConditionNode): void {
|
||||||
|
if (props.modelValue.kind !== 'group') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||||
|
next.children[index] = child
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChild(index: number): void {
|
||||||
|
if (props.modelValue.kind !== 'group') return
|
||||||
|
|
||||||
|
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||||
|
next.children.splice(index, 1)
|
||||||
|
|
||||||
|
// When all children are removed, if top-level group, emit remove to let parent clear the condition
|
||||||
|
if (next.children.length === 0) {
|
||||||
|
if (!props.nested) {
|
||||||
|
emit('remove')
|
||||||
|
} else {
|
||||||
|
next.children.push(createEmptyConditionLeaf())
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -228,105 +228,126 @@
|
|||||||
<span>拖拽左侧手柄可调整规则执行顺序</span>
|
<span>拖拽左侧手柄可调整规则执行顺序</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 请求头规则列表 - 主题色边框 -->
|
<!-- 请求头规则列表 - 主题色边框 -->
|
||||||
<div
|
<template
|
||||||
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
|
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
|
||||||
:key="`header-${index}`"
|
:key="`header-${index}`"
|
||||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
|
|
||||||
:class="[
|
|
||||||
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
|
|
||||||
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
|
|
||||||
]"
|
|
||||||
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
|
|
||||||
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
|
|
||||||
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
|
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
type="button"
|
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
|
||||||
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
|
:class="[
|
||||||
title="拖拽排序"
|
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
|
||||||
draggable="true"
|
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
|
||||||
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
|
]"
|
||||||
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
|
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
|
||||||
|
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
|
||||||
|
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
|
||||||
>
|
>
|
||||||
<GripVertical class="w-3.5 h-3.5" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<span
|
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
|
||||||
class="text-[10px] font-semibold text-primary shrink-0"
|
title="拖拽排序"
|
||||||
title="请求头"
|
draggable="true"
|
||||||
>H</span>
|
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
|
||||||
<Select
|
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
|
||||||
:model-value="rule.action"
|
>
|
||||||
:open="ruleSelectOpen[`${endpoint.id}-${index}`]"
|
<GripVertical class="w-3.5 h-3.5" />
|
||||||
@update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
</button>
|
||||||
@update:open="(v) => handleRuleSelectOpen(endpoint.id, index, v)"
|
<span
|
||||||
>
|
class="text-[10px] font-semibold text-primary shrink-0"
|
||||||
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
title="请求头"
|
||||||
<SelectValue />
|
>H</span>
|
||||||
</SelectTrigger>
|
<Select
|
||||||
<SelectContent>
|
:model-value="rule.action"
|
||||||
<SelectItem value="set">
|
:open="ruleSelectOpen[`${endpoint.id}-${index}`]"
|
||||||
覆写
|
@update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
||||||
</SelectItem>
|
@update:open="(v) => handleRuleSelectOpen(endpoint.id, index, v)"
|
||||||
<SelectItem value="drop">
|
>
|
||||||
删除
|
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
||||||
</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="rename">
|
</SelectTrigger>
|
||||||
重命名
|
<SelectContent>
|
||||||
</SelectItem>
|
<SelectItem value="set">
|
||||||
</SelectContent>
|
覆写
|
||||||
</Select>
|
</SelectItem>
|
||||||
<template v-if="rule.action === 'set'">
|
<SelectItem value="drop">
|
||||||
<Input
|
删除
|
||||||
:model-value="rule.key"
|
</SelectItem>
|
||||||
placeholder="名称"
|
<SelectItem value="rename">
|
||||||
size="sm"
|
重命名
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
</SelectItem>
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
<span class="text-muted-foreground text-xs">=</span>
|
<Button
|
||||||
<Input
|
variant="ghost"
|
||||||
:model-value="rule.value"
|
size="icon"
|
||||||
placeholder="值"
|
class="h-7 w-7 shrink-0"
|
||||||
size="sm"
|
:class="rule.condition ? 'text-primary' : ''"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
title="条件触发"
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
@click="toggleEndpointRuleCondition(endpoint.id, index)"
|
||||||
/>
|
>
|
||||||
</template>
|
<Filter class="w-3 h-3" />
|
||||||
<template v-else-if="rule.action === 'drop'">
|
</Button>
|
||||||
<Input
|
<template v-if="rule.action === 'set'">
|
||||||
:model-value="rule.key"
|
<Input
|
||||||
placeholder="要删除的名称"
|
:model-value="rule.key"
|
||||||
size="sm"
|
placeholder="名称"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
size="sm"
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
/>
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||||
</template>
|
/>
|
||||||
<template v-else-if="rule.action === 'rename'">
|
<span class="text-muted-foreground text-xs">=</span>
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.from"
|
:model-value="rule.value"
|
||||||
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) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
||||||
/>
|
/>
|
||||||
<span class="text-muted-foreground text-xs">→</span>
|
</template>
|
||||||
<Input
|
<template v-else-if="rule.action === 'drop'">
|
||||||
:model-value="rule.to"
|
<Input
|
||||||
placeholder="新名"
|
:model-value="rule.key"
|
||||||
size="sm"
|
placeholder="要删除的名称"
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
size="sm"
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
/>
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||||
</template>
|
/>
|
||||||
<Button
|
</template>
|
||||||
variant="ghost"
|
<template v-else-if="rule.action === 'rename'">
|
||||||
size="icon"
|
<Input
|
||||||
class="h-7 w-7 shrink-0"
|
:model-value="rule.from"
|
||||||
@click="removeEndpointRule(endpoint.id, index)"
|
placeholder="原名"
|
||||||
>
|
size="sm"
|
||||||
<X class="w-3 h-3" />
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
</Button>
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
|
||||||
</div>
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">→</span>
|
||||||
|
<Input
|
||||||
|
:model-value="rule.to"
|
||||||
|
placeholder="新名"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
@click="removeEndpointRule(endpoint.id, index)"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<EndpointConditionEditor
|
||||||
|
v-if="rule.condition"
|
||||||
|
:model-value="rule.condition"
|
||||||
|
path-hint="请求体字段路径"
|
||||||
|
removable
|
||||||
|
@update:model-value="(condition) => updateEndpointRuleCondition(endpoint.id, index, condition)"
|
||||||
|
@remove="clearEndpointRuleCondition(endpoint.id, index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
||||||
@@ -397,14 +418,15 @@
|
|||||||
<code>exists</code> <code>not_exists</code> 字段存在性<br>
|
<code>exists</code> <code>not_exists</code> 字段存在性<br>
|
||||||
<code>in</code> 在列表中(值填 <code>["a","b"]</code>)<br>
|
<code>in</code> 在列表中(值填 <code>["a","b"]</code>)<br>
|
||||||
<code>type_is</code> 类型判断(string/number/boolean/array/object/null)<br>
|
<code>type_is</code> 类型判断(string/number/boolean/array/object/null)<br>
|
||||||
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素
|
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素<br>
|
||||||
|
可切换 <code>Current</code>/<code>Original</code> 数据源,并支持 <code>ALL</code>/<code>ANY</code> 组合条件
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
规则按顺序执行,前面的修改对后续规则可见。
|
规则按顺序执行,前面的修改对后续规则可见。
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
规则在格式转换之后执行,路径需按目标提供商的请求体结构填写。
|
规则默认在格式转换后按目标提供商结构匹配;条件切到 <code>Original</code> 时则按客户端原始请求体匹配。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -474,7 +496,7 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 shrink-0"
|
class="h-7 w-7 shrink-0"
|
||||||
:class="rule.conditionEnabled ? 'text-primary' : ''"
|
:class="rule.condition ? 'text-primary' : ''"
|
||||||
title="条件触发"
|
title="条件触发"
|
||||||
@click="toggleBodyRuleCondition(endpoint.id, index)"
|
@click="toggleBodyRuleCondition(endpoint.id, index)"
|
||||||
>
|
>
|
||||||
@@ -638,80 +660,14 @@
|
|||||||
<X class="w-3 h-3" />
|
<X class="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 条件编辑行 -->
|
<EndpointConditionEditor
|
||||||
<div
|
v-if="rule.condition"
|
||||||
v-if="rule.conditionEnabled"
|
:model-value="rule.condition"
|
||||||
class="flex items-center gap-1.5 px-2 py-1 ml-6 rounded-md bg-muted/20"
|
:path-hint="getBodyRuleConditionPathPlaceholder(rule.path)"
|
||||||
>
|
removable
|
||||||
<span class="text-[10px] font-semibold text-muted-foreground shrink-0">IF</span>
|
@update:model-value="(condition) => updateEndpointBodyRuleCondition(endpoint.id, index, condition)"
|
||||||
<Input
|
@remove="clearEndpointBodyRuleCondition(endpoint.id, index)"
|
||||||
:model-value="rule.conditionPath"
|
/>
|
||||||
:placeholder="rule.path?.includes('[*]') || rule.path?.match(/\[\d+-\d+\]/) ? '$item.字段名' : '字段路径'"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionPath', v)"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
:model-value="rule.conditionOp"
|
|
||||||
@update:model-value="(v: string) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionOp', v)"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="w-[100px] h-7 text-xs shrink-0">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="eq">
|
|
||||||
等于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="neq">
|
|
||||||
不等于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="gt">
|
|
||||||
大于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="lt">
|
|
||||||
小于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="gte">
|
|
||||||
大于等于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="lte">
|
|
||||||
小于等于
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="starts_with">
|
|
||||||
开头匹配
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ends_with">
|
|
||||||
结尾匹配
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="contains">
|
|
||||||
包含
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="matches">
|
|
||||||
正则匹配
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="exists">
|
|
||||||
存在
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="not_exists">
|
|
||||||
不存在
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="in">
|
|
||||||
在列表中
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="type_is">
|
|
||||||
类型是
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
v-if="rule.conditionOp !== 'exists' && rule.conditionOp !== 'not_exists'"
|
|
||||||
:model-value="rule.conditionValue"
|
|
||||||
:placeholder="rule.conditionOp === 'in' ? '["a", "b"]' : rule.conditionOp === 'type_is' ? 'string/number/boolean/...' : '值'"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1 min-w-0 h-7 text-xs"
|
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionValue', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -839,6 +795,7 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
|
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||||
import {
|
import {
|
||||||
createEndpoint,
|
createEndpoint,
|
||||||
getDefaultBodyRules,
|
getDefaultBodyRules,
|
||||||
@@ -850,11 +807,18 @@ import {
|
|||||||
type BodyRule,
|
type BodyRule,
|
||||||
type BodyRuleRegexReplace,
|
type BodyRuleRegexReplace,
|
||||||
type BodyRuleNameStyle,
|
type BodyRuleNameStyle,
|
||||||
type BodyRuleCondition,
|
|
||||||
type BodyRuleConditionOp,
|
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
|
import {
|
||||||
|
conditionEquals,
|
||||||
|
conditionToEditable,
|
||||||
|
createEmptyConditionLeaf,
|
||||||
|
editableConditionToApi,
|
||||||
|
getBodyRuleConditionPathPlaceholder,
|
||||||
|
type EditableConditionNode,
|
||||||
|
validateEditableCondition,
|
||||||
|
} from './endpoint-rule-condition'
|
||||||
|
|
||||||
// 编辑用的规则类型(统一的可编辑结构)
|
// 编辑用的规则类型(统一的可编辑结构)
|
||||||
interface EditableRule {
|
interface EditableRule {
|
||||||
@@ -863,6 +827,7 @@ interface EditableRule {
|
|||||||
value: string // set 用
|
value: string // set 用
|
||||||
from: string // rename 用
|
from: string // rename 用
|
||||||
to: string // rename 用
|
to: string // rename 用
|
||||||
|
condition: EditableConditionNode | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑用的请求体规则类型
|
// 编辑用的请求体规则类型
|
||||||
@@ -879,10 +844,7 @@ interface EditableBodyRule {
|
|||||||
replacement: string // regex_replace 用
|
replacement: string // regex_replace 用
|
||||||
flags: string // regex_replace 用(i/m/s)
|
flags: string // regex_replace 用(i/m/s)
|
||||||
style: string // name_style 用(snake_case/camelCase/PascalCase/kebab-case/capitalize)
|
style: string // name_style 用(snake_case/camelCase/PascalCase/kebab-case/capitalize)
|
||||||
conditionEnabled: boolean // 是否启用条件
|
condition: EditableConditionNode | null
|
||||||
conditionPath: string
|
|
||||||
conditionOp: string
|
|
||||||
conditionValue: string // JSON 格式字符串(保存时 parse)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
||||||
@@ -1346,57 +1308,62 @@ function getEndpointUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
|||||||
return 'auto'
|
return 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyHeaderRule(): EditableRule {
|
||||||
|
return { action: 'set', key: '', value: '', from: '', to: '', condition: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
path: '',
|
||||||
|
value: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
index: '',
|
||||||
|
pattern: '',
|
||||||
|
replacement: '',
|
||||||
|
flags: '',
|
||||||
|
style: '',
|
||||||
|
condition: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化端点的编辑状态
|
// 初始化端点的编辑状态
|
||||||
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
||||||
const rules: EditableRule[] = []
|
const rules: EditableRule[] = []
|
||||||
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
|
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
|
||||||
for (const rule of endpoint.header_rules) {
|
for (const rule of endpoint.header_rules) {
|
||||||
if (rule.action === 'set') {
|
if (rule.action === 'set') {
|
||||||
rules.push({ action: 'set', key: rule.key, value: rule.value || '', from: '', to: '' })
|
rules.push({ ...emptyHeaderRule(), action: 'set', key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'drop') {
|
} else if (rule.action === 'drop') {
|
||||||
rules.push({ action: 'drop', key: rule.key, value: '', from: '', to: '' })
|
rules.push({ ...emptyHeaderRule(), action: 'drop', key: rule.key, condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
rules.push({ action: 'rename', key: '', value: '', from: rule.from, to: rule.to })
|
rules.push({ ...emptyHeaderRule(), action: 'rename', from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
|
|
||||||
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '',
|
|
||||||
conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const bodyRules: EditableBodyRule[] = []
|
const bodyRules: EditableBodyRule[] = []
|
||||||
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) {
|
||||||
// 提取 condition 信息
|
|
||||||
const conditionFields = rule.condition ? {
|
|
||||||
conditionEnabled: true,
|
|
||||||
conditionPath: rule.condition.path || '',
|
|
||||||
conditionOp: rule.condition.op || 'eq',
|
|
||||||
conditionValue: rule.condition.value !== undefined
|
|
||||||
? (typeof rule.condition.value === 'string' ? rule.condition.value : JSON.stringify(rule.condition.value))
|
|
||||||
: '',
|
|
||||||
} : {}
|
|
||||||
|
|
||||||
if (rule.action === 'set') {
|
if (rule.action === 'set') {
|
||||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value, ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('set'), path: rule.path, value, condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'drop') {
|
} else if (rule.action === 'drop') {
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path, ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('drop'), path: rule.path, condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to, ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('rename'), from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'append') {
|
} else if (rule.action === 'append') {
|
||||||
// 前端将 append 统一展示为 insert(index 留空),保存时再根据 index 是否为空转回 append
|
// 前端将 append 统一展示为 insert(index 留空),保存时再根据 index 是否为空转回 append
|
||||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '', ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('insert'), path: rule.path || '', value, index: '', condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'insert') {
|
} else if (rule.action === 'insert') {
|
||||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('insert'), path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'regex_replace') {
|
} else if (rule.action === 'regex_replace') {
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('regex_replace'), path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', condition: conditionToEditable(rule.condition) })
|
||||||
} else if (rule.action === 'name_style') {
|
} else if (rule.action === 'name_style') {
|
||||||
bodyRules.push({ ...emptyBodyRule(), action: 'name_style', path: rule.path || '', style: rule.style || 'capitalize', ...conditionFields })
|
bodyRules.push({ ...emptyBodyRule('name_style'), path: rule.path || '', style: rule.style || 'capitalize', condition: conditionToEditable(rule.condition) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1447,7 +1414,7 @@ function getEndpointEditRules(endpointId: string): EditableRule[] {
|
|||||||
// 添加规则(同时自动展开折叠)
|
// 添加规则(同时自动展开折叠)
|
||||||
function handleAddEndpointRule(endpointId: string) {
|
function handleAddEndpointRule(endpointId: string) {
|
||||||
const rules = getEndpointEditRules(endpointId)
|
const rules = getEndpointEditRules(endpointId)
|
||||||
rules.push({ action: 'set', key: '', value: '', from: '', to: '' })
|
rules.push(emptyHeaderRule())
|
||||||
// 自动展开折叠
|
// 自动展开折叠
|
||||||
endpointRulesExpanded.value[endpointId] = true
|
endpointRulesExpanded.value[endpointId] = true
|
||||||
}
|
}
|
||||||
@@ -1464,11 +1431,8 @@ function removeEndpointRule(endpointId: string, index: number) {
|
|||||||
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
|
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
|
||||||
const rules = getEndpointEditRules(endpointId)
|
const rules = getEndpointEditRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index].action = action
|
const currentCondition = rules[index].condition
|
||||||
rules[index].key = ''
|
rules[index] = { ...emptyHeaderRule(), action, condition: currentCondition }
|
||||||
rules[index].value = ''
|
|
||||||
rules[index].from = ''
|
|
||||||
rules[index].to = ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1480,6 +1444,27 @@ function updateEndpointRuleField(endpointId: string, index: number, field: 'key'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEndpointRuleCondition(endpointId: string, index: number) {
|
||||||
|
const rules = getEndpointEditRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEndpointRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
|
||||||
|
const rules = getEndpointEditRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].condition = condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEndpointRuleCondition(endpointId: string, index: number) {
|
||||||
|
const rules = getEndpointEditRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].condition = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证规则 key(针对特定端点)
|
// 验证规则 key(针对特定端点)
|
||||||
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
|
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
|
||||||
const trimmedKey = key.trim().toLowerCase()
|
const trimmedKey = key.trim().toLowerCase()
|
||||||
@@ -1587,7 +1572,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
|
|||||||
// 添加请求体规则(同时自动展开折叠)
|
// 添加请求体规则(同时自动展开折叠)
|
||||||
function handleAddEndpointBodyRule(endpointId: string) {
|
function handleAddEndpointBodyRule(endpointId: string) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '' })
|
rules.push(emptyBodyRule('set'))
|
||||||
// 自动展开折叠
|
// 自动展开折叠
|
||||||
endpointRulesExpanded.value[endpointId] = true
|
endpointRulesExpanded.value[endpointId] = true
|
||||||
}
|
}
|
||||||
@@ -1604,32 +1589,37 @@ function removeEndpointBodyRule(endpointId: string, index: number) {
|
|||||||
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
|
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index].action = action
|
const currentCondition = rules[index].condition
|
||||||
rules[index].path = ''
|
rules[index] = { ...emptyBodyRule(action), condition: currentCondition }
|
||||||
rules[index].value = ''
|
|
||||||
rules[index].from = ''
|
|
||||||
rules[index].to = ''
|
|
||||||
rules[index].index = ''
|
|
||||||
rules[index].pattern = ''
|
|
||||||
rules[index].replacement = ''
|
|
||||||
rules[index].flags = ''
|
|
||||||
rules[index].style = ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新请求体规则字段
|
// 更新请求体规则字段
|
||||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'style' | 'conditionPath' | 'conditionOp' | 'conditionValue', value: string) {
|
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'style', value: string) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index][field] = value
|
rules[index][field] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换请求体规则的条件启用状态
|
|
||||||
function toggleBodyRuleCondition(endpointId: string, index: number) {
|
function toggleBodyRuleCondition(endpointId: string, index: number) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index].conditionEnabled = !rules[index].conditionEnabled
|
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEndpointBodyRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].condition = condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEndpointBodyRuleCondition(endpointId: string, index: number) {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].condition = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1657,7 +1647,7 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
|||||||
const currentRule = rules[index]
|
const currentRule = rules[index]
|
||||||
// 任意一方启用了条件,则不视为冲突(条件可能互斥,真正冲突在运行时处理)
|
// 任意一方启用了条件,则不视为冲突(条件可能互斥,真正冲突在运行时处理)
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled && (
|
(r, i) => i !== index && !currentRule.condition && !r.condition && (
|
||||||
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === normalizedPath) ||
|
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === normalizedPath) ||
|
||||||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedPath)
|
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedPath)
|
||||||
)
|
)
|
||||||
@@ -1689,7 +1679,7 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
|||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
const currentRule = rules[index]
|
const currentRule = rules[index]
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled &&
|
(r, i) => i !== index && !currentRule.condition && !r.condition &&
|
||||||
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||||
(r.action === 'drop' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
(r.action === 'drop' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||||
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
|
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
|
||||||
@@ -1721,7 +1711,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
|||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
const currentRule = rules[index]
|
const currentRule = rules[index]
|
||||||
const duplicate = rules.findIndex(
|
const duplicate = rules.findIndex(
|
||||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled &&
|
(r, i) => i !== index && !currentRule.condition && !r.condition &&
|
||||||
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedTo) ||
|
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedTo) ||
|
||||||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
|
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
|
||||||
)
|
)
|
||||||
@@ -1932,17 +1922,7 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
if (edited.path !== (original.path ?? '')) return true
|
if (edited.path !== (original.path ?? '')) return true
|
||||||
if (edited.style !== (original.style ?? '')) return true
|
if (edited.style !== (original.style ?? '')) return true
|
||||||
}
|
}
|
||||||
// 条件变更检测
|
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
|
||||||
const origCond = original.condition
|
|
||||||
if (edited.conditionEnabled !== !!origCond) return true
|
|
||||||
if (edited.conditionEnabled && origCond) {
|
|
||||||
if (edited.conditionPath !== origCond.path) return true
|
|
||||||
if (edited.conditionOp !== origCond.op) return true
|
|
||||||
const origVal = origCond.value !== undefined
|
|
||||||
? (typeof origCond.value === 'string' ? origCond.value : JSON.stringify(origCond.value))
|
|
||||||
: ''
|
|
||||||
if (edited.conditionValue !== origVal) return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1951,21 +1931,8 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||||
const result: BodyRule[] = []
|
const result: BodyRule[] = []
|
||||||
|
|
||||||
// 构建 condition 对象(如果启用且有效)
|
|
||||||
function buildCondition(rule: EditableBodyRule): BodyRuleCondition | undefined {
|
|
||||||
if (!rule.conditionEnabled || !rule.conditionPath.trim() || !rule.conditionOp.trim()) return undefined
|
|
||||||
const op = rule.conditionOp as BodyRuleConditionOp
|
|
||||||
if (op === 'exists' || op === 'not_exists') {
|
|
||||||
return { path: rule.conditionPath.trim(), op }
|
|
||||||
}
|
|
||||||
const raw = rule.conditionValue.trim()
|
|
||||||
let val: unknown = raw
|
|
||||||
try { val = JSON.parse(raw) } catch { /* 保留原字符串 */ }
|
|
||||||
return { path: rule.conditionPath.trim(), op, value: val }
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
const condition = buildCondition(rule)
|
const condition = editableConditionToApi(rule.condition)
|
||||||
if (rule.action === 'set' && rule.path.trim()) {
|
if (rule.action === 'set' && rule.path.trim()) {
|
||||||
let value: unknown = rule.value
|
let value: unknown = rule.value
|
||||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||||
@@ -2049,6 +2016,9 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
|||||||
const validStyles = new Set(['snake_case', 'camelCase', 'PascalCase', 'kebab-case', 'capitalize'])
|
const validStyles = new Set(['snake_case', 'camelCase', 'PascalCase', 'kebab-case', 'capitalize'])
|
||||||
if (!rule.style.trim() || !validStyles.has(rule.style.trim())) return `${prefix}请选择有效的命名风格`
|
if (!rule.style.trim() || !validStyles.has(rule.style.trim())) return `${prefix}请选择有效的命名风格`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditionErr = validateEditableCondition(rule.condition)
|
||||||
|
if (conditionErr) return `${prefix}${conditionErr}`
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -2092,6 +2062,7 @@ function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||||
if (edited.from !== original.from || edited.to !== original.to) return true
|
if (edited.from !== original.from || edited.to !== original.to) return true
|
||||||
}
|
}
|
||||||
|
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -2143,31 +2114,37 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
|
|||||||
const result: HeaderRule[] = []
|
const result: HeaderRule[] = []
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
const condition = editableConditionToApi(rule.condition)
|
||||||
if (rule.action === 'set' && rule.key.trim()) {
|
if (rule.action === 'set' && rule.key.trim()) {
|
||||||
result.push({ action: 'set', key: rule.key.trim(), value: rule.value })
|
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...(condition ? { condition } : {}) })
|
||||||
} else if (rule.action === 'drop' && rule.key.trim()) {
|
} else if (rule.action === 'drop' && rule.key.trim()) {
|
||||||
result.push({ action: 'drop', key: rule.key.trim() })
|
result.push({ action: 'drop', key: rule.key.trim(), ...(condition ? { condition } : {}) })
|
||||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim(), ...(condition ? { condition } : {}) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.length > 0 ? result : null
|
return result.length > 0 ? result : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查规则是否有验证错误
|
function getHeaderValidationErrorForEndpoint(endpointId: string): string | null {
|
||||||
function hasValidationErrorsForEndpoint(endpointId: string): boolean {
|
|
||||||
const rules = getEndpointEditRules(endpointId)
|
const rules = getEndpointEditRules(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 (validateRuleKeyForEndpoint(endpointId, rule.key, i)) return true
|
const err = validateRuleKeyForEndpoint(endpointId, rule.key, i)
|
||||||
|
if (err) return `${prefix}${err}`
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
if (validateRenameFromForEndpoint(endpointId, rule.from, i)) return true
|
const fromErr = validateRenameFromForEndpoint(endpointId, rule.from, i)
|
||||||
if (validateRenameToForEndpoint(endpointId, rule.to, i)) return true
|
if (fromErr) return `${prefix}${fromErr}`
|
||||||
|
const toErr = validateRenameToForEndpoint(endpointId, rule.to, i)
|
||||||
|
if (toErr) return `${prefix}${toErr}`
|
||||||
}
|
}
|
||||||
|
const conditionErr = validateEditableCondition(rule.condition)
|
||||||
|
if (conditionErr) return `${prefix}${conditionErr}`
|
||||||
}
|
}
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新端点选择的格式的默认路径
|
// 新端点选择的格式的默认路径
|
||||||
@@ -2243,8 +2220,9 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
|||||||
if (!state || !state.url) return
|
if (!state || !state.url) return
|
||||||
|
|
||||||
// 检查规则是否有验证错误
|
// 检查规则是否有验证错误
|
||||||
if (hasValidationErrorsForEndpoint(endpoint.id)) {
|
const headerErr = getHeaderValidationErrorForEndpoint(endpoint.id)
|
||||||
showError('请修正请求头规则中的错误')
|
if (headerErr) {
|
||||||
|
showError(headerErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import type { BodyRuleCondition, BodyRuleConditionOp } from '@/api/endpoints'
|
||||||
|
|
||||||
|
export type ConditionSource = 'current' | 'original'
|
||||||
|
export type ConditionGroupMode = 'all' | 'any'
|
||||||
|
|
||||||
|
export interface EditableConditionLeaf {
|
||||||
|
kind: 'leaf'
|
||||||
|
path: string
|
||||||
|
op: BodyRuleConditionOp
|
||||||
|
value: string
|
||||||
|
source: ConditionSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableConditionGroup {
|
||||||
|
kind: 'group'
|
||||||
|
mode: ConditionGroupMode
|
||||||
|
children: EditableConditionNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditableConditionNode = EditableConditionLeaf | EditableConditionGroup
|
||||||
|
|
||||||
|
export const CONDITION_OP_OPTIONS: Array<{ value: BodyRuleConditionOp; label: string }> = [
|
||||||
|
{ value: 'eq', label: '等于' },
|
||||||
|
{ value: 'neq', label: '不等于' },
|
||||||
|
{ value: 'gt', label: '大于' },
|
||||||
|
{ value: 'lt', label: '小于' },
|
||||||
|
{ value: 'gte', label: '大于等于' },
|
||||||
|
{ value: 'lte', label: '小于等于' },
|
||||||
|
{ value: 'starts_with', label: '开头匹配' },
|
||||||
|
{ value: 'ends_with', label: '结尾匹配' },
|
||||||
|
{ value: 'contains', label: '包含' },
|
||||||
|
{ value: 'matches', label: '正则匹配' },
|
||||||
|
{ value: 'exists', label: '存在' },
|
||||||
|
{ value: 'not_exists', label: '不存在' },
|
||||||
|
{ value: 'in', label: '在列表中' },
|
||||||
|
{ value: 'type_is', label: '类型是' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const NUMERIC_OPS = new Set(['gt', 'lt', 'gte', 'lte'])
|
||||||
|
const STRING_OPS = new Set(['starts_with', 'ends_with'])
|
||||||
|
const TYPE_IS_VALUES = new Set(['string', 'number', 'boolean', 'array', 'object', 'null'])
|
||||||
|
|
||||||
|
export function createEmptyConditionLeaf(): EditableConditionLeaf {
|
||||||
|
return {
|
||||||
|
kind: 'leaf',
|
||||||
|
path: '',
|
||||||
|
op: 'eq',
|
||||||
|
value: '',
|
||||||
|
source: 'current',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConditionGroup(
|
||||||
|
mode: ConditionGroupMode = 'all',
|
||||||
|
children: EditableConditionNode[] = [createEmptyConditionLeaf()],
|
||||||
|
): EditableConditionGroup {
|
||||||
|
return {
|
||||||
|
kind: 'group',
|
||||||
|
mode,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneEditableCondition(node: EditableConditionNode): EditableConditionNode {
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
return {
|
||||||
|
kind: 'group',
|
||||||
|
mode: node.mode,
|
||||||
|
children: node.children.map(cloneEditableCondition),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...node }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conditionToEditable(condition?: BodyRuleCondition | null): EditableConditionNode | null {
|
||||||
|
if (!condition) return null
|
||||||
|
if ('all' in condition) {
|
||||||
|
return createConditionGroup(
|
||||||
|
'all',
|
||||||
|
condition.all.map(child => conditionToEditable(child) || createEmptyConditionLeaf()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ('any' in condition) {
|
||||||
|
return createConditionGroup(
|
||||||
|
'any',
|
||||||
|
condition.any.map(child => conditionToEditable(child) || createEmptyConditionLeaf()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'leaf',
|
||||||
|
path: condition.path || '',
|
||||||
|
op: condition.op || 'eq',
|
||||||
|
value: condition.value !== undefined
|
||||||
|
? (typeof condition.value === 'string' ? condition.value : JSON.stringify(condition.value))
|
||||||
|
: '',
|
||||||
|
source: condition.source === 'original' ? 'original' : 'current',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editableConditionToApi(node: EditableConditionNode | null): BodyRuleCondition | undefined {
|
||||||
|
if (!node) return undefined
|
||||||
|
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
const children = node.children
|
||||||
|
.map(child => editableConditionToApi(child))
|
||||||
|
.filter((child): child is BodyRuleCondition => !!child)
|
||||||
|
if (!children.length) return undefined
|
||||||
|
return node.mode === 'all' ? { all: children } : { any: children }
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = node.path.trim()
|
||||||
|
if (!path) return undefined
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
path,
|
||||||
|
op: node.op,
|
||||||
|
...(node.source === 'original' ? { source: 'original' as const } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.op === 'exists' || node.op === 'not_exists') {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = node.value.trim()
|
||||||
|
if (!raw) {
|
||||||
|
return { ...base, value: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { ...base, value: JSON.parse(raw) }
|
||||||
|
} catch {
|
||||||
|
return { ...base, value: raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConditionValueRequired(op: BodyRuleConditionOp): boolean {
|
||||||
|
return op !== 'exists' && op !== 'not_exists'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionValuePlaceholder(op: BodyRuleConditionOp): string {
|
||||||
|
if (op === 'in') return '["a", "b"]'
|
||||||
|
if (op === 'type_is') return 'string/number/boolean/...'
|
||||||
|
return '值'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBodyRuleConditionPathPlaceholder(path: string): string {
|
||||||
|
return path.includes('[*]') || /\[\d+-\d+\]/.test(path) ? '$item.字段名' : '字段路径'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conditionEquals(
|
||||||
|
left: EditableConditionNode | null,
|
||||||
|
right: EditableConditionNode | null,
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true
|
||||||
|
if (!left || !right) return false
|
||||||
|
if (left.kind !== right.kind) return false
|
||||||
|
|
||||||
|
if (left.kind === 'group' && right.kind === 'group') {
|
||||||
|
if (left.mode !== right.mode) return false
|
||||||
|
if (left.children.length !== right.children.length) return false
|
||||||
|
return left.children.every((child, i) => conditionEquals(child, right.children[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.kind === 'leaf' && right.kind === 'leaf') {
|
||||||
|
return left.path === right.path
|
||||||
|
&& left.op === right.op
|
||||||
|
&& left.value === right.value
|
||||||
|
&& left.source === right.source
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEditableCondition(node: EditableConditionNode | null): string | null {
|
||||||
|
if (!node) return null
|
||||||
|
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
if (!node.children.length) return '组合条件至少需要一个子条件'
|
||||||
|
for (let i = 0; i < node.children.length; i += 1) {
|
||||||
|
const err = validateEditableCondition(node.children[i])
|
||||||
|
if (err) return `子条件 ${i + 1}: ${err}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = node.path.trim()
|
||||||
|
if (!path) return '条件路径不能为空'
|
||||||
|
|
||||||
|
if (!isConditionValueRequired(node.op)) return null
|
||||||
|
|
||||||
|
const raw = node.value.trim()
|
||||||
|
let parsed: unknown = raw
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
parsed = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NUMERIC_OPS.has(node.op)) {
|
||||||
|
if (typeof parsed !== 'number' || Number.isNaN(parsed)) return '数值条件必须填写数字'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STRING_OPS.has(node.op)) {
|
||||||
|
if (typeof parsed !== 'string') return '该条件值必须为字符串'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.op === 'matches') {
|
||||||
|
if (typeof parsed !== 'string' || !parsed) return '正则条件值不能为空'
|
||||||
|
try {
|
||||||
|
new RegExp(parsed)
|
||||||
|
return null
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return `正则表达式无效:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.op === 'in') {
|
||||||
|
if (!Array.isArray(parsed)) return 'in 条件值必须是 JSON 数组'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.op === 'type_is') {
|
||||||
|
if (typeof parsed !== 'string' || !TYPE_IS_VALUES.has(parsed)) {
|
||||||
|
return 'type_is 仅支持 string/number/boolean/array/object/null'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -366,7 +366,7 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
统一的 endpoint 测试方法,支持 OAuth/Antigravity/Kiro 等特殊路由。
|
统一的 endpoint 测试方法,支持 OAuth/Antigravity/Kiro 等特殊路由。
|
||||||
"""
|
"""
|
||||||
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
||||||
from src.api.handlers.base.request_builder import apply_body_rules
|
from src.api.handlers.base.request_builder import apply_body_rules, evaluate_condition
|
||||||
from src.core.api_format.headers import HeaderBuilder
|
from src.core.api_format.headers import HeaderBuilder
|
||||||
from src.core.provider_types import ProviderType
|
from src.core.provider_types import ProviderType
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if body_rules:
|
if body_rules:
|
||||||
body = apply_body_rules(body, body_rules)
|
body = apply_body_rules(body, body_rules, original_body=body)
|
||||||
|
|
||||||
if is_antigravity:
|
if is_antigravity:
|
||||||
from src.services.provider.adapters.antigravity.envelope import (
|
from src.services.provider.adapters.antigravity.envelope import (
|
||||||
@@ -560,7 +560,13 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
|
|
||||||
header_builder = HeaderBuilder()
|
header_builder = HeaderBuilder()
|
||||||
header_builder.add_many(headers)
|
header_builder.add_many(headers)
|
||||||
header_builder.apply_rules(header_rules, protected_keys)
|
header_builder.apply_rules(
|
||||||
|
header_rules,
|
||||||
|
protected_keys,
|
||||||
|
body=body,
|
||||||
|
original_body=body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
headers = header_builder.build()
|
headers = header_builder.build()
|
||||||
|
|
||||||
# ---- Execute ----
|
# ---- Execute ----
|
||||||
|
|||||||
@@ -599,10 +599,24 @@ _ITEM_PREFIX = "$item."
|
|||||||
_ITEM_EXACT = "$item"
|
_ITEM_EXACT = "$item"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_condition_children(
|
||||||
|
condition: dict[str, Any],
|
||||||
|
) -> tuple[str, list[Any]] | None:
|
||||||
|
"""如果 condition 是 all/any 组合节点,返回 (key, children);否则返回 None。"""
|
||||||
|
for key in ("all", "any"):
|
||||||
|
children = condition.get(key)
|
||||||
|
if isinstance(children, list):
|
||||||
|
return key, children
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _has_item_ref(condition: dict[str, Any] | None) -> bool:
|
def _has_item_ref(condition: dict[str, Any] | None) -> bool:
|
||||||
"""检查 condition 的 path 是否包含 $item 引用"""
|
"""检查 condition 的 path 是否包含 $item 引用"""
|
||||||
if not condition or not isinstance(condition, dict):
|
if not condition or not isinstance(condition, dict):
|
||||||
return False
|
return False
|
||||||
|
group = _get_condition_children(condition)
|
||||||
|
if group is not None:
|
||||||
|
return any(_has_item_ref(c) for c in group[1] if isinstance(c, dict))
|
||||||
path = condition.get("path", "")
|
path = condition.get("path", "")
|
||||||
return isinstance(path, str) and (
|
return isinstance(path, str) and (
|
||||||
path.strip().startswith(_ITEM_PREFIX) or path.strip() == _ITEM_EXACT
|
path.strip().startswith(_ITEM_PREFIX) or path.strip() == _ITEM_EXACT
|
||||||
@@ -624,6 +638,16 @@ def _resolve_item_condition(
|
|||||||
item_path_prefix = "tools[0]"
|
item_path_prefix = "tools[0]"
|
||||||
-> {"path": "tools[0]", "op": "type_is", "value": "object"}
|
-> {"path": "tools[0]", "op": "type_is", "value": "object"}
|
||||||
"""
|
"""
|
||||||
|
group = _get_condition_children(condition)
|
||||||
|
if group is not None:
|
||||||
|
key, children = group
|
||||||
|
return {
|
||||||
|
key: [
|
||||||
|
_resolve_item_condition(c, item_path_prefix) if isinstance(c, dict) else c
|
||||||
|
for c in children
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
resolved = dict(condition)
|
resolved = dict(condition)
|
||||||
raw_path = resolved.get("path", "").strip()
|
raw_path = resolved.get("path", "").strip()
|
||||||
if raw_path == _ITEM_EXACT:
|
if raw_path == _ITEM_EXACT:
|
||||||
@@ -667,6 +691,7 @@ def _iter_wildcard_targets(
|
|||||||
condition: dict[str, Any] | None,
|
condition: dict[str, Any] | None,
|
||||||
item_condition: bool,
|
item_condition: bool,
|
||||||
*,
|
*,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
require_leaf: bool = False,
|
require_leaf: bool = False,
|
||||||
reverse: bool = False,
|
reverse: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
@@ -696,7 +721,7 @@ def _iter_wildcard_targets(
|
|||||||
if item_condition:
|
if item_condition:
|
||||||
prefix = _get_item_prefix_from_concrete(concrete_segs, parts)
|
prefix = _get_item_prefix_from_concrete(concrete_segs, parts)
|
||||||
resolved = _resolve_item_condition(condition, prefix) # type: ignore[arg-type]
|
resolved = _resolve_item_condition(condition, prefix) # type: ignore[arg-type]
|
||||||
if not _evaluate_condition(result, resolved):
|
if not evaluate_condition(result, resolved, original_body=original_body):
|
||||||
continue
|
continue
|
||||||
targets.append(_segments_to_path(concrete_segs))
|
targets.append(_segments_to_path(concrete_segs))
|
||||||
return targets
|
return targets
|
||||||
@@ -715,7 +740,11 @@ _SIMPLE_TYPE_MAP: dict[str, type] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _evaluate_condition(body: dict[str, Any], condition: dict[str, Any]) -> bool:
|
def evaluate_condition(
|
||||||
|
body: dict[str, Any],
|
||||||
|
condition: dict[str, Any],
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
评估单个条件表达式,决定规则是否应该执行。
|
评估单个条件表达式,决定规则是否应该执行。
|
||||||
|
|
||||||
@@ -726,6 +755,24 @@ def _evaluate_condition(body: dict[str, Any], condition: dict[str, Any]) -> bool
|
|||||||
if not isinstance(condition, dict):
|
if not isinstance(condition, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if "all" in condition:
|
||||||
|
children = condition.get("all")
|
||||||
|
if not isinstance(children, list) or not children:
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
isinstance(child, dict) and evaluate_condition(body, child, original_body=original_body)
|
||||||
|
for child in children
|
||||||
|
)
|
||||||
|
|
||||||
|
if "any" in condition:
|
||||||
|
children = condition.get("any")
|
||||||
|
if not isinstance(children, list) or not children:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(child, dict) and evaluate_condition(body, child, original_body=original_body)
|
||||||
|
for child in children
|
||||||
|
)
|
||||||
|
|
||||||
op = condition.get("op")
|
op = condition.get("op")
|
||||||
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
||||||
return False
|
return False
|
||||||
@@ -734,7 +781,12 @@ def _evaluate_condition(body: dict[str, Any], condition: dict[str, Any]) -> bool
|
|||||||
if not isinstance(path, str) or not path.strip():
|
if not isinstance(path, str) or not path.strip():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
found, current_val = _get_nested_value(body, path.strip())
|
source = condition.get("source", "current")
|
||||||
|
if not isinstance(source, str) or source not in {"current", "original"}:
|
||||||
|
return False
|
||||||
|
target = original_body if source == "original" and original_body is not None else body
|
||||||
|
|
||||||
|
found, current_val = _get_nested_value(target, path.strip())
|
||||||
|
|
||||||
# 存在性检查:不需要 value
|
# 存在性检查:不需要 value
|
||||||
if op == "exists":
|
if op == "exists":
|
||||||
@@ -817,6 +869,7 @@ def apply_body_rules(
|
|||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
rules: list[dict[str, Any]],
|
rules: list[dict[str, Any]],
|
||||||
protected_keys: frozenset[str] | None = None,
|
protected_keys: frozenset[str] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
应用请求体规则
|
应用请求体规则
|
||||||
@@ -848,6 +901,7 @@ def apply_body_rules(
|
|||||||
body: 原始请求体
|
body: 原始请求体
|
||||||
rules: 规则列表
|
rules: 规则列表
|
||||||
protected_keys: 受保护的字段(不能被 set/drop/rename 修改)
|
protected_keys: 受保护的字段(不能被 set/drop/rename 修改)
|
||||||
|
original_body: 条件评估使用的原始请求体;未提供时回退到当前 body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
应用规则后的请求体
|
应用规则后的请求体
|
||||||
@@ -870,7 +924,7 @@ def apply_body_rules(
|
|||||||
condition = rule.get("condition")
|
condition = rule.get("condition")
|
||||||
item_condition = _has_item_ref(condition)
|
item_condition = _has_item_ref(condition)
|
||||||
if condition is not None and not item_condition:
|
if condition is not None and not item_condition:
|
||||||
if not _evaluate_condition(result, condition):
|
if not evaluate_condition(result, condition, original_body=original_body):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
action = rule.get("action")
|
action = rule.get("action")
|
||||||
@@ -884,7 +938,7 @@ def apply_body_rules(
|
|||||||
continue
|
continue
|
||||||
parts = _parse_path(path)
|
parts = _parse_path(path)
|
||||||
for target_path in _iter_wildcard_targets(
|
for target_path in _iter_wildcard_targets(
|
||||||
result, path, parts, condition, item_condition
|
result, path, parts, condition, item_condition, original_body=original_body
|
||||||
):
|
):
|
||||||
value = rule.get("value")
|
value = rule.get("value")
|
||||||
if _contains_original_placeholder(value):
|
if _contains_original_placeholder(value):
|
||||||
@@ -903,6 +957,7 @@ def apply_body_rules(
|
|||||||
parts,
|
parts,
|
||||||
condition,
|
condition,
|
||||||
item_condition,
|
item_condition,
|
||||||
|
original_body=original_body,
|
||||||
require_leaf=True,
|
require_leaf=True,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
):
|
):
|
||||||
@@ -940,7 +995,13 @@ def apply_body_rules(
|
|||||||
continue
|
continue
|
||||||
parts = _parse_path(path)
|
parts = _parse_path(path)
|
||||||
for target_path in _iter_wildcard_targets(
|
for target_path in _iter_wildcard_targets(
|
||||||
result, path, parts, condition, item_condition, require_leaf=True
|
result,
|
||||||
|
path,
|
||||||
|
parts,
|
||||||
|
condition,
|
||||||
|
item_condition,
|
||||||
|
original_body=original_body,
|
||||||
|
require_leaf=True,
|
||||||
):
|
):
|
||||||
found, target = _get_nested_value(result, target_path)
|
found, target = _get_nested_value(result, target_path)
|
||||||
if found and isinstance(target, list):
|
if found and isinstance(target, list):
|
||||||
@@ -984,7 +1045,13 @@ def apply_body_rules(
|
|||||||
|
|
||||||
parts = _parse_path(path)
|
parts = _parse_path(path)
|
||||||
for target_path in _iter_wildcard_targets(
|
for target_path in _iter_wildcard_targets(
|
||||||
result, path, parts, condition, item_condition, require_leaf=True
|
result,
|
||||||
|
path,
|
||||||
|
parts,
|
||||||
|
condition,
|
||||||
|
item_condition,
|
||||||
|
original_body=original_body,
|
||||||
|
require_leaf=True,
|
||||||
):
|
):
|
||||||
found, current_val = _get_nested_value(result, target_path)
|
found, current_val = _get_nested_value(result, target_path)
|
||||||
if found and isinstance(current_val, str):
|
if found and isinstance(current_val, str):
|
||||||
@@ -1000,7 +1067,13 @@ def apply_body_rules(
|
|||||||
continue
|
continue
|
||||||
parts = _parse_path(path)
|
parts = _parse_path(path)
|
||||||
for target_path in _iter_wildcard_targets(
|
for target_path in _iter_wildcard_targets(
|
||||||
result, path, parts, condition, item_condition, require_leaf=True
|
result,
|
||||||
|
path,
|
||||||
|
parts,
|
||||||
|
condition,
|
||||||
|
item_condition,
|
||||||
|
original_body=original_body,
|
||||||
|
require_leaf=True,
|
||||||
):
|
):
|
||||||
found, current_val = _get_nested_value(result, target_path)
|
found, current_val = _get_nested_value(result, target_path)
|
||||||
if found and isinstance(current_val, str):
|
if found and isinstance(current_val, str):
|
||||||
@@ -1038,6 +1111,8 @@ class RequestBuilder(ABC):
|
|||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
pre_computed_auth: tuple[str, str] | None = None,
|
pre_computed_auth: tuple[str, str] | None = None,
|
||||||
envelope: ProviderEnvelope | None = None,
|
envelope: ProviderEnvelope | None = None,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""构建请求头"""
|
"""构建请求头"""
|
||||||
pass
|
pass
|
||||||
@@ -1080,7 +1155,7 @@ class RequestBuilder(ABC):
|
|||||||
# 应用请求体规则(如果 endpoint 配置了 body_rules)
|
# 应用请求体规则(如果 endpoint 配置了 body_rules)
|
||||||
body_rules = getattr(endpoint, "body_rules", None)
|
body_rules = getattr(endpoint, "body_rules", None)
|
||||||
if body_rules:
|
if body_rules:
|
||||||
payload = apply_body_rules(payload, body_rules)
|
payload = apply_body_rules(payload, body_rules, original_body=original_body)
|
||||||
|
|
||||||
headers = self.build_headers(
|
headers = self.build_headers(
|
||||||
original_headers,
|
original_headers,
|
||||||
@@ -1089,6 +1164,8 @@ class RequestBuilder(ABC):
|
|||||||
extra_headers=extra_headers,
|
extra_headers=extra_headers,
|
||||||
pre_computed_auth=pre_computed_auth,
|
pre_computed_auth=pre_computed_auth,
|
||||||
envelope=envelope,
|
envelope=envelope,
|
||||||
|
body=payload,
|
||||||
|
original_body=original_body,
|
||||||
)
|
)
|
||||||
return payload, headers
|
return payload, headers
|
||||||
|
|
||||||
@@ -1191,6 +1268,8 @@ class PassthroughRequestBuilder(RequestBuilder):
|
|||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
pre_computed_auth: tuple[str, str] | None = None,
|
pre_computed_auth: tuple[str, str] | None = None,
|
||||||
envelope: ProviderEnvelope | None = None,
|
envelope: ProviderEnvelope | None = None,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
透传请求头 - 清理敏感头部(黑名单),透传其他所有头部
|
透传请求头 - 清理敏感头部(黑名单),透传其他所有头部
|
||||||
@@ -1239,7 +1318,13 @@ class PassthroughRequestBuilder(RequestBuilder):
|
|||||||
# 3. 应用 endpoint 的请求头规则(认证头受保护,无法通过 rules 设置)
|
# 3. 应用 endpoint 的请求头规则(认证头受保护,无法通过 rules 设置)
|
||||||
header_rules = getattr(endpoint, "header_rules", None)
|
header_rules = getattr(endpoint, "header_rules", None)
|
||||||
if header_rules:
|
if header_rules:
|
||||||
builder.apply_rules(header_rules, protected_keys)
|
builder.apply_rules(
|
||||||
|
header_rules,
|
||||||
|
protected_keys,
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
|
|
||||||
# 4. 添加额外头部
|
# 4. 添加额外头部
|
||||||
effective_extra_headers = self._merge_extra_headers_with_original(
|
effective_extra_headers = self._merge_extra_headers_with_original(
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""测试 Gemini API 模型连接性(非流式)"""
|
"""测试 Gemini API 模型连接性(非流式)"""
|
||||||
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
||||||
from src.api.handlers.base.request_builder import apply_body_rules
|
from src.api.handlers.base.request_builder import apply_body_rules, evaluate_condition
|
||||||
from src.core.api_format.headers import HeaderBuilder
|
from src.core.api_format.headers import HeaderBuilder
|
||||||
|
|
||||||
# Gemini需要从request_data或model_name参数获取model名称
|
# Gemini需要从request_data或model_name参数获取model名称
|
||||||
@@ -344,7 +344,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
|
|
||||||
# 应用请求体规则(在格式转换后应用,确保规则效果不被覆盖)
|
# 应用请求体规则(在格式转换后应用,确保规则效果不被覆盖)
|
||||||
if body_rules:
|
if body_rules:
|
||||||
body = apply_body_rules(body, body_rules)
|
body = apply_body_rules(body, body_rules, original_body=body)
|
||||||
|
|
||||||
# Antigravity 需要将请求体包装为 v1internal 信封格式
|
# Antigravity 需要将请求体包装为 v1internal 信封格式
|
||||||
if is_antigravity:
|
if is_antigravity:
|
||||||
@@ -379,7 +379,13 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
|
|
||||||
header_builder = HeaderBuilder()
|
header_builder = HeaderBuilder()
|
||||||
header_builder.add_many(headers)
|
header_builder.add_many(headers)
|
||||||
header_builder.apply_rules(header_rules, protected_keys)
|
header_builder.apply_rules(
|
||||||
|
header_rules,
|
||||||
|
protected_keys,
|
||||||
|
body=body,
|
||||||
|
original_body=body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
headers = header_builder.build()
|
headers = header_builder.build()
|
||||||
|
|
||||||
return await run_endpoint_check(
|
return await run_endpoint_check(
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.handlers.base.request_builder import apply_body_rules, get_provider_auth
|
from src.api.handlers.base.request_builder import (
|
||||||
|
apply_body_rules,
|
||||||
|
evaluate_condition,
|
||||||
|
get_provider_auth,
|
||||||
|
)
|
||||||
from src.api.handlers.base.video_handler_base import (
|
from src.api.handlers.base.video_handler_base import (
|
||||||
VideoHandlerBase,
|
VideoHandlerBase,
|
||||||
normalize_gemini_operation_id,
|
normalize_gemini_operation_id,
|
||||||
@@ -160,14 +164,22 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
converted_body["seconds"] = str(converted_body["seconds"])
|
converted_body["seconds"] = str(converted_body["seconds"])
|
||||||
|
|
||||||
if endpoint_body_rules:
|
if endpoint_body_rules:
|
||||||
converted_body = apply_body_rules(converted_body, endpoint_body_rules)
|
converted_body = apply_body_rules(
|
||||||
|
converted_body,
|
||||||
|
endpoint_body_rules,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 构建 OpenAI 风格的 URL
|
# 构建 OpenAI 风格的 URL
|
||||||
upstream_url = self._build_openai_upstream_url(endpoint.base_url)
|
upstream_url = self._build_openai_upstream_url(endpoint.base_url)
|
||||||
|
|
||||||
# 构建 OpenAI 风格的请求头
|
# 构建 OpenAI 风格的请求头
|
||||||
headers = self._build_openai_upstream_headers(
|
headers = self._build_openai_upstream_headers(
|
||||||
original_headers, upstream_key, endpoint
|
original_headers,
|
||||||
|
upstream_key,
|
||||||
|
endpoint,
|
||||||
|
body=converted_body,
|
||||||
|
original_body=original_request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
@@ -178,11 +190,20 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
original_request_body.copy() if endpoint_body_rules else original_request_body
|
original_request_body.copy() if endpoint_body_rules else original_request_body
|
||||||
)
|
)
|
||||||
if endpoint_body_rules:
|
if endpoint_body_rules:
|
||||||
request_body = apply_body_rules(request_body, endpoint_body_rules)
|
request_body = apply_body_rules(
|
||||||
|
request_body,
|
||||||
|
endpoint_body_rules,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
upstream_url = self._build_upstream_url(endpoint.base_url, internal_request.model)
|
upstream_url = self._build_upstream_url(endpoint.base_url, internal_request.model)
|
||||||
headers = self._build_upstream_headers(
|
headers = self._build_upstream_headers(
|
||||||
original_headers, upstream_key, endpoint, auth_info
|
original_headers,
|
||||||
|
upstream_key,
|
||||||
|
endpoint,
|
||||||
|
auth_info,
|
||||||
|
body=request_body,
|
||||||
|
original_body=original_request_body,
|
||||||
)
|
)
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
return await client.post(upstream_url, headers=headers, json=request_body)
|
return await client.post(upstream_url, headers=headers, json=request_body)
|
||||||
@@ -294,6 +315,8 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
"", # key 不重要,只是用于记录
|
"", # key 不重要,只是用于记录
|
||||||
outcome.candidate.endpoint,
|
outcome.candidate.endpoint,
|
||||||
None, # auth_info
|
None, # auth_info
|
||||||
|
body=converted_request_body,
|
||||||
|
original_body=original_request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
UsageService.finalize_submitted(
|
UsageService.finalize_submitted(
|
||||||
@@ -531,6 +554,9 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
upstream_key: str,
|
upstream_key: str,
|
||||||
endpoint: ProviderEndpoint,
|
endpoint: ProviderEndpoint,
|
||||||
auth_info: Any | None,
|
auth_info: Any | None,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
||||||
endpoint_sig = make_signature_key(
|
endpoint_sig = make_signature_key(
|
||||||
@@ -543,6 +569,9 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
upstream_key,
|
upstream_key,
|
||||||
endpoint_headers=extra_headers,
|
endpoint_headers=extra_headers,
|
||||||
header_rules=getattr(endpoint, "header_rules", None),
|
header_rules=getattr(endpoint, "header_rules", None),
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
)
|
)
|
||||||
if auth_info:
|
if auth_info:
|
||||||
# 覆盖为 OAuth2 Bearer(Vertex AI)
|
# 覆盖为 OAuth2 Bearer(Vertex AI)
|
||||||
@@ -574,6 +603,9 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
original_headers: dict[str, str],
|
original_headers: dict[str, str],
|
||||||
upstream_key: str,
|
upstream_key: str,
|
||||||
endpoint: ProviderEndpoint,
|
endpoint: ProviderEndpoint,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""构建 OpenAI 格式的请求头"""
|
"""构建 OpenAI 格式的请求头"""
|
||||||
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
||||||
@@ -587,6 +619,9 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
upstream_key,
|
upstream_key,
|
||||||
endpoint_headers=extra_headers,
|
endpoint_headers=extra_headers,
|
||||||
header_rules=getattr(endpoint, "header_rules", None),
|
header_rules=getattr(endpoint, "header_rules", None),
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_task_record(
|
def _create_task_record(
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.handlers.base.request_builder import apply_body_rules, get_provider_auth
|
from src.api.handlers.base.request_builder import (
|
||||||
|
apply_body_rules,
|
||||||
|
evaluate_condition,
|
||||||
|
get_provider_auth,
|
||||||
|
)
|
||||||
from src.api.handlers.base.video_handler_base import VideoHandlerBase, sanitize_error_message
|
from src.api.handlers.base.video_handler_base import VideoHandlerBase, sanitize_error_message
|
||||||
from src.clients.http_client import HTTPClientPool
|
from src.clients.http_client import HTTPClientPool
|
||||||
from src.config.settings import config
|
from src.config.settings import config
|
||||||
@@ -154,7 +158,11 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
converted_body["model"] = internal_request.model
|
converted_body["model"] = internal_request.model
|
||||||
|
|
||||||
if endpoint_body_rules:
|
if endpoint_body_rules:
|
||||||
converted_body = apply_body_rules(converted_body, endpoint_body_rules)
|
converted_body = apply_body_rules(
|
||||||
|
converted_body,
|
||||||
|
endpoint_body_rules,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 构建 Gemini 风格的 URL
|
# 构建 Gemini 风格的 URL
|
||||||
upstream_url = self._build_gemini_upstream_url(
|
upstream_url = self._build_gemini_upstream_url(
|
||||||
@@ -164,7 +172,12 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
# 构建 Gemini 风格的请求头
|
# 构建 Gemini 风格的请求头
|
||||||
auth_info = await get_provider_auth(endpoint, _provider_key)
|
auth_info = await get_provider_auth(endpoint, _provider_key)
|
||||||
headers = self._build_gemini_upstream_headers(
|
headers = self._build_gemini_upstream_headers(
|
||||||
original_headers, upstream_key, endpoint, auth_info
|
original_headers,
|
||||||
|
upstream_key,
|
||||||
|
endpoint,
|
||||||
|
auth_info,
|
||||||
|
body=converted_body,
|
||||||
|
original_body=original_request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
@@ -172,10 +185,20 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
else:
|
else:
|
||||||
# 原始 OpenAI 格式
|
# 原始 OpenAI 格式
|
||||||
if endpoint_body_rules:
|
if endpoint_body_rules:
|
||||||
request_body = apply_body_rules(request_body, endpoint_body_rules)
|
request_body = apply_body_rules(
|
||||||
|
request_body,
|
||||||
|
endpoint_body_rules,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
upstream_url = self._build_upstream_url(endpoint.base_url)
|
upstream_url = self._build_upstream_url(endpoint.base_url)
|
||||||
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
|
headers = self._build_upstream_headers(
|
||||||
|
original_headers,
|
||||||
|
upstream_key,
|
||||||
|
endpoint,
|
||||||
|
body=request_body,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
return await client.post(upstream_url, headers=headers, json=request_body)
|
return await client.post(upstream_url, headers=headers, json=request_body)
|
||||||
|
|
||||||
@@ -297,6 +320,8 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
original_headers,
|
original_headers,
|
||||||
"", # key 不重要,只是用于记录
|
"", # key 不重要,只是用于记录
|
||||||
outcome.candidate.endpoint,
|
outcome.candidate.endpoint,
|
||||||
|
body=converted_request_body,
|
||||||
|
original_body=original_request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
UsageService.finalize_submitted(
|
UsageService.finalize_submitted(
|
||||||
@@ -502,8 +527,6 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
upstream_url = self._build_upstream_url(
|
upstream_url = self._build_upstream_url(
|
||||||
endpoint.base_url, f"{original_task.external_task_id}/remix"
|
endpoint.base_url, f"{original_task.external_task_id}/remix"
|
||||||
)
|
)
|
||||||
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
|
|
||||||
|
|
||||||
# 确保 seconds 字段为字符串类型(上游 Go 服务要求 string)
|
# 确保 seconds 字段为字符串类型(上游 Go 服务要求 string)
|
||||||
request_body = original_request_body.copy()
|
request_body = original_request_body.copy()
|
||||||
if "seconds" in request_body and request_body["seconds"] is not None:
|
if "seconds" in request_body and request_body["seconds"] is not None:
|
||||||
@@ -512,7 +535,19 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
# 应用端点的请求体规则
|
# 应用端点的请求体规则
|
||||||
endpoint_body_rules = getattr(endpoint, "body_rules", None)
|
endpoint_body_rules = getattr(endpoint, "body_rules", None)
|
||||||
if endpoint_body_rules:
|
if endpoint_body_rules:
|
||||||
request_body = apply_body_rules(request_body, endpoint_body_rules)
|
request_body = apply_body_rules(
|
||||||
|
request_body,
|
||||||
|
endpoint_body_rules,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = self._build_upstream_headers(
|
||||||
|
original_headers,
|
||||||
|
upstream_key,
|
||||||
|
endpoint,
|
||||||
|
body=request_body,
|
||||||
|
original_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
response = await client.post(upstream_url, headers=headers, json=request_body)
|
response = await client.post(upstream_url, headers=headers, json=request_body)
|
||||||
@@ -787,7 +822,13 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
def _build_upstream_headers(
|
def _build_upstream_headers(
|
||||||
self, original_headers: dict[str, str], upstream_key: str, endpoint: ProviderEndpoint
|
self,
|
||||||
|
original_headers: dict[str, str],
|
||||||
|
upstream_key: str,
|
||||||
|
endpoint: ProviderEndpoint,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
||||||
endpoint_sig = make_signature_key(
|
endpoint_sig = make_signature_key(
|
||||||
@@ -800,6 +841,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
upstream_key,
|
upstream_key,
|
||||||
endpoint_headers=extra_headers,
|
endpoint_headers=extra_headers,
|
||||||
header_rules=getattr(endpoint, "header_rules", None),
|
header_rules=getattr(endpoint, "header_rules", None),
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -819,6 +863,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
upstream_key: str,
|
upstream_key: str,
|
||||||
endpoint: ProviderEndpoint,
|
endpoint: ProviderEndpoint,
|
||||||
auth_info: Any | None,
|
auth_info: Any | None,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""构建 Gemini 格式的请求头"""
|
"""构建 Gemini 格式的请求头"""
|
||||||
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
||||||
@@ -832,6 +879,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
upstream_key,
|
upstream_key,
|
||||||
endpoint_headers=extra_headers,
|
endpoint_headers=extra_headers,
|
||||||
header_rules=getattr(endpoint, "header_rules", None),
|
header_rules=getattr(endpoint, "header_rules", None),
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
)
|
)
|
||||||
if auth_info:
|
if auth_info:
|
||||||
# 覆盖为 OAuth2 Bearer(Vertex AI)
|
# 覆盖为 OAuth2 Bearer(Vertex AI)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Set as AbstractSet
|
from collections.abc import Set as AbstractSet
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from src.core.api_format.enums import ApiFamily
|
from src.core.api_format.enums import ApiFamily
|
||||||
from src.core.api_format.metadata import (
|
from src.core.api_format.metadata import (
|
||||||
@@ -342,6 +342,12 @@ class HeaderBuilder:
|
|||||||
self,
|
self,
|
||||||
rules: list[dict[str, Any]],
|
rules: list[dict[str, Any]],
|
||||||
protected_keys: AbstractSet[str] | None = None,
|
protected_keys: AbstractSet[str] | None = None,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
|
condition_evaluator: (
|
||||||
|
Callable[[dict[str, Any], dict[str, Any], dict[str, Any] | None], bool] | None
|
||||||
|
) = None,
|
||||||
) -> HeaderBuilder:
|
) -> HeaderBuilder:
|
||||||
"""
|
"""
|
||||||
应用请求头规则
|
应用请求头规则
|
||||||
@@ -354,10 +360,23 @@ class HeaderBuilder:
|
|||||||
Args:
|
Args:
|
||||||
rules: 规则列表
|
rules: 规则列表
|
||||||
protected_keys: 受保护的 key(不能被 set/drop/rename 修改)
|
protected_keys: 受保护的 key(不能被 set/drop/rename 修改)
|
||||||
|
body: 条件规则评估用的当前请求体
|
||||||
|
original_body: 条件规则评估用的原始请求体
|
||||||
|
condition_evaluator: 条件评估函数;未提供时带 condition 的规则 fail-closed
|
||||||
"""
|
"""
|
||||||
protected_lower = {k.lower() for k in protected_keys} if protected_keys else set()
|
protected_lower = {k.lower() for k in protected_keys} if protected_keys else set()
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
|
condition = rule.get("condition")
|
||||||
|
if condition is not None:
|
||||||
|
if (
|
||||||
|
not isinstance(condition, dict)
|
||||||
|
or body is None
|
||||||
|
or condition_evaluator is None
|
||||||
|
or not condition_evaluator(body, condition, original_body)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
action = rule.get("action")
|
action = rule.get("action")
|
||||||
|
|
||||||
if action == "set":
|
if action == "set":
|
||||||
@@ -436,6 +455,11 @@ def build_upstream_headers_for_endpoint(
|
|||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
drop_headers: frozenset[str] | None = None,
|
drop_headers: frozenset[str] | None = None,
|
||||||
header_rules: list[dict[str, Any]] | None = None,
|
header_rules: list[dict[str, Any]] | None = None,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
original_body: dict[str, Any] | None = None,
|
||||||
|
condition_evaluator: (
|
||||||
|
Callable[[dict[str, Any], dict[str, Any], dict[str, Any] | None], bool] | None
|
||||||
|
) = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
新模式:构建发送给上游 Provider 的请求头(基于 endpoint signature)。
|
新模式:构建发送给上游 Provider 的请求头(基于 endpoint signature)。
|
||||||
@@ -466,7 +490,13 @@ def build_upstream_headers_for_endpoint(
|
|||||||
|
|
||||||
# 应用用户自定义的请求头规则(认证头受保护)
|
# 应用用户自定义的请求头规则(认证头受保护)
|
||||||
if header_rules:
|
if header_rules:
|
||||||
builder.apply_rules(header_rules, protected_keys)
|
builder.apply_rules(
|
||||||
|
header_rules,
|
||||||
|
protected_keys,
|
||||||
|
body=body,
|
||||||
|
original_body=original_body,
|
||||||
|
condition_evaluator=condition_evaluator,
|
||||||
|
)
|
||||||
|
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
builder.add_many(extra_headers)
|
builder.add_many(extra_headers)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from src.models.admin_requests import (
|
|||||||
# 实际验证在 headers.py 的 apply_rules 中处理
|
# 实际验证在 headers.py 的 apply_rules 中处理
|
||||||
HeaderRule = dict[str, Any]
|
HeaderRule = dict[str, Any]
|
||||||
|
|
||||||
|
_HEADER_RULE_ACTIONS: frozenset[str] = frozenset({"set", "drop", "rename"})
|
||||||
|
|
||||||
|
|
||||||
# ========== Body Rule 类型定义 ==========
|
# ========== Body Rule 类型定义 ==========
|
||||||
# 请求体规则支持六种操作:
|
# 请求体规则支持六种操作:
|
||||||
@@ -79,6 +81,8 @@ _TYPE_IS_VALUES: frozenset[str] = frozenset(
|
|||||||
{"string", "number", "boolean", "array", "object", "null"}
|
{"string", "number", "boolean", "array", "object", "null"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_CONDITION_SOURCES: frozenset[str] = frozenset({"current", "original"})
|
||||||
|
|
||||||
|
|
||||||
def parse_re_flags(flags_str: str) -> int:
|
def parse_re_flags(flags_str: str) -> int:
|
||||||
"""将 flags 字符串(i/m/s)转换为 re 标志位。
|
"""将 flags 字符串(i/m/s)转换为 re 标志位。
|
||||||
@@ -96,21 +100,42 @@ def parse_re_flags(flags_str: str) -> int:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _validate_condition(condition: Any, rule_idx: int) -> None:
|
def _validate_condition(condition: Any, rule_label: str) -> None:
|
||||||
"""校验单条规则的 condition 结构"""
|
"""校验单条规则的 condition 结构。"""
|
||||||
if not isinstance(condition, dict):
|
if not isinstance(condition, dict):
|
||||||
raise ValueError(f"body_rules[{rule_idx}]: condition 必须是 JSON 对象")
|
raise ValueError(f"{rule_label}: condition 必须是 JSON 对象")
|
||||||
|
|
||||||
|
has_all = "all" in condition
|
||||||
|
has_any = "any" in condition
|
||||||
|
if has_all or has_any:
|
||||||
|
if has_all and has_any:
|
||||||
|
raise ValueError(f"{rule_label}: condition 不能同时包含 all 和 any")
|
||||||
|
|
||||||
|
key = "all" if has_all else "any"
|
||||||
|
children = condition.get(key)
|
||||||
|
if not isinstance(children, list) or not children:
|
||||||
|
raise ValueError(f"{rule_label}: condition.{key} 必须是非空数组")
|
||||||
|
|
||||||
|
for idx, child in enumerate(children):
|
||||||
|
_validate_condition(child, f"{rule_label}: condition.{key}[{idx}]")
|
||||||
|
return
|
||||||
|
|
||||||
|
source = condition.get("source", "current")
|
||||||
|
if not isinstance(source, str) or source not in _CONDITION_SOURCES:
|
||||||
|
raise ValueError(
|
||||||
|
f"{rule_label}: condition.source 必须是 {sorted(_CONDITION_SOURCES)} 之一,"
|
||||||
|
f"当前值: {source!r}"
|
||||||
|
)
|
||||||
|
|
||||||
op = condition.get("op")
|
op = condition.get("op")
|
||||||
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"body_rules[{rule_idx}]: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一,"
|
f"{rule_label}: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一," f"当前值: {op!r}"
|
||||||
f"当前值: {op!r}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
path = condition.get("path")
|
path = condition.get("path")
|
||||||
if not isinstance(path, str) or not path.strip():
|
if not isinstance(path, str) or not path.strip():
|
||||||
raise ValueError(f"body_rules[{rule_idx}]: condition 必须提供非空 path")
|
raise ValueError(f"{rule_label}: condition 必须提供非空 path")
|
||||||
|
|
||||||
# exists / not_exists 不需要 value
|
# exists / not_exists 不需要 value
|
||||||
if op in ("exists", "not_exists"):
|
if op in ("exists", "not_exists"):
|
||||||
@@ -121,38 +146,76 @@ def _validate_condition(condition: Any, rule_idx: int) -> None:
|
|||||||
# 数值操作符校验
|
# 数值操作符校验
|
||||||
if op in ("gt", "lt", "gte", "lte"):
|
if op in ("gt", "lt", "gte", "lte"):
|
||||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
raise ValueError(f"body_rules[{rule_idx}]: condition op={op!r} 的 value 必须为数值")
|
raise ValueError(f"{rule_label}: condition op={op!r} 的 value 必须为数值")
|
||||||
|
|
||||||
# matches 正则校验
|
# matches 正则校验
|
||||||
if op == "matches":
|
if op == "matches":
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
raise ValueError(
|
raise ValueError(f"{rule_label}: condition op=matches 的 value 必须为非空字符串")
|
||||||
f"body_rules[{rule_idx}]: condition op=matches 的 value 必须为非空字符串"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
re.compile(value)
|
re.compile(value)
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
raise ValueError(
|
raise ValueError(f"{rule_label}: condition op=matches 的 value 不是合法正则: {e}")
|
||||||
f"body_rules[{rule_idx}]: condition op=matches 的 value 不是合法正则: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# in 校验
|
# in 校验
|
||||||
if op == "in":
|
if op == "in":
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
raise ValueError(f"body_rules[{rule_idx}]: condition op=in 的 value 必须为数组")
|
raise ValueError(f"{rule_label}: condition op=in 的 value 必须为数组")
|
||||||
|
|
||||||
# type_is 校验
|
# type_is 校验
|
||||||
if op == "type_is":
|
if op == "type_is":
|
||||||
if not isinstance(value, str) or value not in _TYPE_IS_VALUES:
|
if not isinstance(value, str) or value not in _TYPE_IS_VALUES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"body_rules[{rule_idx}]: condition op=type_is 的 value 必须是 "
|
f"{rule_label}: condition op=type_is 的 value 必须是 "
|
||||||
f"{sorted(_TYPE_IS_VALUES)} 之一"
|
f"{sorted(_TYPE_IS_VALUES)} 之一"
|
||||||
)
|
)
|
||||||
|
|
||||||
# starts_with / ends_with / contains 对 value 做字符串校验
|
# starts_with / ends_with / contains 对 value 做字符串校验
|
||||||
if op in ("starts_with", "ends_with"):
|
if op in ("starts_with", "ends_with"):
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise ValueError(f"body_rules[{rule_idx}]: condition op={op!r} 的 value 必须为字符串")
|
raise ValueError(f"{rule_label}: condition op={op!r} 的 value 必须为字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_header_rules(rules: list[HeaderRule]) -> list[HeaderRule]:
|
||||||
|
"""校验 header_rules 列表的结构和 condition 合法性。"""
|
||||||
|
for idx, rule in enumerate(rules):
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
raise ValueError(f"header_rules[{idx}]: 规则必须是 JSON 对象")
|
||||||
|
|
||||||
|
action = rule.get("action")
|
||||||
|
if not isinstance(action, str) or action.strip().lower() not in _HEADER_RULE_ACTIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"header_rules[{idx}]: action 必须是 {sorted(_HEADER_RULE_ACTIONS)} 之一,"
|
||||||
|
f"当前值: {action!r}"
|
||||||
|
)
|
||||||
|
action = action.strip().lower()
|
||||||
|
|
||||||
|
if action == "set":
|
||||||
|
key = rule.get("key")
|
||||||
|
value = rule.get("value")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
raise ValueError(f"header_rules[{idx}]: set 必须提供非空 key")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"header_rules[{idx}]: set 的 value 必须为字符串")
|
||||||
|
|
||||||
|
if action == "drop":
|
||||||
|
key = rule.get("key")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
raise ValueError(f"header_rules[{idx}]: drop 必须提供非空 key")
|
||||||
|
|
||||||
|
if action == "rename":
|
||||||
|
from_val = rule.get("from")
|
||||||
|
to_val = rule.get("to")
|
||||||
|
if not isinstance(from_val, str) or not from_val.strip():
|
||||||
|
raise ValueError(f"header_rules[{idx}]: rename 必须提供非空 from")
|
||||||
|
if not isinstance(to_val, str) or not to_val.strip():
|
||||||
|
raise ValueError(f"header_rules[{idx}]: rename 必须提供非空 to")
|
||||||
|
|
||||||
|
condition = rule.get("condition")
|
||||||
|
if condition is not None:
|
||||||
|
_validate_condition(condition, f"header_rules[{idx}]")
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||||
@@ -246,7 +309,7 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
|||||||
# ---------- condition 校验 ----------
|
# ---------- condition 校验 ----------
|
||||||
condition = rule.get("condition")
|
condition = rule.get("condition")
|
||||||
if condition is not None:
|
if condition is not None:
|
||||||
_validate_condition(condition, idx)
|
_validate_condition(condition, f"body_rules[{idx}]")
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
|
|
||||||
@@ -322,6 +385,14 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
return _validate_body_rules(v)
|
return _validate_body_rules(v)
|
||||||
|
|
||||||
|
@field_validator("header_rules")
|
||||||
|
@classmethod
|
||||||
|
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||||
|
"""校验 header_rules 结构和 condition 合法性"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return _validate_header_rules(v)
|
||||||
|
|
||||||
|
|
||||||
class ProviderEndpointUpdate(BaseModel):
|
class ProviderEndpointUpdate(BaseModel):
|
||||||
"""更新 Endpoint 请求"""
|
"""更新 Endpoint 请求"""
|
||||||
@@ -374,6 +445,14 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
return _validate_body_rules(v)
|
return _validate_body_rules(v)
|
||||||
|
|
||||||
|
@field_validator("header_rules")
|
||||||
|
@classmethod
|
||||||
|
def validate_header_rules(cls, v: list[HeaderRule] | None) -> list[HeaderRule] | None:
|
||||||
|
"""校验 header_rules 结构和 condition 合法性"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return _validate_header_rules(v)
|
||||||
|
|
||||||
|
|
||||||
class ProviderEndpointResponse(BaseModel):
|
class ProviderEndpointResponse(BaseModel):
|
||||||
"""Endpoint 响应"""
|
"""Endpoint 响应"""
|
||||||
|
|||||||
@@ -1408,3 +1408,106 @@ class TestItemCondition:
|
|||||||
# flag=False,全局条件不满足,所有元素都不变
|
# flag=False,全局条件不满足,所有元素都不变
|
||||||
assert "active" not in result["tools"][0]
|
assert "active" not in result["tools"][0]
|
||||||
assert "active" not in result["tools"][1]
|
assert "active" not in result["tools"][1]
|
||||||
|
|
||||||
|
def test_nested_all_any_condition_with_item_ref(self) -> None:
|
||||||
|
"""嵌套 all/any 中包含 $item 时,按元素递归评估。"""
|
||||||
|
body = {
|
||||||
|
"flag": True,
|
||||||
|
"tools": [
|
||||||
|
{"name": "a", "type": "read"},
|
||||||
|
{"name": "b", "type": "write"},
|
||||||
|
{"name": "c", "type": "other"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "tools[*].enabled",
|
||||||
|
"value": True,
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{"path": "flag", "op": "eq", "value": True},
|
||||||
|
{
|
||||||
|
"any": [
|
||||||
|
{"path": "$item.type", "op": "eq", "value": "read"},
|
||||||
|
{"path": "$item.type", "op": "eq", "value": "write"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result["tools"][0]["enabled"] is True
|
||||||
|
assert result["tools"][1]["enabled"] is True
|
||||||
|
assert "enabled" not in result["tools"][2]
|
||||||
|
|
||||||
|
def test_condition_source_original_uses_original_body_after_current_mutation(self) -> None:
|
||||||
|
"""source=original 在前序规则改写 current body 后仍读取原始请求体。"""
|
||||||
|
original_body = {"metadata": {"mode": "prod"}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
original_body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "metadata.mode", "value": "test"},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "audit.from_original",
|
||||||
|
"value": True,
|
||||||
|
"condition": {
|
||||||
|
"path": "metadata.mode",
|
||||||
|
"op": "eq",
|
||||||
|
"value": "prod",
|
||||||
|
"source": "original",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "audit.from_current",
|
||||||
|
"value": True,
|
||||||
|
"condition": {
|
||||||
|
"path": "metadata.mode",
|
||||||
|
"op": "eq",
|
||||||
|
"value": "prod",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
original_body=original_body,
|
||||||
|
)
|
||||||
|
assert result["metadata"]["mode"] == "test"
|
||||||
|
assert result["audit"]["from_original"] is True
|
||||||
|
assert "from_current" not in result["audit"]
|
||||||
|
|
||||||
|
def test_condition_all_any_can_mix_original_and_current_sources(self) -> None:
|
||||||
|
"""组合条件允许 current/original 混用。"""
|
||||||
|
original_body = {"mode": "prod", "count": 0}
|
||||||
|
result = apply_body_rules(
|
||||||
|
original_body,
|
||||||
|
[
|
||||||
|
{"action": "set", "path": "count", "value": 3},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "matched",
|
||||||
|
"value": True,
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{"path": "count", "op": "gte", "value": 1},
|
||||||
|
{
|
||||||
|
"any": [
|
||||||
|
{
|
||||||
|
"path": "mode",
|
||||||
|
"op": "eq",
|
||||||
|
"value": "prod",
|
||||||
|
"source": "original",
|
||||||
|
},
|
||||||
|
{"path": "mode", "op": "eq", "value": "stage"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
original_body=original_body,
|
||||||
|
)
|
||||||
|
assert result["matched"] is True
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from src.models.endpoint_models import ProviderEndpointCreate, ProviderEndpointUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_endpoint_models_accept_nested_conditions_and_source() -> None:
|
||||||
|
payload = {
|
||||||
|
"provider_id": "provider-1",
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"base_url": "https://api.example.com",
|
||||||
|
"header_rules": [
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Test",
|
||||||
|
"value": "1",
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{"path": "mode", "op": "eq", "value": "prod", "source": "original"},
|
||||||
|
{
|
||||||
|
"any": [
|
||||||
|
{"path": "tier", "op": "eq", "value": "gold"},
|
||||||
|
{"path": "tier", "op": "eq", "value": "silver"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body_rules": [
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "metadata.enabled",
|
||||||
|
"value": True,
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{"path": "metadata.kind", "op": "eq", "value": "chat"},
|
||||||
|
{"path": "metadata.tags", "op": "contains", "value": "vip"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
created = ProviderEndpointCreate(**payload)
|
||||||
|
updated = ProviderEndpointUpdate(
|
||||||
|
header_rules=payload["header_rules"],
|
||||||
|
body_rules=payload["body_rules"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.header_rules == payload["header_rules"]
|
||||||
|
assert created.body_rules == payload["body_rules"]
|
||||||
|
assert updated.header_rules == payload["header_rules"]
|
||||||
|
assert updated.body_rules == payload["body_rules"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field_name", "rules"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"header_rules",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Test",
|
||||||
|
"value": "1",
|
||||||
|
"condition": {"path": "mode", "op": "eq", "value": "prod", "source": "bad"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"body_rules",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "metadata.enabled",
|
||||||
|
"value": True,
|
||||||
|
"condition": {"all": []},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_provider_endpoint_models_reject_invalid_condition_shapes(
|
||||||
|
field_name: str,
|
||||||
|
rules: list[dict],
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProviderEndpointCreate(
|
||||||
|
provider_id="provider-1",
|
||||||
|
api_format="openai:chat",
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
**{field_name: rules},
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from src.api.handlers.base.request_builder import evaluate_condition
|
||||||
from src.core.api_format import (
|
from src.core.api_format import (
|
||||||
CORE_REDACT_HEADERS,
|
CORE_REDACT_HEADERS,
|
||||||
HeaderBuilder,
|
HeaderBuilder,
|
||||||
@@ -93,6 +94,68 @@ class TestHeaderBuilder:
|
|||||||
parsed = json.loads(normalized)
|
parsed = json.loads(normalized)
|
||||||
assert "d:\\桌面\\123\\Aether" in parsed["workspaces"]
|
assert "d:\\桌面\\123\\Aether" in parsed["workspaces"]
|
||||||
|
|
||||||
|
def test_apply_rules_supports_nested_conditions(self) -> None:
|
||||||
|
builder = HeaderBuilder()
|
||||||
|
builder.apply_rules(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Flag",
|
||||||
|
"value": "1",
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{"path": "metadata.mode", "op": "eq", "value": "prod"},
|
||||||
|
{
|
||||||
|
"any": [
|
||||||
|
{"path": "tier", "op": "eq", "value": "gold"},
|
||||||
|
{"path": "tier", "op": "eq", "value": "silver"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
body={"metadata": {"mode": "prod"}, "tier": "silver"},
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
|
assert builder.build()["X-Flag"] == "1"
|
||||||
|
|
||||||
|
def test_apply_rules_supports_original_source(self) -> None:
|
||||||
|
builder = HeaderBuilder()
|
||||||
|
builder.apply_rules(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Original",
|
||||||
|
"value": "yes",
|
||||||
|
"condition": {
|
||||||
|
"path": "metadata.mode",
|
||||||
|
"op": "eq",
|
||||||
|
"value": "prod",
|
||||||
|
"source": "original",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
body={"metadata": {"mode": "test"}},
|
||||||
|
original_body={"metadata": {"mode": "prod"}},
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
|
assert builder.build()["X-Original"] == "yes"
|
||||||
|
|
||||||
|
def test_apply_rules_fail_closed_without_body_or_evaluator(self) -> None:
|
||||||
|
builder = HeaderBuilder()
|
||||||
|
builder.apply_rules(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Skip",
|
||||||
|
"value": "1",
|
||||||
|
"condition": {"path": "flag", "op": "eq", "value": True},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert "X-Skip" not in builder.build()
|
||||||
|
|
||||||
|
|
||||||
class TestBuildUpstreamHeaders:
|
class TestBuildUpstreamHeaders:
|
||||||
def test_priority_and_drop_headers(self) -> None:
|
def test_priority_and_drop_headers(self) -> None:
|
||||||
@@ -143,6 +206,24 @@ class TestBuildUpstreamHeaders:
|
|||||||
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
|
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
|
||||||
assert result["Content-Type"] == "application/json"
|
assert result["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
def test_header_rules_can_use_condition_against_body(self) -> None:
|
||||||
|
result = build_upstream_headers_for_endpoint(
|
||||||
|
{},
|
||||||
|
"openai:chat",
|
||||||
|
"provider",
|
||||||
|
header_rules=[
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"key": "X-Conditional",
|
||||||
|
"value": "1",
|
||||||
|
"condition": {"path": "mode", "op": "eq", "value": "prod"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
body={"mode": "prod"},
|
||||||
|
condition_evaluator=evaluate_condition,
|
||||||
|
)
|
||||||
|
assert result["X-Conditional"] == "1"
|
||||||
|
|
||||||
|
|
||||||
class TestFilterResponseHeaders:
|
class TestFilterResponseHeaders:
|
||||||
def test_drops_hop_by_hop_and_body_dependent_headers(self) -> None:
|
def test_drops_hop_by_hop_and_body_dependent_headers(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user