# 性能测试 bodyTemplate 动态变量替换与响应捕获 - 计划执行文档

> **文档版本**: v1.1
> **创建日期**: 2026-08-13
> **关联需求**: `_PRD_bodyTemplate动态变量替换与响应捕获.md`（位于 `Docs/PRD/性能测试/需求文档/`）
> **执行策略**: 分阶段迭代，每阶段可独立验证

---

## 一、执行总览

### 1.1 阶段划分

| 阶段 | 名称 | 核心交付 | 预计工期 |
|------|------|---------|---------|
| Phase 1 | Schema 与模型扩展 | PerformanceTask 新增 capture_rules + 新表 PerformanceTaskOutput | 0.5天 |
| Phase 2 | 模板变量解析器 | TemplateResolver 支持 8 种占位符 | 1天 |
| Phase 3 | 执行器集成 | _send_request 集成模板替换 + 响应捕获 + 签名适配 | 1天 |
| Phase 4 | 服务与路由 | 输出保存、输出查询 API、跨任务引用 | 0.5天 |
| Phase 5 | 前端页面 | 配置页 UI + 输出查看 | 1天 |
| Phase 6 | 集成测试与验证 | 新建会议全链路验证 + 取消会议引用验证 | 0.5天 |

### 1.2 依赖关系

```
Phase 1 (模型/Schema) ──→ Phase 4 (服务/路由)
                             ↑
Phase 2 (解析器) ───────→ Phase 3 (执行器集成) ──→ Phase 6 (集成测试)
                             ↑                            ↑
                    Phase 5 (前端页面) ─────────────────┘
```

### 1.3 文件清单

| 文件 | 阶段 | 说明 |
|------|------|------|
| `backend/app/models/performance_output.py` | Phase 1 | PerformanceTaskOutput 新表 |
| `backend/app/schemas/performance_output.py` | Phase 1 | 输出 Pydantic Schema |
| `backend/app/schemas/performance.py` | Phase 1 | 新增 capture_rules 字段 |
| `backend/app/models/performance.py` | Phase 1 | 新增 capture_rules 字段（body_template 已有） |
| `backend/app/executors/template_resolver.py` | Phase 2 | 变量替换解析器 |
| `backend/app/executors/performance_executor.py` | Phase 3 | 集成模板替换 + 响应捕获 |
| `backend/app/services/performance_service.py` | Phase 4 | 输出保存 + 跨任务引用 |
| `backend/app/routers/performance_output.py` | Phase 4 | 输出查询 API |
| `backend/app/database.py` | Phase 4 | 注册新表 |
| `backend/app/main.py` | Phase 4 | 注册新路由 |
| `frontend/src/api/performance.ts` | Phase 5 | 新增 API |
| `frontend/src/types/performance.ts` | Phase 5 | 新增类型 |
| `frontend/src/views/performance/TaskConfig.vue` | Phase 5 | 配置页 UI |
| `frontend/src/views/performance/TaskDetail.vue` | Phase 5 | 输出查看 |

---

## 二、Phase 1: Schema 与模型扩展

### 2.1 任务清单

| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | 新增 PerformanceTaskOutput 模型 | `backend/app/models/performance_output.py` | 输出存储表 |
| 1.2 | 新增输出 Schema | `backend/app/schemas/performance_output.py` | Output/Create/List 响应 |
| 1.3 | PerformanceTask 新增 capture_rules | `backend/app/schemas/performance.py` | Schema + model |
| 1.4 | 注册新表到 database.py | `backend/app/database.py` | 导入新模型 |

### 2.2 详细设计

#### 2.2.1 PerformanceTaskOutput 模型

```python
class PerformanceTaskOutput(Base):
    """性能测试任务输出"""
    __tablename__ = "performance_task_outputs"

    id: int = mapped_column(Integer, primary_key=True, autoincrement=True)
    task_id: str = mapped_column(String(64), index=True)  # 关联任务ID
    key: str = mapped_column(String(100))                 # 输出键名
    value: list = mapped_column(JSON, default=list)       # 值列表（JSON数组）
    created_at: datetime = mapped_column(DateTime, default=datetime.utcnow)

    # 唯一约束：(task_id, key) 允许一次执行更新覆盖
```

#### 2.2.2 CaptureRule Schema（加入 performance.py）

```python
class CaptureRule(BaseModel):
    """响应捕获规则"""
    key: str = Field(..., description="输出键名")
    json_path: str = Field(..., description="JSON Path 表达式，如 $.data.messageId")
    multiple: bool = Field(True, description="是否允许多值追加")
    description: str = Field("", description="规则描述")

    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
```

`PerformanceTaskCreate` / `PerformanceTaskUpdate` 增加字段：

