提交 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
...@@ -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__}
...@@ -217,3 +218,172 @@ class TemplateResolver: ...@@ -217,3 +218,172 @@ class TemplateResolver:
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))
# ==================== 唯一性字段 ====================
@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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论