mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: body_rules 支持 condition 条件触发
为每条 body_rule 新增可选 condition 字段,支持 eq/neq/gt/lt/gte/lte/ starts_with/ends_with/contains/matches/exists/not_exists/in/type_is 共 14 种操作符,规则仅在条件满足时执行。 后端: 新增 _evaluate_condition 条件评估器与 _validate_condition 校验逻辑 前端: EndpointFormDialog 增加条件编辑行(IF 面板)与 Filter 按钮切换 测试: 新增 TestConditionalBodyRules 覆盖全部操作符及链式触发场景
This commit is contained in:
@@ -203,7 +203,22 @@ export interface BodyRuleRegexReplace {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace
|
||||
export type BodyRuleConditionOp =
|
||||
| 'eq' | 'neq'
|
||||
| 'gt' | 'lt' | 'gte' | 'lte'
|
||||
| 'starts_with' | 'ends_with' | 'contains' | 'matches'
|
||||
| 'exists' | 'not_exists'
|
||||
| 'in' | 'type_is'
|
||||
|
||||
export interface BodyRuleCondition {
|
||||
path: string
|
||||
op: BodyRuleConditionOp
|
||||
value?: any // exists / not_exists 不需要 value
|
||||
}
|
||||
|
||||
export type BodyRule = (BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace) & {
|
||||
condition?: BodyRuleCondition
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式接受策略配置
|
||||
|
||||
@@ -300,165 +300,252 @@
|
||||
</div>
|
||||
|
||||
<!-- 请求体规则列表 - 次要色边框 -->
|
||||
<div
|
||||
<template
|
||||
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
|
||||
:key="`body-${index}`"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-semibold text-muted-foreground shrink-0"
|
||||
title="请求体"
|
||||
>B</span>
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
|
||||
@update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
|
||||
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
|
||||
>
|
||||
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="set">
|
||||
覆写
|
||||
</SelectItem>
|
||||
<SelectItem value="drop">
|
||||
删除
|
||||
</SelectItem>
|
||||
<SelectItem value="rename">
|
||||
重命名
|
||||
</SelectItem>
|
||||
<SelectItem value="insert">
|
||||
插入
|
||||
</SelectItem>
|
||||
<SelectItem value="regex_replace">
|
||||
正则替换
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<template v-if="rule.action === 'set'">
|
||||
<span
|
||||
class="text-[10px] font-semibold text-muted-foreground shrink-0"
|
||||
title="请求体"
|
||||
>B</span>
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
|
||||
@update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
|
||||
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
|
||||
>
|
||||
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="set">
|
||||
覆写
|
||||
</SelectItem>
|
||||
<SelectItem value="drop">
|
||||
删除
|
||||
</SelectItem>
|
||||
<SelectItem value="rename">
|
||||
重命名
|
||||
</SelectItem>
|
||||
<SelectItem value="insert">
|
||||
插入
|
||||
</SelectItem>
|
||||
<SelectItem value="regex_replace">
|
||||
正则替换
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<template v-if="rule.action === 'set'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="字段路径(如 metadata.user_id)"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">=</span>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="123 / "text" / {{$original}}"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getBodySetValueValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'drop'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="要删除的字段路径"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'rename'">
|
||||
<Input
|
||||
:model-value="rule.from"
|
||||
placeholder="原路径"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'from', v)"
|
||||
/>
|
||||
<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) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'insert' || rule.action === 'append'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="数组路径(如 messages)"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.index"
|
||||
placeholder="末尾"
|
||||
size="sm"
|
||||
class="w-14 h-7 text-xs shrink-0"
|
||||
title="插入位置(留空=追加到末尾)"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'index', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="值 (JSON)"
|
||||
size="sm"
|
||||
class="flex-[3] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getBodySetValueValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'regex_replace'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="字段路径"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.pattern"
|
||||
placeholder="正则"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs font-mono"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'pattern', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">→</span>
|
||||
<Input
|
||||
:model-value="rule.replacement"
|
||||
placeholder="替换为"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'replacement', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.flags"
|
||||
placeholder="ims"
|
||||
size="sm"
|
||||
class="w-12 h-7 text-xs shrink-0 font-mono"
|
||||
title="正则标志:i=忽略大小写 m=多行 s=dotall"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'flags', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getRegexPatternValidation(rule) === true ? 'text-green-600' : getRegexPatternValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getRegexPatternValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:class="rule.conditionEnabled ? 'text-primary' : ''"
|
||||
title="条件触发"
|
||||
@click="toggleBodyRuleCondition(endpoint.id, index)"
|
||||
>
|
||||
<Filter class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="removeEndpointBodyRule(endpoint.id, index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 条件编辑行 -->
|
||||
<div
|
||||
v-if="rule.conditionEnabled"
|
||||
class="flex items-center gap-1.5 px-2 py-1 ml-6 rounded-md bg-muted/20"
|
||||
>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground shrink-0">IF</span>
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="字段路径(如 metadata.user_id)"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">=</span>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="123 / "text" / {{$original}}"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getBodySetValueValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'drop'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="要删除的字段路径"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'rename'">
|
||||
<Input
|
||||
:model-value="rule.from"
|
||||
placeholder="原路径"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'from', v)"
|
||||
/>
|
||||
<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) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'insert' || rule.action === 'append'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
placeholder="数组路径(如 messages)"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.index"
|
||||
placeholder="末尾"
|
||||
size="sm"
|
||||
class="w-14 h-7 text-xs shrink-0"
|
||||
title="插入位置(留空=追加到末尾)"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'index', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="值 (JSON)"
|
||||
size="sm"
|
||||
class="flex-[3] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getBodySetValueValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'regex_replace'">
|
||||
<Input
|
||||
:model-value="rule.path"
|
||||
:model-value="rule.conditionPath"
|
||||
placeholder="字段路径"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
|
||||
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
|
||||
:model-value="rule.pattern"
|
||||
placeholder="正则"
|
||||
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-[2] min-w-0 h-7 text-xs font-mono"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'pattern', v)"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionValue', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">→</span>
|
||||
<Input
|
||||
:model-value="rule.replacement"
|
||||
placeholder="替换为"
|
||||
size="sm"
|
||||
class="flex-[2] min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'replacement', v)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.flags"
|
||||
placeholder="ims"
|
||||
size="sm"
|
||||
class="w-12 h-7 text-xs shrink-0 font-mono"
|
||||
title="正则标志:i=忽略大小写 m=多行 s=dotall"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'flags', v)"
|
||||
/>
|
||||
<CheckCircle
|
||||
class="w-4 h-4 shrink-0"
|
||||
:class="getRegexPatternValidation(rule) === true ? 'text-green-600' : getRegexPatternValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
|
||||
:title="getRegexPatternValidationTip(rule)"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="removeEndpointBodyRule(endpoint.id, index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -578,7 +665,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui'
|
||||
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save } from 'lucide-vue-next'
|
||||
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
@@ -592,6 +679,8 @@ import {
|
||||
type HeaderRule,
|
||||
type BodyRule,
|
||||
type BodyRuleRegexReplace,
|
||||
type BodyRuleCondition,
|
||||
type BodyRuleConditionOp,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -617,6 +706,10 @@ interface EditableBodyRule {
|
||||
pattern: string // regex_replace 用
|
||||
replacement: string // regex_replace 用
|
||||
flags: string // regex_replace 用(i/m/s)
|
||||
conditionEnabled: boolean // 是否启用条件
|
||||
conditionPath: string
|
||||
conditionOp: string
|
||||
conditionValue: string // JSON 格式字符串(保存时 parse)
|
||||
}
|
||||
|
||||
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
||||
@@ -771,7 +864,7 @@ function prepareValueForJsonParse(raw: string): string {
|
||||
continue
|
||||
}
|
||||
if (!inStr && result.startsWith(ORIGINAL_SENTINEL, i)) {
|
||||
out += '"' + ORIGINAL_SENTINEL + '"'
|
||||
out += `"${ ORIGINAL_SENTINEL }"`
|
||||
i += ORIGINAL_SENTINEL.length
|
||||
continue
|
||||
}
|
||||
@@ -919,27 +1012,38 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
||||
|
||||
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
|
||||
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '',
|
||||
conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '',
|
||||
})
|
||||
|
||||
const bodyRules: EditableBodyRule[] = []
|
||||
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||
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') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value, ...conditionFields })
|
||||
} else if (rule.action === 'drop') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path, ...conditionFields })
|
||||
} else if (rule.action === 'rename') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to, ...conditionFields })
|
||||
} else if (rule.action === 'append') {
|
||||
// 前端将 append 统一展示为 insert(index 留空),保存时再根据 index 是否为空转回 append
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '' })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '', ...conditionFields })
|
||||
} else if (rule.action === 'insert') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? '') })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields })
|
||||
} else if (rule.action === 'regex_replace') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '' })
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', ...conditionFields })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1128,7 +1232,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
|
||||
// 添加请求体规则(同时自动展开折叠)
|
||||
function handleAddEndpointBodyRule(endpointId: string) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '' })
|
||||
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '' })
|
||||
// 自动展开折叠
|
||||
endpointRulesExpanded.value[endpointId] = true
|
||||
}
|
||||
@@ -1156,13 +1260,21 @@ function updateEndpointBodyRuleAction(endpointId: string, index: number, action:
|
||||
}
|
||||
|
||||
// 更新请求体规则字段
|
||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags', value: string) {
|
||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'conditionPath' | 'conditionOp' | 'conditionValue', value: string) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 切换请求体规则的条件启用状态
|
||||
function toggleBodyRuleCondition(endpointId: string, index: number) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].conditionEnabled = !rules[index].conditionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// 验证请求体规则 path(针对特定端点)
|
||||
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
|
||||
const raw = path.trim()
|
||||
@@ -1447,6 +1559,17 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
if (edited.replacement !== (original.replacement ?? '')) return true
|
||||
if (edited.flags !== (original.flags ?? '')) 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
|
||||
}
|
||||
@@ -1455,26 +1578,40 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
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: any = raw
|
||||
try { val = JSON.parse(raw) } catch { /* 保留原字符串 */ }
|
||||
return { path: rule.conditionPath.trim(), op, value: val }
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const condition = buildCondition(rule)
|
||||
if (rule.action === 'set' && rule.path.trim()) {
|
||||
let value: any = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
result.push({ action: 'set', path: rule.path.trim(), value })
|
||||
result.push({ action: 'set', path: rule.path.trim(), value, ...(condition ? { condition } : {}) })
|
||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||
result.push({ action: 'drop', path: rule.path.trim() })
|
||||
result.push({ action: 'drop', path: rule.path.trim(), ...(condition ? { condition } : {}) })
|
||||
} 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 } : {}) })
|
||||
} else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
|
||||
let value: any = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
const indexStr = rule.index.trim()
|
||||
if (indexStr === '') {
|
||||
// 索引留空 → append 到末尾
|
||||
result.push({ action: 'append', path: rule.path.trim(), value })
|
||||
result.push({ action: 'append', path: rule.path.trim(), value, ...(condition ? { condition } : {}) })
|
||||
} else {
|
||||
const idx = parseInt(indexStr, 10)
|
||||
if (isNaN(idx)) continue
|
||||
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value })
|
||||
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...(condition ? { condition } : {}) })
|
||||
}
|
||||
} else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
|
||||
const entry: BodyRuleRegexReplace = {
|
||||
@@ -1484,7 +1621,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
replacement: rule.replacement || '',
|
||||
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
|
||||
}
|
||||
result.push(entry)
|
||||
result.push({ ...entry, ...(condition ? { condition } : {}) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from src.core.api_format import (
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from src.models.endpoint_models import parse_re_flags
|
||||
from src.models.endpoint_models import _CONDITION_OPS, _TYPE_IS_VALUES, parse_re_flags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
||||
@@ -539,6 +539,117 @@ def _resolve_original_placeholder(template: Any, original: Any) -> Any:
|
||||
return template
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 条件评估器
|
||||
# ==============================================================================
|
||||
|
||||
# _CONDITION_OPS / _TYPE_IS_VALUES 从 endpoint_models 导入,避免重复定义
|
||||
|
||||
_SIMPLE_TYPE_MAP: dict[str, type] = {
|
||||
"string": str,
|
||||
"array": list,
|
||||
"object": dict,
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_condition(body: dict[str, Any], condition: dict[str, Any]) -> bool:
|
||||
"""
|
||||
评估单个条件表达式,决定规则是否应该执行。
|
||||
|
||||
条件格式: {"path": "model", "op": "starts_with", "value": "claude"}
|
||||
|
||||
条件无效时返回 False(跳过该规则,fail-closed)。
|
||||
"""
|
||||
if not isinstance(condition, dict):
|
||||
return False
|
||||
|
||||
op = condition.get("op")
|
||||
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
||||
return False
|
||||
|
||||
path = condition.get("path")
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return False
|
||||
|
||||
found, current_val = _get_nested_value(body, path.strip())
|
||||
|
||||
# 存在性检查:不需要 value
|
||||
if op == "exists":
|
||||
return found
|
||||
if op == "not_exists":
|
||||
return not found
|
||||
|
||||
# 其他操作符要求字段存在
|
||||
if not found:
|
||||
return False
|
||||
|
||||
expected = condition.get("value")
|
||||
|
||||
# 相等/不等
|
||||
if op == "eq":
|
||||
return current_val == expected
|
||||
if op == "neq":
|
||||
return current_val != expected
|
||||
|
||||
# 数值比较
|
||||
if op in ("gt", "lt", "gte", "lte"):
|
||||
if not isinstance(current_val, (int, float)) or not isinstance(expected, (int, float)):
|
||||
return False
|
||||
if op == "gt":
|
||||
return current_val > expected
|
||||
if op == "lt":
|
||||
return current_val < expected
|
||||
if op == "gte":
|
||||
return current_val >= expected
|
||||
return current_val <= expected # lte
|
||||
|
||||
# 字符串操作
|
||||
if op == "starts_with":
|
||||
return (
|
||||
isinstance(current_val, str)
|
||||
and isinstance(expected, str)
|
||||
and current_val.startswith(expected)
|
||||
)
|
||||
if op == "ends_with":
|
||||
return (
|
||||
isinstance(current_val, str)
|
||||
and isinstance(expected, str)
|
||||
and current_val.endswith(expected)
|
||||
)
|
||||
if op == "contains":
|
||||
if isinstance(current_val, str) and isinstance(expected, str):
|
||||
return expected in current_val
|
||||
if isinstance(current_val, list):
|
||||
return expected in current_val
|
||||
return False
|
||||
if op == "matches":
|
||||
if not isinstance(current_val, str) or not isinstance(expected, str):
|
||||
return False
|
||||
try:
|
||||
return re.search(expected, current_val) is not None
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
# 列表包含
|
||||
if op == "in":
|
||||
return isinstance(expected, list) and current_val in expected
|
||||
|
||||
# 类型判断
|
||||
if op == "type_is":
|
||||
if not isinstance(expected, str) or expected not in _TYPE_IS_VALUES:
|
||||
return False
|
||||
# bool 是 int 的子类,需要特殊处理
|
||||
if expected == "number":
|
||||
return isinstance(current_val, (int, float)) and not isinstance(current_val, bool)
|
||||
if expected == "boolean":
|
||||
return isinstance(current_val, bool)
|
||||
if expected == "null":
|
||||
return current_val is None
|
||||
return isinstance(current_val, _SIMPLE_TYPE_MAP[expected])
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def apply_body_rules(
|
||||
body: dict[str, Any],
|
||||
rules: list[dict[str, Any]],
|
||||
@@ -585,6 +696,11 @@ def apply_body_rules(
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
|
||||
# 条件触发:condition 存在且不满足时跳过规则
|
||||
condition = rule.get("condition")
|
||||
if condition is not None and not _evaluate_condition(result, condition):
|
||||
continue
|
||||
|
||||
action = rule.get("action")
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
|
||||
@@ -42,6 +42,31 @@ _BODY_RULE_ACTIONS: frozenset[str] = frozenset(
|
||||
# regex_replace 允许的 flags 字符
|
||||
_REGEX_FLAG_CHARS: frozenset[str] = frozenset({"i", "m", "s"})
|
||||
|
||||
# condition 允许的操作符
|
||||
_CONDITION_OPS: frozenset[str] = frozenset(
|
||||
{
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"lt",
|
||||
"gte",
|
||||
"lte",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
"contains",
|
||||
"matches",
|
||||
"exists",
|
||||
"not_exists",
|
||||
"in",
|
||||
"type_is",
|
||||
}
|
||||
)
|
||||
|
||||
# type_is 允许的类型值
|
||||
_TYPE_IS_VALUES: frozenset[str] = frozenset(
|
||||
{"string", "number", "boolean", "array", "object", "null"}
|
||||
)
|
||||
|
||||
|
||||
def parse_re_flags(flags_str: str) -> int:
|
||||
"""将 flags 字符串(i/m/s)转换为 re 标志位。
|
||||
@@ -59,6 +84,65 @@ def parse_re_flags(flags_str: str) -> int:
|
||||
return result
|
||||
|
||||
|
||||
def _validate_condition(condition: Any, rule_idx: int) -> None:
|
||||
"""校验单条规则的 condition 结构"""
|
||||
if not isinstance(condition, dict):
|
||||
raise ValueError(f"body_rules[{rule_idx}]: condition 必须是 JSON 对象")
|
||||
|
||||
op = condition.get("op")
|
||||
if not isinstance(op, str) or op not in _CONDITION_OPS:
|
||||
raise ValueError(
|
||||
f"body_rules[{rule_idx}]: condition.op 必须是 {sorted(_CONDITION_OPS)} 之一,"
|
||||
f"当前值: {op!r}"
|
||||
)
|
||||
|
||||
path = condition.get("path")
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError(f"body_rules[{rule_idx}]: condition 必须提供非空 path")
|
||||
|
||||
# exists / not_exists 不需要 value
|
||||
if op in ("exists", "not_exists"):
|
||||
return
|
||||
|
||||
value = condition.get("value")
|
||||
|
||||
# 数值操作符校验
|
||||
if op in ("gt", "lt", "gte", "lte"):
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
raise ValueError(f"body_rules[{rule_idx}]: condition op={op!r} 的 value 必须为数值")
|
||||
|
||||
# matches 正则校验
|
||||
if op == "matches":
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(
|
||||
f"body_rules[{rule_idx}]: condition op=matches 的 value 必须为非空字符串"
|
||||
)
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
f"body_rules[{rule_idx}]: condition op=matches 的 value 不是合法正则: {e}"
|
||||
)
|
||||
|
||||
# in 校验
|
||||
if op == "in":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"body_rules[{rule_idx}]: condition op=in 的 value 必须为数组")
|
||||
|
||||
# type_is 校验
|
||||
if op == "type_is":
|
||||
if not isinstance(value, str) or value not in _TYPE_IS_VALUES:
|
||||
raise ValueError(
|
||||
f"body_rules[{rule_idx}]: condition op=type_is 的 value 必须是 "
|
||||
f"{sorted(_TYPE_IS_VALUES)} 之一"
|
||||
)
|
||||
|
||||
# starts_with / ends_with / contains 对 value 做字符串校验
|
||||
if op in ("starts_with", "ends_with"):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"body_rules[{rule_idx}]: condition op={op!r} 的 value 必须为字符串")
|
||||
|
||||
|
||||
def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
"""校验 body_rules 列表的结构和正则合法性。
|
||||
|
||||
@@ -138,6 +222,11 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||
if not isinstance(count, int) or count < 0:
|
||||
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
|
||||
|
||||
# ---------- condition 校验 ----------
|
||||
condition = rule.get("condition")
|
||||
if condition is not None:
|
||||
_validate_condition(condition, idx)
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
|
||||
@@ -218,3 +218,774 @@ class TestSetWithOriginalPlaceholder:
|
||||
[{"action": "set", "path": "data", "value": {"a": {"b": [{"c": "{{$original}}"}]}}}],
|
||||
)
|
||||
assert result == {"data": {"a": {"b": [{"c": "hello"}]}}}
|
||||
|
||||
|
||||
class TestConditionalBodyRules:
|
||||
"""条件触发 body_rules 的测试"""
|
||||
|
||||
# ---- 向后兼容 ----
|
||||
|
||||
def test_no_condition_always_executes(self) -> None:
|
||||
"""无 condition 字段的规则无条件执行"""
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(body, [{"action": "set", "path": "b", "value": 2}])
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
def test_condition_none_always_executes(self) -> None:
|
||||
"""condition 为 None 时无条件执行"""
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body, [{"action": "set", "path": "b", "value": 2, "condition": None}]
|
||||
)
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
# ---- 无效 condition -> 跳过规则 (fail-closed) ----
|
||||
|
||||
def test_invalid_condition_not_dict_skips(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body, [{"action": "set", "path": "b", "value": 2, "condition": "bad"}]
|
||||
)
|
||||
assert result == {"a": 1}
|
||||
|
||||
def test_invalid_condition_missing_op_skips(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body, [{"action": "set", "path": "b", "value": 2, "condition": {"path": "a"}}]
|
||||
)
|
||||
assert result == {"a": 1}
|
||||
|
||||
def test_invalid_condition_missing_path_skips(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[{"action": "set", "path": "b", "value": 2, "condition": {"op": "eq", "value": 1}}],
|
||||
)
|
||||
assert result == {"a": 1}
|
||||
|
||||
def test_invalid_condition_unknown_op_skips(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "b",
|
||||
"value": 2,
|
||||
"condition": {"path": "a", "op": "like", "value": 1},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result == {"a": 1}
|
||||
|
||||
# ---- eq / neq ----
|
||||
|
||||
def test_eq_match(self) -> None:
|
||||
body = {"model": "claude-3"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "temp",
|
||||
"value": 0.5,
|
||||
"condition": {"path": "model", "op": "eq", "value": "claude-3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["temp"] == 0.5
|
||||
|
||||
def test_eq_no_match(self) -> None:
|
||||
body = {"model": "gpt-4"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "temp",
|
||||
"value": 0.5,
|
||||
"condition": {"path": "model", "op": "eq", "value": "claude-3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "temp" not in result
|
||||
|
||||
def test_neq_match(self) -> None:
|
||||
body = {"model": "gpt-4"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "temp",
|
||||
"value": 0.5,
|
||||
"condition": {"path": "model", "op": "neq", "value": "claude-3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["temp"] == 0.5
|
||||
|
||||
def test_neq_no_match(self) -> None:
|
||||
body = {"model": "claude-3"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "temp",
|
||||
"value": 0.5,
|
||||
"condition": {"path": "model", "op": "neq", "value": "claude-3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "temp" not in result
|
||||
|
||||
# ---- gt / lt / gte / lte ----
|
||||
|
||||
def test_gt_match(self) -> None:
|
||||
body = {"score": 80}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "passed",
|
||||
"value": True,
|
||||
"condition": {"path": "score", "op": "gt", "value": 60},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["passed"] is True
|
||||
|
||||
def test_gt_no_match(self) -> None:
|
||||
body = {"score": 60}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "passed",
|
||||
"value": True,
|
||||
"condition": {"path": "score", "op": "gt", "value": 60},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "passed" not in result
|
||||
|
||||
def test_lt_match(self) -> None:
|
||||
body = {"temp": 0.3}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "low",
|
||||
"value": True,
|
||||
"condition": {"path": "temp", "op": "lt", "value": 0.5},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["low"] is True
|
||||
|
||||
def test_gte_match_equal(self) -> None:
|
||||
body = {"count": 10}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "count", "op": "gte", "value": 10},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_lte_match(self) -> None:
|
||||
body = {"count": 5}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "count", "op": "lte", "value": 5},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_numeric_op_on_non_numeric_returns_false(self) -> None:
|
||||
body = {"val": "hello"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "gt", "value": 0},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
# ---- starts_with / ends_with ----
|
||||
|
||||
def test_starts_with_match(self) -> None:
|
||||
body = {"model": "claude-3-opus"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "provider",
|
||||
"value": "anthropic",
|
||||
"condition": {"path": "model", "op": "starts_with", "value": "claude"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["provider"] == "anthropic"
|
||||
|
||||
def test_starts_with_no_match(self) -> None:
|
||||
body = {"model": "gpt-4"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "provider",
|
||||
"value": "anthropic",
|
||||
"condition": {"path": "model", "op": "starts_with", "value": "claude"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "provider" not in result
|
||||
|
||||
def test_ends_with_match(self) -> None:
|
||||
body = {"model": "claude-3-opus"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "tier",
|
||||
"value": "top",
|
||||
"condition": {"path": "model", "op": "ends_with", "value": "opus"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["tier"] == "top"
|
||||
|
||||
def test_starts_with_on_non_string_returns_false(self) -> None:
|
||||
body = {"val": 123}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "starts_with", "value": "1"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
# ---- contains ----
|
||||
|
||||
def test_contains_string_match(self) -> None:
|
||||
body = {"prompt": "hello world"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "found",
|
||||
"value": True,
|
||||
"condition": {"path": "prompt", "op": "contains", "value": "world"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["found"] is True
|
||||
|
||||
def test_contains_array_match(self) -> None:
|
||||
body = {"tags": ["a", "b", "c"]}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "found",
|
||||
"value": True,
|
||||
"condition": {"path": "tags", "op": "contains", "value": "b"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["found"] is True
|
||||
|
||||
def test_contains_array_no_match(self) -> None:
|
||||
body = {"tags": ["a", "b"]}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "found",
|
||||
"value": True,
|
||||
"condition": {"path": "tags", "op": "contains", "value": "z"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "found" not in result
|
||||
|
||||
# ---- matches (regex) ----
|
||||
|
||||
def test_matches_regex_match(self) -> None:
|
||||
body = {"model": "claude-3.5-sonnet"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "matched",
|
||||
"value": True,
|
||||
"condition": {"path": "model", "op": "matches", "value": r"claude-\d+"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["matched"] is True
|
||||
|
||||
def test_matches_regex_no_match(self) -> None:
|
||||
body = {"model": "gpt-4o"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "matched",
|
||||
"value": True,
|
||||
"condition": {"path": "model", "op": "matches", "value": r"^claude"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "matched" not in result
|
||||
|
||||
def test_matches_invalid_regex_returns_false(self) -> None:
|
||||
body = {"val": "test"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "matches", "value": "[invalid"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
# ---- exists / not_exists ----
|
||||
|
||||
def test_exists_match(self) -> None:
|
||||
body = {"metadata": {"key": "val"}}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "has_meta",
|
||||
"value": True,
|
||||
"condition": {"path": "metadata", "op": "exists"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["has_meta"] is True
|
||||
|
||||
def test_exists_no_match(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "has_meta",
|
||||
"value": True,
|
||||
"condition": {"path": "metadata", "op": "exists"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "has_meta" not in result
|
||||
|
||||
def test_not_exists_match(self) -> None:
|
||||
body = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata",
|
||||
"value": {},
|
||||
"condition": {"path": "metadata", "op": "not_exists"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["metadata"] == {}
|
||||
|
||||
def test_not_exists_no_match(self) -> None:
|
||||
body = {"metadata": "exists"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "created",
|
||||
"value": True,
|
||||
"condition": {"path": "metadata", "op": "not_exists"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "created" not in result
|
||||
|
||||
# ---- in ----
|
||||
|
||||
def test_in_match(self) -> None:
|
||||
body = {"model": "claude-3"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "supported",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"path": "model",
|
||||
"op": "in",
|
||||
"value": ["claude-3", "claude-3.5", "gpt-4"],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["supported"] is True
|
||||
|
||||
def test_in_no_match(self) -> None:
|
||||
body = {"model": "gemini-pro"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "supported",
|
||||
"value": True,
|
||||
"condition": {"path": "model", "op": "in", "value": ["claude-3", "gpt-4"]},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "supported" not in result
|
||||
|
||||
def test_in_non_list_value_returns_false(self) -> None:
|
||||
body = {"model": "claude"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "model", "op": "in", "value": "claude"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
# ---- type_is ----
|
||||
|
||||
def test_type_is_string(self) -> None:
|
||||
body = {"val": "hello"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "string"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_number_excludes_bool(self) -> None:
|
||||
body = {"val": True}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "number"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
def test_type_is_number_int(self) -> None:
|
||||
body = {"val": 42}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "number"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_boolean(self) -> None:
|
||||
body = {"val": False}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "boolean"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_array(self) -> None:
|
||||
body = {"val": [1, 2]}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "array"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_object(self) -> None:
|
||||
body = {"val": {"a": 1}}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "object"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_null(self) -> None:
|
||||
body = {"val": None}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "null"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_type_is_invalid_type_name(self) -> None:
|
||||
body = {"val": "hello"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "ok",
|
||||
"value": True,
|
||||
"condition": {"path": "val", "op": "type_is", "value": "str"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ok" not in result
|
||||
|
||||
# ---- 链式触发 ----
|
||||
|
||||
def test_chain_second_rule_sees_first_rule_changes(self) -> None:
|
||||
"""第二条规则的 condition 评估的是第一条规则修改后的 body"""
|
||||
body: dict[str, Any] = {"a": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
# 规则 1: 无条件创建 metadata
|
||||
{"action": "set", "path": "metadata", "value": {}},
|
||||
# 规则 2: 仅当 metadata 存在且是 object 时设置子字段
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata.gateway",
|
||||
"value": "aether",
|
||||
"condition": {"path": "metadata", "op": "type_is", "value": "object"},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert result == {"a": 1, "metadata": {"gateway": "aether"}}
|
||||
|
||||
def test_chain_condition_on_previously_set_value(self) -> None:
|
||||
"""条件引用前序规则 set 的值"""
|
||||
body: dict[str, Any] = {}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
# 规则 1: 创建标记
|
||||
{"action": "set", "path": "flag", "value": "enabled"},
|
||||
# 规则 2: 当 flag == "enabled" 时设置
|
||||
{
|
||||
"action": "set",
|
||||
"path": "extra",
|
||||
"value": 42,
|
||||
"condition": {"path": "flag", "op": "eq", "value": "enabled"},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert result == {"flag": "enabled", "extra": 42}
|
||||
|
||||
def test_chain_not_exists_then_set(self) -> None:
|
||||
"""经典链式模式: not_exists 创建 → 后续规则 condition 检测已创建"""
|
||||
body: dict[str, Any] = {}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata",
|
||||
"value": {},
|
||||
"condition": {"path": "metadata", "op": "not_exists"},
|
||||
},
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata.source",
|
||||
"value": "api",
|
||||
"condition": {"path": "metadata", "op": "exists"},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert result == {"metadata": {"source": "api"}}
|
||||
|
||||
def test_chain_skipped_rule_does_not_affect_subsequent(self) -> None:
|
||||
"""被跳过的规则不影响后续规则的 condition 评估"""
|
||||
body = {"x": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
# 规则 1: 条件不满足,跳过
|
||||
{
|
||||
"action": "set",
|
||||
"path": "y",
|
||||
"value": 2,
|
||||
"condition": {"path": "x", "op": "eq", "value": 999},
|
||||
},
|
||||
# 规则 2: y 不存在(因为规则 1 被跳过了)
|
||||
{
|
||||
"action": "set",
|
||||
"path": "z",
|
||||
"value": 3,
|
||||
"condition": {"path": "y", "op": "not_exists"},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert "y" not in result
|
||||
assert result["z"] == 3
|
||||
|
||||
# ---- 条件与其他 action 类型配合 ----
|
||||
|
||||
def test_condition_with_drop_action(self) -> None:
|
||||
body = {"temp": 0.5, "model": "claude-3"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "drop",
|
||||
"path": "temp",
|
||||
"condition": {"path": "model", "op": "starts_with", "value": "claude"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "temp" not in result
|
||||
|
||||
def test_condition_with_rename_action(self) -> None:
|
||||
body = {"old_key": "value", "model": "gpt-4"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "rename",
|
||||
"from": "old_key",
|
||||
"to": "new_key",
|
||||
"condition": {"path": "model", "op": "starts_with", "value": "gpt"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "old_key" not in result
|
||||
assert result["new_key"] == "value"
|
||||
|
||||
def test_condition_prevents_rename(self) -> None:
|
||||
body = {"old_key": "value", "model": "claude-3"}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "rename",
|
||||
"from": "old_key",
|
||||
"to": "new_key",
|
||||
"condition": {"path": "model", "op": "starts_with", "value": "gpt"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["old_key"] == "value"
|
||||
assert "new_key" not in result
|
||||
|
||||
# ---- 嵌套路径条件 ----
|
||||
|
||||
def test_condition_on_nested_path(self) -> None:
|
||||
body = {"config": {"mode": "advanced"}, "extra": 1}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "feature",
|
||||
"value": True,
|
||||
"condition": {"path": "config.mode", "op": "eq", "value": "advanced"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["feature"] is True
|
||||
|
||||
def test_condition_on_missing_nested_path(self) -> None:
|
||||
body = {"config": {}}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "feature",
|
||||
"value": True,
|
||||
"condition": {"path": "config.mode", "op": "eq", "value": "advanced"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "feature" not in result
|
||||
|
||||
Reference in New Issue
Block a user