提交 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:
("performance_tasks", "capture_rules", "JSON"),
# 性能测试:任务关联的接口预设(旧库升级)
("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:唯一性字段配置(旧库升级)
("performance_tasks", "unique_fields", "JSON"),
# 功能测试报告模板:描述字段(旧库升级)
("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
# 钉钉配置:被测系统URL(旧库升级)
......
......@@ -894,6 +894,14 @@ class PerformanceExecutor:
else:
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)
async with session.request(
......
......@@ -21,12 +21,13 @@
创建日期:2026-08-13
"""
import copy
import random
import re
import string
import uuid
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from typing import Any, Callable, Optional, List, Dict
# 占位符正则:{__TOKEN__}
......@@ -216,4 +217,173 @@ class TemplateResolver:
"""生成 N 位随机字母数字字符串"""
length = max(length, 1)
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
\ No newline at end of file
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):
# 响应捕获规则
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="总请求数")
success_count: Mapped[int] = mapped_column(Integer, default=0, comment="成功数")
......@@ -197,6 +200,7 @@ class PerformanceTask(Base):
"preset_id": self.preset_id,
"assertions": self.assertions or [],
"capture_rules": self.capture_rules or [],
"unique_fields": self.unique_fields or [],
"total_requests": self.total_requests,
"success_count": self.success_count,
"fail_count": self.fail_count,
......
......@@ -37,6 +37,17 @@ class AssertionRule(BaseModel):
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):
......@@ -78,6 +89,9 @@ class PerformanceTaskCreate(BaseModel):
# 响应捕获规则
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)
......@@ -106,6 +120,9 @@ class PerformanceTaskUpdate(BaseModel):
assertions: Optional[List[AssertionRule]] = 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)
......@@ -137,6 +154,9 @@ class PerformanceTaskResponse(BaseModel):
capture_rules: Optional[List[Dict[str, Any]]] = None
preset_id: Optional[str] = None
# 唯一性字段配置
unique_fields: Optional[List[Dict[str, Any]]] = None
# 结果统计
total_requests: 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 {
description?: string | null
}
/** 唯一性字段规则 */
export interface UniqueFieldRule {
field: string
strategy: 'suffix' | 'template'
template?: string | null
}
/** 性能测试任务 */
export interface PerformanceTask {
id: string
......@@ -67,6 +74,7 @@ export interface PerformanceTask {
durationActual: number | null
presetId: string | null
captureRules: any[] | null
uniqueFields: UniqueFieldRule[] | null
createdAt: string
updatedAt: string
}
......@@ -93,6 +101,7 @@ export interface PerformanceTaskCreate {
loginPassword?: string | null
signRequest?: boolean
assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
}
/** 更新任务请求 */
......@@ -117,6 +126,7 @@ export interface PerformanceTaskUpdate {
loginPassword?: string | null
signRequest?: boolean
assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
}
/** 任务列表项 */
......
......@@ -269,6 +269,44 @@
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" />
</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>
<template #footer>
......@@ -314,9 +352,9 @@
import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
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 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()
......@@ -365,6 +403,7 @@ interface TaskForm {
signRequest: boolean
assertions: any[]
presetId: string
uniqueFields: UniqueFieldRule[]
}
const defaultForm = (): TaskForm => ({
......@@ -389,6 +428,7 @@ const defaultForm = (): TaskForm => ({
signRequest: false,
assertions: [],
presetId: '',
uniqueFields: [],
})
const form = reactive<TaskForm>(defaultForm())
......@@ -493,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
signRequest: fullTask.signRequest,
assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '',
uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })),
})
dialogVisible.value = true
}
......@@ -630,6 +671,16 @@ function accountKeyLabel(key: string) {
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) {
const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger' }
......@@ -690,6 +741,7 @@ async function handleSave() {
signRequest: form.signRequest,
assertions: [],
presetId: form.presetId || null,
uniqueFields: form.uniqueFields.length > 0 ? form.uniqueFields : null,
}
if (editingTask.value) {
......@@ -841,4 +893,26 @@ onMounted(() => {
margin: 0;
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>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论