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:
fawney19
2026-02-11 00:50:08 +08:00
parent 262bc6e1f7
commit 3d6d4a48a5
5 changed files with 1294 additions and 166 deletions

View File

@@ -203,7 +203,22 @@ export interface BodyRuleRegexReplace {
count?: number 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
}
/** /**
* 格式接受策略配置 * 格式接受策略配置

View File

@@ -300,165 +300,252 @@
</div> </div>
<!-- 请求体规则列表 - 次要色边框 --> <!-- 请求体规则列表 - 次要色边框 -->
<div <template
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)" v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
:key="`body-${index}`" :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 <div
class="text-[10px] font-semibold text-muted-foreground shrink-0" class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
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"> <span
<SelectValue /> class="text-[10px] font-semibold text-muted-foreground shrink-0"
</SelectTrigger> title="请求体"
<SelectContent> >B</span>
<SelectItem value="set"> <Select
覆写 :model-value="rule.action"
</SelectItem> :open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
<SelectItem value="drop"> @update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
删除 @update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
</SelectItem> >
<SelectItem value="rename"> <SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
重命名 <SelectValue />
</SelectItem> </SelectTrigger>
<SelectItem value="insert"> <SelectContent>
插入 <SelectItem value="set">
</SelectItem> 覆写
<SelectItem value="regex_replace"> </SelectItem>
正则替换 <SelectItem value="drop">
</SelectItem> 删除
</SelectContent> </SelectItem>
</Select> <SelectItem value="rename">
<template v-if="rule.action === 'set'"> 重命名
</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 / &quot;text&quot; / {{$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 <Input
:model-value="rule.path" :model-value="rule.conditionPath"
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 / &quot;text&quot; / {{$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="字段路径" placeholder="字段路径"
size="sm" size="sm"
class="flex-[2] min-w-0 h-7 text-xs" class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)" @update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, '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 <Input
:model-value="rule.pattern" v-if="rule.conditionOp !== 'exists' && rule.conditionOp !== 'not_exists'"
placeholder="正则" :model-value="rule.conditionValue"
:placeholder="rule.conditionOp === 'in' ? '[&quot;a&quot;, &quot;b&quot;]' : rule.conditionOp === 'type_is' ? 'string/number/boolean/...' : '值'"
size="sm" size="sm"
class="flex-[2] min-w-0 h-7 text-xs font-mono" class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'pattern', v)" @update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionValue', v)"
/> />
<span class="text-muted-foreground text-xs">→</span> </div>
<Input </template>
: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> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
@@ -578,7 +665,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
CollapsibleContent, CollapsibleContent,
} from '@/components/ui' } 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 { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import AlertDialog from '@/components/common/AlertDialog.vue' import AlertDialog from '@/components/common/AlertDialog.vue'
@@ -592,6 +679,8 @@ import {
type HeaderRule, type HeaderRule,
type BodyRule, type BodyRule,
type BodyRuleRegexReplace, type BodyRuleRegexReplace,
type BodyRuleCondition,
type BodyRuleConditionOp,
} from '@/api/endpoints' } from '@/api/endpoints'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
@@ -617,6 +706,10 @@ interface EditableBodyRule {
pattern: string // regex_replace 用 pattern: string // regex_replace 用
replacement: string // regex_replace 用 replacement: string // regex_replace 用
flags: string // regex_replace 用i/m/s flags: string // regex_replace 用i/m/s
conditionEnabled: boolean // 是否启用条件
conditionPath: string
conditionOp: string
conditionValue: string // JSON 格式字符串(保存时 parse
} }
// 端点编辑状态(仅 URL、路径、规则格式转换是直接保存的 // 端点编辑状态(仅 URL、路径、规则格式转换是直接保存的
@@ -771,7 +864,7 @@ function prepareValueForJsonParse(raw: string): string {
continue continue
} }
if (!inStr && result.startsWith(ORIGINAL_SENTINEL, i)) { if (!inStr && result.startsWith(ORIGINAL_SENTINEL, i)) {
out += '"' + ORIGINAL_SENTINEL + '"' out += `"${ ORIGINAL_SENTINEL }"`
i += ORIGINAL_SENTINEL.length i += ORIGINAL_SENTINEL.length
continue continue
} }
@@ -919,27 +1012,38 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({ const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '',
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 }) bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value, ...conditionFields })
} else if (rule.action === 'drop') { } 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') { } 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') { } else if (rule.action === 'append') {
// 前端将 append 统一展示为 insertindex 留空),保存时再根据 index 是否为空转回 append // 前端将 append 统一展示为 insertindex 留空),保存时再根据 index 是否为空转回 append
const { value } = initBodyRuleSetValueForEditor(rule.value) 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') { } 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 ?? '') }) bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields })
} 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 || '' }) 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) { function handleAddEndpointBodyRule(endpointId: string) {
const rules = getEndpointEditBodyRules(endpointId) 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 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) const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) { if (rules[index]) {
rules[index][field] = value rules[index][field] = value
} }
} }
// 切换请求体规则的条件启用状态
function toggleBodyRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index].conditionEnabled = !rules[index].conditionEnabled
}
}
// 验证请求体规则 path针对特定端点 // 验证请求体规则 path针对特定端点
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null { function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
const raw = path.trim() const raw = path.trim()
@@ -1447,6 +1559,17 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
if (edited.replacement !== (original.replacement ?? '')) return true if (edited.replacement !== (original.replacement ?? '')) return true
if (edited.flags !== (original.flags ?? '')) 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 return false
} }
@@ -1455,26 +1578,40 @@ 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: any = 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)
if (rule.action === 'set' && rule.path.trim()) { if (rule.action === 'set' && rule.path.trim()) {
let value: any = rule.value let value: any = 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 }
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()) { } 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()) { } 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()) { } else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
let value: any = rule.value let value: any = 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 }
const indexStr = rule.index.trim() const indexStr = rule.index.trim()
if (indexStr === '') { if (indexStr === '') {
// 索引留空 → append 到末尾 // 索引留空 → append 到末尾
result.push({ action: 'append', path: rule.path.trim(), value }) result.push({ action: 'append', path: rule.path.trim(), value, ...(condition ? { condition } : {}) })
} else { } else {
const idx = parseInt(indexStr, 10) const idx = parseInt(indexStr, 10)
if (isNaN(idx)) continue 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()) { } else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
const entry: BodyRuleRegexReplace = { const entry: BodyRuleRegexReplace = {
@@ -1484,7 +1621,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
replacement: rule.replacement || '', replacement: rule.replacement || '',
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}), ...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
} }
result.push(entry) result.push({ ...entry, ...(condition ? { condition } : {}) })
} }
} }

View File

@@ -34,7 +34,7 @@ from src.core.api_format import (
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token 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: if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint from src.models.database import ProviderAPIKey, ProviderEndpoint
@@ -539,6 +539,117 @@ def _resolve_original_placeholder(template: Any, original: Any) -> Any:
return template 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( def apply_body_rules(
body: dict[str, Any], body: dict[str, Any],
rules: list[dict[str, Any]], rules: list[dict[str, Any]],
@@ -585,6 +696,11 @@ def apply_body_rules(
if not isinstance(rule, dict): if not isinstance(rule, dict):
continue continue
# 条件触发condition 存在且不满足时跳过规则
condition = rule.get("condition")
if condition is not None and not _evaluate_condition(result, condition):
continue
action = rule.get("action") action = rule.get("action")
if not isinstance(action, str): if not isinstance(action, str):
continue continue

View File

@@ -42,6 +42,31 @@ _BODY_RULE_ACTIONS: frozenset[str] = frozenset(
# regex_replace 允许的 flags 字符 # regex_replace 允许的 flags 字符
_REGEX_FLAG_CHARS: frozenset[str] = frozenset({"i", "m", "s"}) _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: def parse_re_flags(flags_str: str) -> int:
"""将 flags 字符串i/m/s转换为 re 标志位。 """将 flags 字符串i/m/s转换为 re 标志位。
@@ -59,6 +84,65 @@ def parse_re_flags(flags_str: str) -> int:
return result 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]: def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
"""校验 body_rules 列表的结构和正则合法性。 """校验 body_rules 列表的结构和正则合法性。
@@ -138,6 +222,11 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
if not isinstance(count, int) or count < 0: if not isinstance(count, int) or count < 0:
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数") 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 return rules

View File

@@ -218,3 +218,774 @@ class TestSetWithOriginalPlaceholder:
[{"action": "set", "path": "data", "value": {"a": {"b": [{"c": "{{$original}}"}]}}}], [{"action": "set", "path": "data", "value": {"a": {"b": [{"c": "{{$original}}"}]}}}],
) )
assert result == {"data": {"a": {"b": [{"c": "hello"}]}}} 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