```python
capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板(支持变量替换)")
```

`body` 字段已在前序修复中放宽为 `Optional[Any]`。

---

## 三、Phase 2: 模板变量解析器

### 3.1 任务清单

| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2.1 | 实现 TemplateResolver 类 | `backend/app/executors/template_resolver.py` | 变量替换核心 |
| 2.2 | 单元测试 | `backend/tests/test_template_resolver.py` | 8 种占位符覆盖 |

### 3.2 详细设计

#### 3.2.1 占位符正则

```python
import re

PATTERN = re.compile(
    r"\{__([A-Z_+\d:]+?)__\}"
)

# 占位符格式：
#   NOW
#   NOW_+2_H
#   NOW_+30_M
#   RANDOM_NAME_8
#   UUID
#   RANDOM_INT_1000_9999
#   INDEX
#   REF:perf_xxx:message_ids
```

#### 3.2.2 解析器实现

```python
class TemplateResolver:
    """请求体模板变量替换器"""

    def __init__(self, ref_provider: Optional[Callable[[str, str], list]] = None):
        """
        Args:
            ref_provider: 跨任务引用取值回调 (task_id, key) -> list
                          None 时 REF 占位符不替换
        """
        self._ref_provider = ref_provider
        self._now_offset = 0  # 测试时可注入固定时间

    def resolve(self, template, request_index: int = 0):
        """
        递归替换模板中的占位符

        Args:
            template: dict/list/str，支持嵌套
            request_index: 当前请求序号（对应 {__INDEX__}）

        Returns:
            替换后的 dict/list/str（不修改原模板，浅拷贝顶层容器）
        """
        if isinstance(template, dict):
            return {k: self.resolve(v, request_index) for k, v in template.items()}
        if isinstance(template, list):
            return [self.resolve(v, request_index) for v in template]
        if isinstance(template, str):
            return self._replace_string(template, request_index)
        return template

    def _replace_string(self, text: str, request_index: int) -> str:
        if "__" not in text:
            return text
        def _repl(match):
            token = match.group(1)
            return self._resolve_token(token, request_index)
        return PATTERN.sub(_repl, text)

    def _resolve_token(self, token: str, request_index: int) -> str:
        parts = token.split(":")
        kind = parts[0]
        if kind == "NOW":
            # NOW / NOW_+2_H / NOW_+5_M
            if len(parts) > 1:
                return self._now_with_offset(parts[1], parts[2] if len(parts) > 2 else "M")
            return self._now_str()
        if kind == "RANDOM_NAME":
            length = int(parts[1]) if len(parts) > 1 else 8
            return self._random_string(length)
        if kind == "UUID":
            return uuid.uuid4().hex
        if kind == "RANDOM_INT":
            lo, hi = int(parts[1]), int(parts[2])
            return str(random.randint(lo, hi))
        if kind == "INDEX":
            return str(request_index)
        if kind == "REF":
            # REF:task_id:key
            task_id, key = parts[1], parts[2]
            if self._ref_provider:
                values = self._ref_provider(task_id, key)
                if values:
                    return str(values[request_index % len(values)])
            return ""
        # 未知占位符 → 保留原样
        return f"{{__{token}__}}"

    # ---------- 时间相关 ----------

    def _now_str(self) -> str:
        return (datetime.now() + timedelta(minutes=self._now_offset)).strftime("%Y-%m-%d %H:%M")

    def _now_with_offset(self, delta: str, unit: str) -> str:
        n = int(delta.lstrip("+"))
        base = datetime.now() + timedelta(minutes=self._now_offset)
        if unit == "H":
            base += timedelta(hours=n)
        elif unit == "M":
            base += timedelta(minutes=n)
        elif unit == "D":
            base += timedelta(days=n)
        return base.strftime("%Y-%m-%d %H:%M")

    def _random_string(self, length: int) -> str:
        chars = string.ascii_letters + string.digits
        return "".join(random.choice(chars) for _ in range(length))
```

#### 3.2.3 设计要点

- **不修改原模板**：resolve 返回新容器，原始 bodyTemplate 保持数据库存储不变
- **支持嵌套**：dict/list 递归处理，字符串内可包含多个占位符
- **未知占位符保留原样**：避免静默吞掉拼写错误
- **REF 循环取值**：多值输出按 `request_index % len(values)` 轮询使用，保证每次请求用不同的 messageId
- **可注入 now_offset**：便于单元测试验证时间偏移

---

## 四、Phase 3: 执行器集成

### 4.1 任务清单

| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 3.1 | _send_request 集成模板替换 | `backend/app/executors/performance_executor.py` | 请求体动态化 |
| 3.2 | 签名基于实际 body | 同上 | 替换后的 body 参与签名 |
| 3.3 | 响应捕获回调 | 同上 | 每次响应后提取字段 |

