提交 687deef2 authored 作者: 陈泽健's avatar 陈泽健

feat(performance): 新增压测请求体字段唯一性配置功能

- 后端模型 performance_tasks 新增 unique_fields JSON 字段,支持 suffix/template 两种唯一化策略
- 新增 UniqueFieldRule Schema,任务创建/更新/响应均包含 unique_fields
- TemplateResolver 新增 apply_unique_fields() 及 JSON Path 路径解析/赋值工具函数
- PerformanceExecutor 在发送请求前按配置对 body 字段自动生成唯一值
- 前端 TaskList.vue 新增可折叠面板,支持添加/编辑/删除唯一性字段规则
- 新增测试 test_unique_fields.py 覆盖路径解析、赋值、唯一值生成场景
- 新增 PRD 需求文档与执行计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 695460eb
# PRD — 压测字段唯一性配置
> **版本**: v1.0
> **日期**: 2026-08-19
> **状态**: 待实现
> **关联 PRD**: `_PRD_bodyTemplate动态变量替换与响应捕获.md`
---
## 1. 背景与问题
### 1.1 当前痛点
性能测试中,部分 API 的请求体字段要求**业务唯一性**(如 `templateName``messageName``topicName` 等),同一值不可重复提交。当前系统的 bodyTemplate 机制虽支持 `{__RANDOM_NAME_8__}` 等占位符,但存在以下问题:
1. **并发模式下所有请求解析出相同值**`{__RANDOM_NAME_8__}` 在模板解析时一次性替换,所有并发请求获得相同随机值 → 第一个请求成功,后续请求因唯一性约束失败
2. **无结构化配置**:用户无法在任务中声明"哪些字段需要唯一",只能手动在 bodyTemplate 中写占位符,但占位符不支持按请求序号递增
3. **curl 导入后无唯一性标记**:从 curl 导入任务时,系统不知道哪些字段有唯一性约束,用户需手动编辑 bodyTemplate
### 1.2 用户需求
> 用户原话:*"新建任务弹窗这里没办法设置哪些字段需要保持唯一性"*
在新建/编辑性能测试任务时,能够**声明请求体中哪些字段需要按请求自动唯一化**,执行时系统自动为每个请求生成该字段的唯一值,确保并发压测不会因字段重复而失败。
### 1.3 适用场景
| 场景 | 示例字段 | 说明 |
|------|---------|------|
| 新建会议 | `messageName` | 会议名称不可重复 |
| 新建模板 | `templateName` | 模板名称不可重复 |
| 新建话题 | `topicList[].topicName` | 话题名称不可重复 |
| 其他唯一性字段 | 任意 JSON Path 定位的字段 | 业务自定义 |
---
## 2. 需求范围
### 2.1 核心功能
1. **任务级 `unique_fields` 配置**:新建/编辑任务时,可配置一个或多个需要唯一化的字段
2. **每个字段可指定唯一化策略**
- `suffix`(默认):追加 `_req_{request_index}``_{random_8}` 后缀
- `template`:使用自定义模板替换(如 `{__RANDOM_NAME_8__}`
3. **执行时自动唯一化**:在 bodyTemplate 解析**之后**,对已配置的字段按请求序号生成唯一值
4. **curl 导入时推断**:导入 curl 后,用户可手动标记需要唯一化的字段
### 2.2 非功能性需求
- 唯一化在 bodyTemplate 解析后执行,不修改 bodyTemplate 本身
- 兼容 `body`(静态 body)和 `body_template`(动态模板)两种模式
- 不影响已有的 bodyTemplate 占位符替换逻辑
- 唯一化字段支持 JSON Path 表达式(如 `$.templateName``$[0].topicList[0].topicName`
### 2.3 不在此范围
- 不涉及数据库唯一约束校验
- 不涉及 API 返回的重复字段错误自动识别
- 不涉及跨任务唯一性(同一任务内唯一即可)
---
## 3. 设计方案
### 3.1 数据模型
```python
class UniqueFieldRule(BaseModel):
"""唯一性字段规则"""
field: str # JSON Path 定位字段,如 "$.templateName" 或 "templateName"
strategy: str = "suffix" # 唯一化策略: "suffix" | "template"
template: Optional[str] = None # 当 strategy="template" 时使用
```
### 3.2 字段变更
**PerformanceTask 模型**(新增字段):
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `unique_fields` | JSON | `[]` | 唯一性字段规则列表 |
**PerformanceTaskCreate / Update / Response**(同步新增):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `unique_fields` | `Optional[List[UniqueFieldRule]]` | 否 | 唯一性字段配置 |
### 3.3 执行器唯一化逻辑
`performance_executor.py``_send_request()``_worker()` 中,在 bodyTemplate 解析完成后、签名之前,插入唯一化步骤:
```
bodyTemplate 解析 → 得到 resolved_body
对 resolved_body 中每个 unique_field 做唯一化处理
用处理后的 body 做签名 → 发送请求
```
**唯一化策略**
| 策略 | 行为 | 示例(request_index=5) |
|------|------|------------------------|
| `suffix` | 原值 + `_req_{index}` | `"测试模板"``"测试模板_req_005"` |
| `template` | 用 `{__RANDOM_NAME_8__}` 等替换 | `"测试模板"``"xYzAbCdE"` |
### 3.4 唯一化时机
```
_send_request() 内部流程(修改后):
1. _resolve_body(task_dict, body, ...) → 解析 bodyTemplate 占位符
2. 对 resolved_body 应用 unique_fields 唯一化 ← 新增步骤
3. _build_headers(..., body_for_sign=resolved_body) → 签名
4. 发送 HTTP 请求
5. _capture_response(...) → 捕获响应
```
### 3.5 前端交互
**新建/编辑任务弹窗** → 新增「唯一性字段」配置区域:
```
┌─ 唯一性字段(可选)─────────────────────────────────────┐
│ [+ 添加字段] │
│ ┌─ 字段1 ───────────────────────────────────────────┐ │
│ │ JSON Path: [templateName ] ▼ 策略: [suffix]│ │
│ │ (支持 $.templateName 或直接字段名) │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌─ 字段2 ───────────────────────────────────────────┐ │
│ │ JSON Path: [topicList[0].topicName] ▼ 策略: [suffix]│ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
**curl 导入后**:解析 body 中所有字符串字段,推荐用户标记唯一性。
---
## 4. 边界情况
| 场景 | 行为 |
|------|------|
| `unique_fields` 为空数组 | 不执行唯一化,行为与当前一致 |
| 字段在 body 中不存在 | 静默跳过,不报错 |
| 字段值本身已包含 `_req_` 后缀 | 追加新后缀(`xxx_req_1``xxx_req_1_req_005`)— 用户需注意 |
| 并发模式 + suffix 策略 | 每个请求独立调用 `_apply_unique()`,request_index 递增 |
| body 为数组 `[{...}]` | JSON Path 支持 `$[0].fieldName` 定位数组元素 |
| 使用 `template` 策略但 template 为空 | 回退使用 `suffix` 策略 |
---
## 5. 验收标准
1. ✅ 新建任务时可添加多个唯一性字段
2. ✅ 编辑任务时可修改/删除唯一性字段
3. ✅ curl 导入后 body 字段可被标记为唯一性字段
4. ✅ 执行时每个请求的指定字段值不同(用 `suffix` 策略验证)
5. ✅ 结合 bodyTemplate 的 `{__RANDOM_NAME_8__}` 仍正常工作
6. ✅ 后端兼容旧任务(`unique_fields` 为空,行为不变)
7. ✅ 前端 `npm run build` 无类型错误
8. ✅ 端到端验证:curl 导入 → 标记唯一字段 → 执行 → 全部成功
---
## 6. 相关文档
- `_PRD_bodyTemplate动态变量替换与响应捕获.md` — bodyTemplate 基础机制
- `_PRD_从curl自动生成压测任务.md` — curl 导入机制
- `HANDOFF_性能测试.md` — 当前进度
---
*本文档由 Claude Code 生成,遵循 PRD → 计划执行 → 代码的工作流。*
\ No newline at end of file
# 执行计划 — 压测字段唯一性配置
> **生成时间**: 2026-08-19
> **基于 PRD**: `_PRD_需求优化_压测字段唯一性配置.md`
> **当前分支**: `platform-auto-test`
> **预计工作量**: 7 个文件改动(后端 4 + 前端 2 + 测试 1)
---
## 概述
在性能测试任务中支持配置**唯一性字段**(如 `templateName``messageName`),执行时系统自动为每个请求将该字段值唯一化,确保并发压测不会因字段重复而失败。
### 核心改动一览
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/models/performance.py` | 修改 | `PerformanceTask` 新增 `unique_fields` JSON 列 |
| `backend/app/schemas/performance.py` | 修改 | 新增 `UniqueFieldRule` schema;Create/Update/Response 上加字段 |
| `backend/app/executors/performance_executor.py` | 修改 | `_send_request()` 中 body 解析后插入唯一化步骤 |
| `backend/app/executors/template_resolver.py` | 修改 | 新增 `apply_unique_fields()` 静态方法 |
| `backend/app/database.py` | 修改 | 追加 `performance_tasks.unique_fields` 列迁移 |
| `frontend/src/types/performance.ts` | 修改 | 新增 `UniqueFieldRule` 接口 |
| `frontend/src/views/performance/TaskList.vue` | 修改 | 弹窗新增唯一性字段配置 UI |
| `backend/tests/test_unique_fields.py` | 新建 | 单元测试 |
---
## Phase 1: 后端模型 + Schema(2 文件)
### 1.1 models/performance.py
`PerformanceTask` 类中新增字段:
```python
unique_fields = Column(JSON, default=[], nullable=False)
```
`to_dict()` 方法中返回该字段(JSON 列自动序列化,无需额外处理)。
### 1.2 schemas/performance.py
新增 `UniqueFieldRule` schema:
```python
class UniqueFieldRule(BaseModel):
field: str = Field(..., description="JSON Path 定位字段,如 'templateName' 或 `$[0].templateName`")
strategy: str = Field(default="suffix", pattern=r"^(suffix|template)$")
template: Optional[str] = Field(default=None, description="strategy=template 时使用的模板,如 '{__UUID__}'")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
```
`PerformanceTaskCreate``PerformanceTaskUpdate``PerformanceTaskResponse` 中新增:
```python
unique_fields: Optional[List[UniqueFieldRule]] = Field(default=None, alias="uniqueFields")
```
### 1.3 数据库迁移
`database.py``_ensure_columns()` 函数中追加:
```python
# 14. 性能测试任务 - 唯一性字段配置
try:
await cursor.execute(
"ALTER TABLE performance_tasks ADD COLUMN unique_fields JSON DEFAULT ('[]')"
)
except Exception:
pass # 已存在
```
---
## Phase 2: 执行器唯一化逻辑(2 文件)
### 2.1 template_resolver.py — 新增 `apply_unique_fields()` 方法
```python
@staticmethod
def apply_unique_fields(body: Any, unique_fields: List[Dict], request_index: int) -> Any:
"""对已解析的 body 应用唯一性字段配置。
在 bodyTemplate 解析完成后调用,对指定字段按请求序号生成唯一值。
Args:
body: 已解析的请求体(dict/list/str)
unique_fields: 唯一性字段配置列表,每项含 field/strategy/template
request_index: 当前请求序号(从 0 递增)
Returns:
唯一化后的 body(不修改原对象)
"""
if not unique_fields:
return body
# 深拷贝 body
result = copy.deepcopy(body)
for rule in unique_fields:
field_path = rule.get("field", "")
strategy = rule.get("strategy", "suffix")
template = rule.get("template")
# 解析 JSON Path,定位并修改值
value = _get_field_by_path(result, field_path)
if value is not None and isinstance(value, str):
new_value = _generate_unique_value(value, strategy, template, request_index)
_set_field_by_path(result, field_path, new_value)
return result
def _get_field_by_path(obj: Any, path: str) -> Any:
"""通过 JSON Path 获取字段值。
支持格式:'templateName'、'$[0].templateName'、'topicList[0].topicName'
"""
# 去除开头的 "$." 或 "$"
clean_path = re.sub(r"^\$\.?|^\.", "", path)
if not clean_path:
return obj
# 按 "." 分割路径段
parts = clean_path.split(".")
current = obj
for part in parts:
if current is None:
return None
# 处理数组索引:topicList[0] → 取 topicList 的第 0 个元素
array_match = re.match(r"^(\w+)\[(\d+)\]$", part)
if array_match:
key, idx = array_match.group(1), int(array_match.group(2))
if isinstance(current, dict):
current = current.get(key)
if isinstance(current, (list, tuple)) and idx < len(current):
current = current[idx]
else:
return None
elif isinstance(current, dict):
current = current.get(part)
elif isinstance(current, (list, tuple)):
# 如果当前是数组,对每个元素递归查找
results = []
for item in current:
v = _get_field_by_path(item, part)
if v is not None:
results.append(v)
current = results[0] if results else None
else:
return None
return current
def _set_field_by_path(obj: Any, path: str, value: Any) -> None:
"""通过 JSON Path 设置字段值(原地修改)。"""
clean_path = re.sub(r"^\$\.?|^\.", "", path)
if not clean_path:
return
parts = clean_path.split(".")
parent = obj
for i, part in enumerate(parts[:-1]):
array_match = re.match(r"^(\w+)\[(\d+)\]$", part)
if array_match:
key, idx = array_match.group(1), int(array_match.group(2))
if isinstance(parent, dict):
parent = parent.get(key)
if isinstance(parent, (list, tuple)) and idx < len(parent):
parent = parent[idx]
else:
return
elif isinstance(parent, dict):
parent = parent.get(part)
else:
return
last_part = parts[-1]
array_match = re.match(r"^(\w+)\[(\d+)\]$", last_part)
if array_match:
key, idx = array_match.group(1), int(array_match.group(2))
if isinstance(parent, dict) and key in parent:
arr = parent[key]
if isinstance(arr, (list, tuple)) and idx < len(arr):
arr[idx] = value
elif isinstance(parent, dict):
parent[last_part] = value
def _generate_unique_value(original: str, strategy: str, template: Optional[str], index: int) -> str:
"""根据策略生成唯一值。"""
if strategy == "suffix":
# 追加 _req_{index}(3位补零)
return f"{original}_req_{index:03d}"
elif strategy == "template" and template:
# 使用模板替换(如 "{__UUID__}" → 替换原值)
# 这里简单处理:如果模板是占位符,直接返回占位符解析结果
# 实际使用时,模板会在 resolve() 中处理
return template # 注意:这将在后续的 resolve() 中被替换
return original
```
### 2.2 performance_executor.py — `_send_request()` 中插入唯一化
`_send_request()` 方法中,`_resolve_body()` 之后、`_build_headers()` 之前插入:
```python
async def _send_request(self, session, task, request_index: int = 0) -> None:
# ... 现有逻辑 ...
# 1. 解析请求体
if method in ("POST", "PUT", "PATCH"):
if task.get("body_template"):
json_data = self._template_resolver.resolve(task["body_template"], request_index)
else:
json_data = self._resolve_body(task, request_index)
else:
json_data = None
# ⭐ 新增:对已解析的 body 应用唯一性字段
unique_fields = task.get("unique_fields", [])
if unique_fields and json_data is not None:
json_data = TemplateResolver.apply_unique_fields(json_data, unique_fields, request_index)
# 2. 构造请求头(传入 body 用于签名)
headers = self._build_headers(task, body_for_sign=json_data)
# ... 后续逻辑 ...
```
**关键设计**:唯一化在 bodyTemplate 解析完成后执行,因此:
- bodyTemplate 的 `{__RANDOM_NAME_8__}` 等占位符先替换
- 然后对 `templateName` 等字段追加 `_req_{index}` 后缀
- 最终值如 `随机字符串_req_005`
---
## Phase 3: 前端表单 + 类型(2 文件)
### 3.1 types/performance.ts
```typescript
export interface UniqueFieldRule {
field: string
strategy: 'suffix' | 'template'
template?: string | null
}
```
`PerformanceTaskCreate``PerformanceTaskUpdate``PerformanceTask` 接口中新增:
```typescript
uniqueFields?: UniqueFieldRule[] | null
```
### 3.2 TaskList.vue — 唯一性字段配置 UI
`TaskForm` 接口中新增:
```typescript
interface TaskForm {
// ... 现有字段 ...
uniqueFields: UniqueFieldRule[]
}
```
`defaultForm()` 中新增:
```typescript
uniqueFields: []
```
在保存时处理:
```typescript
// handleSave 中
formData.uniqueFields = form.uniqueFields.length > 0 ? form.uniqueFields : null
```
**UI 布局**:在弹窗的「请求头」/「请求体」区域下方,新增折叠面板「唯一性字段配置」:
```html
<el-collapse>
<el-collapse-item title="唯一性字段(可选)" name="uniqueFields">
<div v-for="(rule, index) in form.uniqueFields" :key="index"
style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center;">
<el-input v-model="rule.field" placeholder="JSON Path,如 templateName" style="flex: 1;" />
<el-select v-model="rule.strategy" style="width: 120px;">
<el-option label="后缀递增" value="suffix" />
<el-option label="自定义模板" value="template" />
</el-select>
<el-input v-if="rule.strategy === 'template'" v-model="rule.template"
placeholder="{__UUID__}" style="width: 160px;" />
<el-button type="danger" :icon="Delete" @click="removeUniqueField(index)" circle />
</div>
<el-button type="primary" @click="addUniqueField" :icon="Plus">添加字段</el-button>
</el-collapse-item>
</el-collapse>
```
**编辑回填**:在 `openEditDialog` 中:
```typescript
form.uniqueFields = row.uniqueFields || []
```
---
## Phase 4: 测试(1 文件)
### 4.1 tests/test_unique_fields.py
```python
"""测试唯一性字段配置功能。"""
import pytest
from app.executors.template_resolver import (
TemplateResolver,
_get_field_by_path,
_set_field_by_path,
_generate_unique_value,
apply_unique_fields,
)
class TestGetFieldByPath:
def test_simple_field(self):
body = {"templateName": "测试模板", "duration": 15}
assert _get_field_by_path(body, "templateName") == "测试模板"
def test_array_element(self):
body = [{"templateName": "模板A"}, {"templateName": "模板B"}]
assert _get_field_by_path(body, "$[0].templateName") == "模板A"
@pytest.mark.parametrize("path,expected", [
("topicList[0].topicName", "话题A"),
("$[0].topicList[0].topicName", "话题A"),
("participantList", []),
("nonexistent", None),
])
def test_various_paths(self, path, expected):
body = [{"topicList": [{"topicName": "话题A"}], "participantList": []}]
assert _get_field_by_path(body, path) == expected
class TestGenerateUniqueValue:
def test_suffix_strategy(self):
assert _generate_unique_value("测试模板", "suffix", None, 0) == "测试模板_req_000"
assert _generate_unique_value("测试模板", "suffix", None, 5) == "测试模板_req_005"
assert _generate_unique_value("测试模板", "suffix", None, 999) == "测试模板_req_999"
def test_template_strategy(self):
result = _generate_unique_value("原值", "template", "{__UUID__}", 0)
assert result == "{__UUID__}" # 占位符由后续 resolve() 处理
class TestApplyUniqueFields:
def test_no_unique_fields(self):
body = {"templateName": "测试模板"}
result = TemplateResolver.apply_unique_fields(body, [], 0)
assert result == body
def test_single_field(self):
body = {"templateName": "测试模板", "duration": 15}
unique_fields = [{"field": "templateName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 5)
assert result["templateName"] == "测试模板_req_005"
assert result["duration"] == 15 # 其他字段不变
def test_array_body_field(self):
body = [{"templateName": "测试模板", "duration": 15}]
unique_fields = [{"field": "$[0].templateName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 3)
assert result[0]["templateName"] == "测试模板_req_003"
def test_multiple_fields(self):
body = {"templateName": "模板A", "messageName": "会议A"}
unique_fields = [
{"field": "templateName", "strategy": "suffix"},
{"field": "messageName", "strategy": "suffix"},
]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 1)
assert result["templateName"] == "模板A_req_001"
assert result["messageName"] == "会议A_req_001"
def test_deep_nested_field(self):
body = [{"topicList": [{"topicName": "话题A"}]}]
unique_fields = [{"field": "$[0].topicList[0].topicName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 7)
assert result[0]["topicList"][0]["topicName"] == "话题A_req_007"
```
---
## 关键设计决策
| 决策 | 选项 | 选择 | 理由 |
|------|------|------|------|
| 唯一化时机 | ① bodyTemplate 解析前 ② 解析后 | ② 解析后 | 先替换占位符再唯一化,确保 `{__RANDOM_NAME_8__}` 等先被解析 |
| 字段路径语法 | ① JSON Path 标准 ② 简化字段名 | ② 简化字段名 + 支持 `$[0]` 前缀 | 性能测试场景 body 结构已知,无需完整 JSON Path 实现 |
| 存储格式 | ① 独立表 ② 任务 JSON 字段 | ② 任务 JSON 字段 | 数据量小,无需跨任务查询,避免新增表 |
| 唯一化策略默认值 | ① suffix ② template | ① suffix | 最简单直观,用户无需额外配置 |
| 深拷贝 | ① 是 ② 否 | ① 是 | 避免修改原 body 影响其他请求 |
---
## 部署计划
```bash
# 1. 更新后端文件
scp backend/app/models/performance.py ... 5.60:/data/...
scp backend/app/schemas/performance.py ... 5.60:/data/...
scp backend/app/executors/performance_executor.py ... 5.60:/data/...
scp backend/app/executors/template_resolver.py ... 5.60:/data/...
scp backend/app/database.py ... 5.60:/data/...
# 2. 构建前端
cd frontend && npm run build
# 3. 部署前端
scp -r frontend/dist/* ... 5.60:/data/...
# 4. 清理 pyc + 重启
docker exec plat-auto-test-app sh -c "find /app -name '*.pyc' -delete"
docker restart plat-auto-test-app
```
---
## 验证清单
- [ ] 单元测试:`python -m pytest tests/test_unique_fields.py -v` → 全部通过
- [ ] 新建任务:配置 `templateName` 为唯一字段 → 保存成功
- [ ] 编辑任务:修改/删除唯一字段 → 保存成功
- [ ] 执行任务:`concurrent=2` 模式 → 两个请求的 `templateName` 不同
- [ ] 结合 bodyTemplate:`{__RANDOM_NAME_8__}` + `templateName` 唯一化 → 两者均生效
- [ ] 旧任务兼容:`unique_fields=[]` 的任务执行 → 行为不变
- [ ] 前端构建:`npm run build` → 无 TS 错误
- [ ] 用户 curl 导入 → 手动标记唯一字段 → 执行 → 全部成功
---
*本计划由 Claude Code 生成,遵循 PRD → 计划执行 → 代码的工作流。*
\ No newline at end of file
...@@ -143,6 +143,8 @@ async def _ensure_columns(conn) -> None: ...@@ -143,6 +143,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "capture_rules", "JSON"), ("performance_tasks", "capture_rules", "JSON"),
# 性能测试:任务关联的接口预设(旧库升级) # 性能测试:任务关联的接口预设(旧库升级)
("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"), ("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:唯一性字段配置(旧库升级)
("performance_tasks", "unique_fields", "JSON"),
# 功能测试报告模板:描述字段(旧库升级) # 功能测试报告模板:描述字段(旧库升级)
("report_templates", "description", "VARCHAR(200) DEFAULT ''"), ("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
# 钉钉配置:被测系统URL(旧库升级) # 钉钉配置:被测系统URL(旧库升级)
......
...@@ -894,6 +894,14 @@ class PerformanceExecutor: ...@@ -894,6 +894,14 @@ class PerformanceExecutor:
else: else:
json_data = self._resolve_body(task, request_index) json_data = self._resolve_body(task, request_index)
# ⭐ 唯一性字段处理:对已解析的 body 按配置生成唯一值
if json_data is not None:
unique_fields = getattr(task, "unique_fields", []) or []
if unique_fields:
json_data = TemplateResolver.apply_unique_fields(
json_data, unique_fields, request_index
)
headers = self._build_headers(task, body_for_sign=json_data) headers = self._build_headers(task, body_for_sign=json_data)
async with session.request( async with session.request(
......
...@@ -21,12 +21,13 @@ ...@@ -21,12 +21,13 @@
创建日期:2026-08-13 创建日期:2026-08-13
""" """
import copy
import random import random
import re import re
import string import string
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Callable, Optional from typing import Any, Callable, Optional, List, Dict
# 占位符正则:{__TOKEN__} # 占位符正则:{__TOKEN__}
...@@ -216,4 +217,173 @@ class TemplateResolver: ...@@ -216,4 +217,173 @@ class TemplateResolver:
"""生成 N 位随机字母数字字符串""" """生成 N 位随机字母数字字符串"""
length = max(length, 1) length = max(length, 1)
chars = string.ascii_letters + string.digits chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length)) return "".join(random.choice(chars) for _ in range(length))
\ No newline at end of file
# ==================== 唯一性字段 ====================
@staticmethod
def apply_unique_fields(body: Any, unique_fields: List[Dict], request_index: int) -> Any:
"""
对已解析的 body 应用唯一性字段配置。
在 bodyTemplate 解析完成后调用,对指定字段按请求序号生成唯一值。
Args:
body: 已解析的请求体(dict/list/str)
unique_fields: 唯一性字段配置列表,每项含 field/strategy/template
request_index: 当前请求序号(从 0 递增)
Returns:
唯一化后的 body(不修改原对象)
"""
if not unique_fields:
return body
# 深拷贝 body
result = copy.deepcopy(body)
for rule in unique_fields:
field_path = rule.get("field", "")
strategy = rule.get("strategy", "suffix")
template = rule.get("template")
value = _get_field_by_path(result, field_path)
if value is not None and isinstance(value, str):
new_value = _generate_unique_value(value, strategy, template, request_index)
_set_field_by_path(result, field_path, new_value)
return result
def _get_field_by_path(obj: Any, path: str) -> Any:
"""
通过 JSON Path 获取字段值。
支持格式:
- 'templateName' → 简单字段名
- '$[0].templateName' → 顶层数组第 0 个元素的字段
- 'topicList[0].topicName' → 嵌套数组字段
- 'level1.level2' → 深层嵌套字段
对数组会透明遍历:'topicList[0].topicName' 作用于顶层 list 时,
会依次应用到每个元素并返回第一个命中值。
"""
clean_path = re.sub(r"^\$\.?|^\.", "", path)
if not clean_path:
return obj
parts = clean_path.split(".")
results = [obj]
for part in parts:
bare = re.match(r"^\[(\d+)\]$", part) # 裸下标:[0]
keyed = re.match(r"^(\w+)\[(\d+)\]$", part) # 键+下标:topicList[0]
next_results = []
for node in results:
if node is None:
continue
if bare:
idx = int(bare.group(1))
if isinstance(node, (list, tuple)) and idx < len(node):
next_results.append(node[idx])
elif keyed:
key, idx = keyed.group(1), int(keyed.group(2))
if isinstance(node, dict) and key in node:
sub = node[key]
if isinstance(sub, (list, tuple)) and idx < len(sub):
next_results.append(sub[idx])
elif isinstance(node, (list, tuple)):
# 顶层是数组:对每个元素递归查找该部分
for item in node:
v = _get_field_by_path(item, part)
if v is not None:
next_results.append(v)
else:
if isinstance(node, dict):
v = node.get(part)
if v is not None:
next_results.append(v)
elif isinstance(node, (list, tuple)):
# 当前是数组:对每个元素递归查找该部分
for item in node:
v = _get_field_by_path(item, part)
if v is not None:
next_results.append(v)
results = next_results
if not results:
break
return results[0] if results else None
def _set_field_by_path(obj: Any, path: str, value: Any) -> None:
"""
通过 JSON Path 设置字段值(原地修改)。
与 _get_field_by_path 相同的路径语法;路径不存在时静默跳过。
"""
clean_path = re.sub(r"^\$\.?|^\.", "", path)
if not clean_path:
return
parts = clean_path.split(".")
def _apply(node: Any, idx: int) -> None:
"""递归:node 是 parts[idx] 对应的容器,idx 是当前要处理的段"""
part = parts[idx]
if idx == len(parts) - 1:
# 最后一段:实际赋值
bare = re.match(r"^\[(\d+)\]$", part)
keyed = re.match(r"^(\w+)\[(\d+)\]$", part)
if bare:
i = int(bare.group(1))
if isinstance(node, (list, tuple)) and i < len(node):
node[i] = value
elif keyed:
key, i = keyed.group(1), int(keyed.group(2))
if isinstance(node, dict) and key in node:
sub = node[key]
if isinstance(sub, (list, tuple)) and i < len(sub):
sub[i] = value
elif isinstance(node, dict):
node[part] = value
elif isinstance(node, (list, tuple)):
for item in node:
_apply(item, idx)
return
bare = re.match(r"^\[(\d+)\]$", part)
keyed = re.match(r"^(\w+)\[(\d+)\]$", part)
if bare:
i = int(bare.group(1))
if isinstance(node, (list, tuple)) and i < len(node):
_apply(node[i], idx + 1)
elif keyed:
key, i = keyed.group(1), int(keyed.group(2))
if isinstance(node, dict) and key in node:
_apply(node[key], idx + 1)
elif isinstance(node, (list, tuple)):
for item in node:
_apply(item, idx)
else:
if isinstance(node, dict):
if part in node:
_apply(node[part], idx + 1)
elif isinstance(node, (list, tuple)):
for item in node:
_apply(item, idx)
_apply(obj, 0)
def _generate_unique_value(original: str, strategy: str, template: Optional[str], index: int) -> str:
"""根据策略生成唯一值。"""
if strategy == "suffix":
# 追加 _req_{index}(3位补零)
return f"{original}_req_{index:03d}"
elif strategy == "template" and template:
# 使用模板替换(如 "{__UUID__}" → 替换原值)
return template # 占位符由后续的 resolve() 处理
return original
\ No newline at end of file
...@@ -129,6 +129,9 @@ class PerformanceTask(Base): ...@@ -129,6 +129,9 @@ class PerformanceTask(Base):
# 响应捕获规则 # 响应捕获规则
capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="响应捕获规则列表") capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="响应捕获规则列表")
# 唯一性字段配置(每个请求自动生成唯一值,避免并发压测重复冲突)
unique_fields: Mapped[list] = mapped_column(JSON, default=list, comment="唯一性字段配置列表")
# 结果统计 # 结果统计
total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数") total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数")
success_count: Mapped[int] = mapped_column(Integer, default=0, comment="成功数") success_count: Mapped[int] = mapped_column(Integer, default=0, comment="成功数")
...@@ -197,6 +200,7 @@ class PerformanceTask(Base): ...@@ -197,6 +200,7 @@ class PerformanceTask(Base):
"preset_id": self.preset_id, "preset_id": self.preset_id,
"assertions": self.assertions or [], "assertions": self.assertions or [],
"capture_rules": self.capture_rules or [], "capture_rules": self.capture_rules or [],
"unique_fields": self.unique_fields or [],
"total_requests": self.total_requests, "total_requests": self.total_requests,
"success_count": self.success_count, "success_count": self.success_count,
"fail_count": self.fail_count, "fail_count": self.fail_count,
......
...@@ -37,6 +37,17 @@ class AssertionRule(BaseModel): ...@@ -37,6 +37,17 @@ class AssertionRule(BaseModel):
description: str = Field("", description="断言描述") description: str = Field("", description="断言描述")
# ==================== 唯一性字段规则 ====================
class UniqueFieldRule(BaseModel):
"""唯一性字段规则"""
field: str = Field(..., description="JSON Path 定位字段,如 'templateName' 或 `topicList[0].topicName`")
strategy: str = Field(default="suffix", pattern=r"^(suffix|template)$", description="唯一化策略: suffix/template")
template: Optional[str] = Field(default=None, description="strategy=template 时使用的模板,如 '{__UUID__}'")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 性能测试任务 ==================== # ==================== 性能测试任务 ====================
class PerformanceTaskCreate(BaseModel): class PerformanceTaskCreate(BaseModel):
...@@ -78,6 +89,9 @@ class PerformanceTaskCreate(BaseModel): ...@@ -78,6 +89,9 @@ class PerformanceTaskCreate(BaseModel):
# 响应捕获规则 # 响应捕获规则
capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表") capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表")
# 唯一性字段配置
unique_fields: Optional[List[UniqueFieldRule]] = Field(default=None, alias="uniqueFields", description="唯一性字段配置列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
...@@ -106,6 +120,9 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -106,6 +120,9 @@ class PerformanceTaskUpdate(BaseModel):
assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表") assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表")
capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表") capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表")
# 唯一性字段配置
unique_fields: Optional[List[UniqueFieldRule]] = Field(default=None, alias="uniqueFields", description="唯一性字段配置列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
...@@ -137,6 +154,9 @@ class PerformanceTaskResponse(BaseModel): ...@@ -137,6 +154,9 @@ class PerformanceTaskResponse(BaseModel):
capture_rules: Optional[List[Dict[str, Any]]] = None capture_rules: Optional[List[Dict[str, Any]]] = None
preset_id: Optional[str] = None preset_id: Optional[str] = None
# 唯一性字段配置
unique_fields: Optional[List[Dict[str, Any]]] = None
# 结果统计 # 结果统计
total_requests: int = 0 total_requests: int = 0
success_count: int = 0 success_count: int = 0
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_unique_fields.py
模块描述:测试唯一性字段配置功能
作者:czj
创建日期:2026-08-19
"""
import pytest
from app.executors.template_resolver import (
TemplateResolver,
_get_field_by_path,
_set_field_by_path,
_generate_unique_value,
)
class TestGetFieldByPath:
"""测试 _get_field_by_path 路径解析"""
def test_simple_field(self):
body = {"templateName": "测试模板", "duration": 15}
assert _get_field_by_path(body, "templateName") == "测试模板"
def test_simple_field_nonexistent(self):
body = {"templateName": "测试模板"}
assert _get_field_by_path(body, "nonexistent") is None
def test_array_element(self):
body = [{"templateName": "模板A"}, {"templateName": "模板B"}]
assert _get_field_by_path(body, "$[0].templateName") == "模板A"
assert _get_field_by_path(body, "$[1].templateName") == "模板B"
@pytest.mark.parametrize("path,expected", [
("topicList[0].topicName", "话题A"),
("$[0].topicList[0].topicName", "话题A"),
("participantList", []),
("nonexistent", None),
])
def test_various_paths(self, path, expected):
body = [{"topicList": [{"topicName": "话题A"}], "participantList": []}]
assert _get_field_by_path(body, path) == expected
def test_deep_nested(self):
body = {"level1": {"level2": {"level3": "deep_value"}}}
assert _get_field_by_path(body, "level1.level2.level3") == "deep_value"
def test_null_body(self):
assert _get_field_by_path(None, "field") is None
def test_empty_path(self):
body = {"templateName": "测试模板"}
assert _get_field_by_path(body, "") == body
class TestSetFieldByPath:
"""测试 _set_field_by_path 设置值"""
def test_simple_field(self):
body = {"templateName": "旧值"}
_set_field_by_path(body, "templateName", "新值")
assert body["templateName"] == "新值"
def test_array_element(self):
body = [{"templateName": "旧值A"}]
_set_field_by_path(body, "$[0].templateName", "新值A")
assert body[0]["templateName"] == "新值A"
def test_nested_field(self):
body = {"level1": {"level2": {"level3": "旧值"}}}
_set_field_by_path(body, "level1.level2.level3", "新值")
assert body["level1"]["level2"]["level3"] == "新值"
def test_empty_path(self):
body = {"templateName": "旧值"}
_set_field_by_path(body, "", "新值")
assert body["templateName"] == "旧值" # 不应修改
class TestGenerateUniqueValue:
"""测试 _generate_unique_value 唯一值生成"""
def test_suffix_strategy(self):
assert _generate_unique_value("测试模板", "suffix", None, 0) == "测试模板_req_000"
assert _generate_unique_value("测试模板", "suffix", None, 5) == "测试模板_req_005"
assert _generate_unique_value("测试模板", "suffix", None, 999) == "测试模板_req_999"
def test_suffix_strategy_empty_string(self):
assert _generate_unique_value("", "suffix", None, 0) == "_req_000"
def test_template_strategy(self):
result = _generate_unique_value("原值", "template", "{__UUID__}", 0)
assert result == "{__UUID__}" # 占位符由后续 resolve() 处理
def test_template_strategy_no_template(self):
# strategy=template 但 template 为空 → 回退返回原值
result = _generate_unique_value("原值", "template", None, 0)
assert result == "原值"
def test_unknown_strategy(self):
result = _generate_unique_value("原值", "unknown", None, 0)
assert result == "原值"
class TestApplyUniqueFields:
"""测试 TemplateResolver.apply_unique_fields 整体流程"""
def test_no_unique_fields(self):
body = {"templateName": "测试模板"}
result = TemplateResolver.apply_unique_fields(body, [], 0)
assert result == body
def test_none_unique_fields(self):
body = {"templateName": "测试模板"}
result = TemplateResolver.apply_unique_fields(body, None, 0)
assert result == body
# 确保返回的是同一个对象(未深拷贝)
assert result is body
def test_single_field(self):
body = {"templateName": "测试模板", "duration": 15}
unique_fields = [{"field": "templateName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 5)
assert result["templateName"] == "测试模板_req_005"
assert result["duration"] == 15 # 其他字段不变
def test_array_body_field(self):
body = [{"templateName": "测试模板", "duration": 15}]
unique_fields = [{"field": "$[0].templateName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 3)
assert result[0]["templateName"] == "测试模板_req_003"
def test_multiple_fields(self):
body = {"templateName": "模板A", "messageName": "会议A"}
unique_fields = [
{"field": "templateName", "strategy": "suffix"},
{"field": "messageName", "strategy": "suffix"},
]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 1)
assert result["templateName"] == "模板A_req_001"
assert result["messageName"] == "会议A_req_001"
def test_deep_nested_field(self):
body = [{"topicList": [{"topicName": "话题A"}]}]
unique_fields = [{"field": "$[0].topicList[0].topicName", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 7)
assert result[0]["topicList"][0]["topicName"] == "话题A_req_007"
def test_does_not_mutate_original(self):
"""验证深拷贝:原始 body 不应被修改"""
body = {"templateName": "原值"}
unique_fields = [{"field": "templateName", "strategy": "suffix"}]
TemplateResolver.apply_unique_fields(body, unique_fields, 0)
assert body["templateName"] == "原值" # 原对象不变
def test_field_not_found_skips_silently(self):
"""字段在 body 中不存在 → 静默跳过"""
body = {"templateName": "测试模板"}
unique_fields = [{"field": "nonexistent", "strategy": "suffix"}]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 0)
assert result == body
def test_non_string_field_skips(self):
"""非字符串字段 → 跳过"""
body = {"count": 123, "price": 45.6}
unique_fields = [
{"field": "count", "strategy": "suffix"},
{"field": "price", "strategy": "suffix"},
]
result = TemplateResolver.apply_unique_fields(body, unique_fields, 0)
assert result["count"] == 123 # 不变
assert result["price"] == 45.6 # 不变
def test_body_is_none(self):
"""body 为 None → 返回 None"""
result = TemplateResolver.apply_unique_fields(None, [{"field": "x", "strategy": "suffix"}], 0)
assert result is None
def test_body_is_string(self):
"""body 为字符串时 → 不处理(唯一化只对 JSON 结构有效)"""
result = TemplateResolver.apply_unique_fields("hello", [{"field": "x", "strategy": "suffix"}], 0)
assert result == "hello"
\ No newline at end of file
...@@ -26,6 +26,13 @@ export interface AssertionRule { ...@@ -26,6 +26,13 @@ export interface AssertionRule {
description?: string | null description?: string | null
} }
/** 唯一性字段规则 */
export interface UniqueFieldRule {
field: string
strategy: 'suffix' | 'template'
template?: string | null
}
/** 性能测试任务 */ /** 性能测试任务 */
export interface PerformanceTask { export interface PerformanceTask {
id: string id: string
...@@ -67,6 +74,7 @@ export interface PerformanceTask { ...@@ -67,6 +74,7 @@ export interface PerformanceTask {
durationActual: number | null durationActual: number | null
presetId: string | null presetId: string | null
captureRules: any[] | null captureRules: any[] | null
uniqueFields: UniqueFieldRule[] | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
...@@ -93,6 +101,7 @@ export interface PerformanceTaskCreate { ...@@ -93,6 +101,7 @@ export interface PerformanceTaskCreate {
loginPassword?: string | null loginPassword?: string | null
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
} }
/** 更新任务请求 */ /** 更新任务请求 */
...@@ -117,6 +126,7 @@ export interface PerformanceTaskUpdate { ...@@ -117,6 +126,7 @@ export interface PerformanceTaskUpdate {
loginPassword?: string | null loginPassword?: string | null
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
} }
/** 任务列表项 */ /** 任务列表项 */
......
...@@ -269,6 +269,44 @@ ...@@ -269,6 +269,44 @@
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" />
</el-form-item> </el-form-item>
<el-collapse v-if="!form.presetId">
<el-collapse-item name="uniqueFields" title="唯一性字段配置(可选)">
<div class="unique-field-tip">
压测并发下部分业务字段(如模板名称 templateName)不允许重复。配置后每个请求会自动生成唯一值:
后缀递增(001 → 001_req_000)或自定义模板(如 <code>{__UUID__}</code>)。
字段路径支持 JSON Path,如 <code>templateName</code><code>$[0].templateName</code><code>topicList[0].topicName</code>
</div>
<div v-for="(rule, index) in form.uniqueFields" :key="index" class="unique-field-row">
<el-input
v-model="rule.field"
placeholder="字段路径,如 templateName / $[0].templateName"
clearable
style="flex: 1"
/>
<el-select v-model="rule.strategy" style="width: 140px">
<el-option label="后缀递增" value="suffix" />
<el-option label="自定义模板" value="template" />
</el-select>
<el-input
v-if="rule.strategy === 'template'"
v-model="rule.template"
placeholder="{__UUID__}"
clearable
style="width: 200px"
/>
<el-button
type="danger"
circle
:icon="Delete"
@click="removeUniqueField(index)"
/>
</div>
<el-button type="primary" plain :icon="Plus" @click="addUniqueField">
添加唯一性字段
</el-button>
</el-collapse-item>
</el-collapse>
</el-form> </el-form>
<template #footer> <template #footer>
...@@ -314,9 +352,9 @@ ...@@ -314,9 +352,9 @@
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Refresh, Upload } from '@element-plus/icons-vue' import { Plus, Refresh, Upload, Delete } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl } from '@/api/performance' import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult } from '@/types/performance' import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule } from '@/types/performance'
const router = useRouter() const router = useRouter()
...@@ -365,6 +403,7 @@ interface TaskForm { ...@@ -365,6 +403,7 @@ interface TaskForm {
signRequest: boolean signRequest: boolean
assertions: any[] assertions: any[]
presetId: string presetId: string
uniqueFields: UniqueFieldRule[]
} }
const defaultForm = (): TaskForm => ({ const defaultForm = (): TaskForm => ({
...@@ -389,6 +428,7 @@ const defaultForm = (): TaskForm => ({ ...@@ -389,6 +428,7 @@ const defaultForm = (): TaskForm => ({
signRequest: false, signRequest: false,
assertions: [], assertions: [],
presetId: '', presetId: '',
uniqueFields: [],
}) })
const form = reactive<TaskForm>(defaultForm()) const form = reactive<TaskForm>(defaultForm())
...@@ -493,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -493,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
signRequest: fullTask.signRequest, signRequest: fullTask.signRequest,
assertions: fullTask.assertions || [], assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '', presetId: presetId && selectedPreset.value ? presetId : '',
uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })),
}) })
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -630,6 +671,16 @@ function accountKeyLabel(key: string) { ...@@ -630,6 +671,16 @@ function accountKeyLabel(key: string) {
return map[key] || key return map[key] || key
} }
/** 添加唯一性字段规则 */
function addUniqueField() {
form.uniqueFields.push({ field: '', strategy: 'suffix', template: null })
}
/** 删除唯一性字段规则 */
function removeUniqueField(index: number) {
form.uniqueFields.splice(index, 1)
}
/** 方法标签颜色 */ /** 方法标签颜色 */
function methodTagType(method: string) { function methodTagType(method: string) {
const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger' } const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger' }
...@@ -690,6 +741,7 @@ async function handleSave() { ...@@ -690,6 +741,7 @@ async function handleSave() {
signRequest: form.signRequest, signRequest: form.signRequest,
assertions: [], assertions: [],
presetId: form.presetId || null, presetId: form.presetId || null,
uniqueFields: form.uniqueFields.length > 0 ? form.uniqueFields : null,
} }
if (editingTask.value) { if (editingTask.value) {
...@@ -841,4 +893,26 @@ onMounted(() => { ...@@ -841,4 +893,26 @@ onMounted(() => {
margin: 0; margin: 0;
color: #606266; color: #606266;
} }
/* 唯一性字段配置 */
.unique-field-tip {
font-size: 12px;
color: #909399;
line-height: 1.7;
margin-bottom: 10px;
}
.unique-field-tip code {
background: #f5f7fa;
padding: 1px 5px;
border-radius: 3px;
color: #409eff;
}
.unique-field-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
</style> </style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论