# 执行计划 — 压测字段唯一性配置

> **生成时间**: 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 → 计划执行 → 代码的工作流。*