### 4.2 详细设计

#### 4.2.1 _send_request 改造

```python
def _resolve_body(self, task, request_index: int):
    """解析请求体：bodyTemplate 优先，其次 body"""
    if getattr(task, "body_template", None):
        return self._template_resolver.resolve(task.body_template, request_index)
    return task.body if task.method.upper() in ("POST", "PUT", "PATCH") else None

async def _send_request(self, session, task, request_index: int = 0):
    start = time.time()
    status = 0
    body = ""
    elapsed_ms = 0.0

    try:
        # 1. 解析请求体（先于签名）
        if self._is_login_target(task):
            json_data = self._get_login_body(task)
        else:
            json_data = self._resolve_body(task, request_index)

        # 2. 签名基于实际请求体
        headers = self._build_headers(task, body_for_sign=json_data)

        async with session.request(
            method, url,
            headers=headers,
            json=json_data,
            ssl=False,
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            status = resp.status
            body = await resp.text()
            elapsed_ms = (time.time() - start) * 1000

            # 3. 响应捕获
            if getattr(task, "capture_rules", None):
                self._capture_response(task.capture_rules, body, resp.status)
                ...
```

#### 4.2.2 _build_headers 签名适配

原签名逻辑中 `body_for_sign` 取 `task.body`，需改为接收实际解析后的 body：

```python
def _build_headers(self, task, body_for_sign=None) -> Dict[str, str]:
    ...
    if task.sign_request and not self._is_login_target(task) and self._token:
        if body_for_sign is None:
            body_for_sign = task.body if task.method.upper() in ("POST", "PUT", "PATCH") else None
        sign_headers = self._get_http_client()._generate_sign(
            body_data=body_for_sign, bearer_token=f"Bearer {self._token}"
        )
        headers.update(sign_headers)
    return headers
```

#### 4.2.3 响应捕获实现

```python
def _capture_response(self, capture_rules, body_text, status_code):
    """从响应体中按规则捕获字段"""
    if not body_text:
        return
    try:
        data = json.loads(body_text)
    except Exception:
        return  # 非 JSON 响应，跳过捕获

    for rule in capture_rules:
        key = rule["key"]
        path = rule.get("jsonPath", rule.get("json_path", ""))
        multiple = rule.get("multiple", True)
        values = self._extract_json_path(data, path)
        if values:
            # 存入内存缓冲区，结束后统一写库
            if key not in self._captured:
                self._captured[key] = []
            if multiple:
                self._captured[key].extend(values)
            else:
                self._captured[key] = [values[-1]]

def _extract_json_path(self, data, path):
    """简化 JSON Path 提取，支持 $.a.b.c 与 $.a[0].b"""
    # 实现要点：
    # - 以 $ 开头，按 . 分段
    # - 支持数组下标 [n]
    # - 支持通配 *（提取所有元素）
    # - 找不到返回 []
```

#### 4.2.4 请求序号传递

`_worker` 循环中维护 `request_index` 自增计数，传递到 `_send_request`：

```python
async def _worker(self, session, task, semaphore):
    request_index = 0
    while self._running:
        async with semaphore:
            await self._send_request(session, task, request_index)
        request_index += 1
        await self._sleep_with_check(...)
```

> 说明：`{__INDEX__}` 为每个 worker 内局部自增；若需全局唯一序号，改为线程安全计数器（见 Phase 6 优化项）。

---

## 五、Phase 4: 服务与路由

### 5.1 任务清单

| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 4.1 | 执行完成后保存输出 | `backend/app/services/performance_service.py` | 缓冲区 → DB |
| 4.2 | 输出查询路由 | `backend/app/routers/performance_output.py` | GET outputs |
| 4.3 | 注册路由与新表 | `main.py` + `database.py` | 启动加载 |
| 4.4 | 跨任务引用提供者 | IPO 中 executor 的 ref_provider | REF 占位符 |

### 5.2 详细设计

#### 5.2.1 执行完成保存输出

`PerformanceExecutor.execute()` 返回结果中携带 `captured` 字典；`_run_sync` 完成后由 service 写入：

```python
# performance_service.py
async def _save_outputs(self, task_id: str, captured: dict):
    """保存捕获的输出到数据库"""
    for key, values in (captured or {}).items():
        # upsert: (task_id, key) 存在则覆盖 value，否则插入
        existing = await self.db.execute(
            select(PerformanceTaskOutput).where(
                PerformanceTaskOutput.task_id == task_id,
                PerformanceTaskOutput.key == key,
            )
        )
        obj = existing.scalar_one_or_none()
        if obj:
            obj.value = values
        else:
            self.db.add(PerformanceTaskOutput(task_id=task_id, key=key, value=values))
    await self.db.commit()
```

#### 5.2.2 跨任务引用提供者

```python
# service 中构造 executor 时传入：
def _ref_provider(task_id: str, key: str) -> list:
    # 查数据库，返回最新的输出值列表
    ...
    return values or []

executor = PerformanceExecutor(config, ref_provider=self._ref_provider)
```

#### 5.2.3 输出查询 API

| 方法 | 路径 | 响应 |
|------|------|------|
| GET | `/api/performance/tasks/{id}/outputs` | `[{key, value, created_at}]` |
| GET | `/api/performance/tasks/{id}/outputs/{key}` | `{key, value}` |

---

## 六、Phase 5: 前端页面

### 6.1 任务清单

| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 5.1 | 配置页 bodyTemplate 编辑器 | `TaskConfig.vue` | 占位符按钮 + JSON 校验 |
| 5.2 | 配置页捕获规则编辑器 | `TaskConfig.vue` | 规则增删改 |
| 5.3 | 任务详情输出查看 | `TaskDetail.vue` | 输出标签页 |

### 6.2 详细设计

#### 6.2.1 bodyTemplate 编辑器

- 请求体与请求体模板互斥（radio 切换）
- bodyTemplate 使用 JSON 编辑器（textarea + 校验）
- 提供占位符快捷插入按钮：
  `{__NOW__}` `{__NOW_+1_H__}` `{__NOW_+30_M__}` `{__UUID__}` `{__RANDOM_NAME_8__}` `{__RANDOM_INT_1000_9999__}` `{__INDEX__}`

#### 6.2.2 捕获规则编辑器

| 列 | 说明 |
|----|------|
| key | 输出键名 |
| jsonPath | 如 `$.data.messageId` |
| 多值 | switch |
| 描述 | 可选 |
| 操作 | 删除 |

#### 6.2.3 输出查看

- 加载任务后调用 `GET /api/performance/tasks/{id}/outputs`
- 表格展示 key / value（数组展开）/ 更新时间
- 复制按钮

---

## 七、Phase 6: 集成测试与验证

### 7.1 测试用例

| # | 场景 | 步骤 | 预期 |
|---|------|------|------|
| 6.1 | bodyTemplate 基础替换 | 创建任务含 `{__NOW__}`，执行，抓包 | body 中 startTime=当前时间 |
| 6.2 | 时间偏移 | `{__NOW_+2_H__}` | endTime = startTime + 2h |
| 6.3 | 随机名称 | `{__RANDOM_NAME_8__}` | 每次请求名称不同 |
| 6.4 | 无模板兼容 | 旧任务（仅 body） | 行为与改造前一致 |
| 6.5 | 签名正确性 | 有模板 + sign_request=true | 请求返回 2xx（签名通过） |
| 6.6 | 响应捕获 | captureRules 提取 messageId | outputs API 返回 messageId 数组 |
| 6.7 | 跨任务引用 | 取消会议任务 REF 引用 | 使用真实 messageId 成功 |
| 6.8 | 占位符容错 | 无效占位符 | 原样保留，不报错 |

### 7.2 真实场景验证

1. **新建会议任务**：bodyTemplate + captureRules → 执行 → `message_ids` 输出
2. **取消会议任务**：`{__REF:xx:message_ids__}` → 执行 → 验证 2xx 成功
3. **前端操作**：配置页创建任务 → 执行 → 输出查看

---

## 八、风险与应对

| 风险 | 影响 | 应对 |
|------|------|------|
| JSON Path 实现复杂度 | 影响 Phase 3 工期 | 先支持 `.a.b` / `[n]` / `*` 子集，复杂路径用逗号分隔多个子路径 |
| 签名与请求体不一致 | 每次请求 body 变化导致签名频繁计算 | 签名复用现有 HttpClient._generate_sign，仅输入变化 |
| 捕获值过多内存占用 | 高并发 + 大量捕获 | 缓冲区上限 10000 条，超出丢弃并告警 |
| REF 引用死锁/性能 | 引用任务输出查询频繁 | 每任务启动时一次性加载 REF 值到内存，不每次查询 |
| 前端 JSON 编辑器体验 | 复杂模板编辑不便 | 先用 textarea + 占位符按钮，后续升级 Monaco |

---

## 九、验收标准

见 PRD 第七节。核心验收：

1. `{__NOW__}`、`{__NOW_+N_H__}`、`{__RANDOM_NAME_N__}` 替换正确
2. 无 bodyTemplate 任务行为不变
3. 响应捕获的 messageId 可通过 API 查询
4. 取消会议任务引用 messageIds 后压测请求返回成功
5. 签名在动态 body 下依然有效（目标接口返回非 4xx 签名错误）