提交 59948f7c authored 作者: 陈泽健's avatar 陈泽健

feat(performance): 混合场景 mix 每虚拟用户独立登录(CSV账号各自token + 登录重登替换)

- 后端 HttpClient 新增 login_with_credentials(username,password) 与 _extract_token 静态方法
  - 复用 captcha uuid + SHA256 加密 + 签名;不写入 _tokens 缓存(token 由 executor 管理)
- 执行引擎 per-VU 状态:_vu_tokens Dict[int,str] + _vu_tokens_lock + _vu_login_failed
  - _ensure_vu_token 异步幂等(run_in_executor 包裹同步登录),失败记入 _vu_login_failed
  - _resolve_csv_credentials 按 global_thread_idx 分配 CSV 行(idx % len 循环复用)
  - _mix_worker 启动各自登录;登录接口进随机池命中后替换该 VU 自己的 token
  - _send_request 新增 bearer_token 可选参 + 返回 Optional[str](登录 2xx 提取新 token)
  - _build_headers bearer_token 优先于 self._token;登录目标 bearer_token="" 签名且无 Authorization
  - _is_login_target 支持 url_override(识别随机池中的登录接口)
  - _run_mix 校验:CSV 未启用/缺 username/password 列/账号数 < 并发数 → 拒绝执行
  - execute() per_vu_login 时跳过共享预登录
- Model/Schema 增加 per_vu_login 字段(默认 False)+ _ensure_columns 自动迁移
- 前端 types perVuLogin + TaskList.vue「每虚拟用户独立登录」开关(仅 mix 显示)
- 单测 TestPerVuLogin 13 条(各自token/重登替换/失败处理/CSV校验/共享回归)
- 已部署 5.60(含 performance_tasks.per_vu_login 自动迁移);端到端由用户手动验证
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 63ed9c69
# HANDOFF_性能测试
> 生成时间:2026-08-12 15:00
> 最后更新:2026-09-01 12:00
> 最后更新:2026-09-01 18:00
> 当前分支:`platform-auto-test`
> 最近提交:`720bf764 feat(ui-automation): sut-explore 补齐会议运维+待办巡检缺口用例 8/8 通过`
> 最近提交:`545ee9b4 feat(performance): 混合场景 mix 权重随机调度 + 执行时长膨胀修复`
> 主分支:`master`
---
......@@ -26,12 +26,131 @@
| 目标机资源监控(SSH)+ Java 进程监控 | ✅ 已部署 | SSH 采集 CPU/内存/负载 + Java 进程级监控 + 实时监控/报告展示(2026-08-22) |
| **两种压测场景增强(burst/endurance)+ 报告页场景标签/多接口明细表** | **✅ 已部署,5.60 e2e 验证通过** | 瞬时并发(集合点同步/循环模式/错误策略)+ 8 小时长稳压测(多接口线程组/思考时间分布/CSV 参数化)(2026-08-22) |
| **混合场景(mix)权重随机调度 + 执行时长膨胀修复** | **✅ 已部署,5.60 e2e 验证通过** | 登录前置 + 预定/模板创建/模板查询按权重 30/20/40 随机;durationActual 膨胀(2.4×)根因定位并修复(2026-09-01) |
| **混合场景(mix)per-VU 独立登录** | **✅ 已部署 5.60(2026-09-01)** | mix 100 VU 各自 CSV 账号独立登录拿各自 token;登录接口可进随机池模拟重登;CSV 账号数 < 虚拟用户数拒绝执行;端到端验证由用户手动执行 |
---
## 🆕 当前会话(2026-09-01 — 混合场景(mix)压测 + 执行时长膨胀修复
## 🆕 当前会话(2026-09-01 18:00 — 混合场景(mix)per-VU 独立登录改造 + 5.60 部署
**任务来源**:PRD《混合场景压测》—— 数百虚拟用户,每次迭代按权重随机选接口(JMeter 概率分配器/Weighted Switch 语义);登录为**前置步骤**(共享 token,不进随机池)。走完整流程:PRD → 计划执行文档 → 代码 → 测试 → 部署 5.60 → 短时长验证。
**任务来源**:P1 续作 — 100 虚拟用户各自登录、各自持有 token(**非共享**)。原 mix 实现是任务级单 token(`_ensure_token` 一次,所有 worker 共用 `self._token`),与用户「各自登录使用各自 token」的需求不符。
**核心约束**(用户明确):
1. **100 VU 各自独立登录**:通过 CSV 参数化导入 100 行 (username, password),按 `global_thread_idx % len(records)` 分配给各 VU
2. **登录接口可进随机池**:模拟会话过期重登,命中后替换**该 VU 自己的** token
3. **CSV 账号数 < 虚拟用户数 → 拒绝执行**(用户新增约束)
4. **存量任务行为完全不变**`per_vu_login` 默认 `False`,非 mix 场景完全不动
5. **手动触发执行**:30 分钟正式压测由用户在 UI 手动启动,**不自动执行**
### 已实现
| 功能 | 状态 | 说明 |
|------|------|------|
| 后端 `HttpClient.login_with_credentials(username, password)` | ✅ | 复用 captcha uuid + SHA256 + 签名,**不写入** `self._tokens` 缓存(per-VU token 由 executor 管理);抽出 `HttpClient._extract_token(data)` 静态方法多路径提取(accessToken/token/access_token/data.*) |
| 后端 `HttpClient._extract_token` | ✅ | 登录响应多路径提取的复用入口 |
| 执行引擎 per-VU 状态 | ✅ | `self._vu_tokens: Dict[int,str]` + `self._vu_tokens_lock` + `self._vu_login_failed: set``__init__` 初始化) |
| `_is_login_target(task, url_override=None)` | ✅ | mix 场景登录接口进随机池时通过 `url_override` 识别,避免重复判断 |
| `_resolve_csv_credentials(task, idx, ...)` | ✅ | 按 idx 解析 CSV 中 username/password(列名由 `csv_variable_mapping``{{csv.username}}` `{{csv.password}}` 解析 + 回退字面量) |
| `_ensure_vu_token(task, idx, ...)` 异步幂等 | ✅ | 已登录 VU 立即返回缓存;未登录 → `loop.run_in_executor` 包裹同步 `login_with_credentials`,失败记入 `_vu_login_failed` |
| `_build_headers(..., bearer_token=None, login_target=None)` | ✅ | `token = bearer_token if bearer_token is not None else self._token`,登录目标用 `bearer_token=""` 签名且不带 `Authorization`(存量行为不变) |
| `_send_request(..., bearer_token=None)` 返回 `Optional[str]` | ✅ | 登录目标 2xx 时提取 token 返回(替换 VU 自己的 token),非登录目标仍返回 `None`(存量行为不变) |
| `_mix_worker` per-VU 启动登录 + 重登替换 | ✅ | worker 启动时 `_ensure_vu_token`;命中登录接口后替换 `_vu_tokens[idx]`,失败保留旧 token + warning |
| `_run_mix` 校验 | ✅ | per-VU 模式:CSV 未启用 / 缺 username/password 列 / `len(records) < concurrency` → 返回 `error`,执行被拒绝(error_message 包含原因) |
| `execute()` 共享预登录跳过 | ✅ | `auth_required and not per_vu_login` 才走 `_ensure_token`;per-VU 模式登录延迟到各 worker(ramp_up 期间) |
| `Model.per_vu_login: Mapped[bool]` + `to_dict` | ✅ | `Boolean, default=False``_ensure_columns` 自动迁移添加(已确认 5.60 启动日志「补齐字段: performance_tasks.per_vu_login」) |
| `Schema.PerformanceTaskCreate/Update/Response.per_vu_login` | ✅ | Create `bool=False` / Update `Optional[bool]=None` / Response `bool=False`,camelCase alias 双向兼容 |
| 前端 `types/performance.ts` perVuLogin 字段 | ✅ | PerformanceTask / PerformanceTaskCreate / PerformanceTaskUpdate 均加 `perVuLogin?: boolean` |
| 前端 `TaskList.vue` 开关(仅 mix) | ✅ | CSV 区块下「每虚拟用户独立登录」开关 + FieldTip 详细解释 + `form.scenarioType === 'mix'` 时才保存该字段 |
| 单元测试 `TestPerVuLogin` 13 条 | ✅ | per-VU 各自 token / 启动登录失败记录 / 登录接口进随机池替换自己 token / 启动失败 VU 不发业务请求 / CSV 账号数 < 并发数拒绝 / CSV 缺列拒绝 / CSV 未启用拒绝 / 共享模式回归 / per-VU bearer 覆盖 / 登录目标不带 Authorization / `_extract_token` 多路径 / `_is_login_target` url_override |
| 全量 pytest | ✅ 388 passed | `backend/tests/` 全量(排除先前 import 错误的 `test_screenshot_cleanup.py`) |
| 前端 `npm run build` | ✅ | vue-tsc + Vite 35.7s 通过 |
| 5.60 部署(后端 5 文件 + dist/) | ✅ | `tmp/deploy_pervu_560.py`(paramiko 密码 SSH/SCP + dist 强制覆盖 + 重启 + 健康检查 + 自动迁移列确认) |
### 关键设计决策
- **token 传递链**`_mix_worker → _send_request(..., bearer_token=per_VU_token) → _build_headers(..., bearer_token=...)`,非 per-VU 模式 `bearer_token=None` 回落 `self._token`(共享模式回归)
- **登录目标识别**`_is_login_target(task, url_override=None)` 接收 `_send_request``api_config.url`,解决 mix 场景登录进随机池后无法靠 `task.target_url` 识别的问题
- **token 替换策略**:登录接口命中 → 提取新 token → `self._vu_tokens[idx] = new_token`;提取失败 → 保留旧 token + warning 日志(不重置回无 token)
- **登录失败处理**:启动失败 → 该 VU 不发业务请求 + 加入 `_vu_login_failed`;重登失败 → 保留旧 token 继续压测
- **CSV 分配**`global_thread_idx % len(csv_records)` 循环复用(与现有 body 替换语义一致);CSV 缺 username/password 列时**回退查找列名** → 仍无则拒绝(_run_mix 校验有 `user_col in csv_fields` membership 检查避免假阳性)
- **签名一致性**:登录接口 `_send_request` 始终以 `bearer_token=""` 签名(即使已有 per-VU token),保证登录路径签名算法(RandomCode 头)与现状一致
- **执行拒绝位置**`_run_mix` 校验失败返回 `{"error": True, "error_message": "..."}``execute()` 主流程感知到后写入 execution 记录的 error_message 字段(不抛异常)
- **非 mix 场景不受影响**`_worker` / `_burst_worker` / `_endurance_worker` 完全不动,per-VU 本期仅 mix
- **前端开关**仅 mix 显示;保存时 `perVuLogin: form.scenarioType === 'mix' ? form.perVuLogin : false`,避免非 mix 任务被误写
### 5.60 部署验证(自动部分)
| 步骤 | 结果 |
|------|------|
| 后端 5 文件上传 | ✅ performance_executor.py (139828) / http_client.py (20561) / models/performance.py (53403) / schemas/performance.py (42516) / database.py (15299) |
| 前端 dist 上传 | ✅ 94 个文件(sftp 逐文件,跳过 .map) |
| 容器重启 | ✅ `docker compose restart app` 完成(warning 仅为 docker-compose.yml version 字段过期,可忽略) |
| 健康检查 | ✅ `HTTP 200`,Uvicorn 监听 0.0.0.0:80 |
| 数据库迁移 | ✅ 启动日志「补齐字段: performance_tasks.per_vu_login」 |
| 应用启动 | ✅ Application startup complete,所有后台循环(消息消费/定时任务/看门狗)已启动 |
| 容器日志 | ✅ 无错误;仅 `SCREENSHOT_RETENTION_DAYS` 属性缺失 warning(与本次无关) |
**端到端验证(含 100 VU 真实账号)由用户手动执行**(用户提供 100 行 CSV 后在 UI 启动)。
### 未提交的代码(本次会话)
```
# per-VU 改造相关 — 待 /GitCommit 提交
M backend/app/executors/performance_executor.py # _ensure_vu_token / _resolve_csv_credentials / _send_request bearer_token / _build_headers / _mix_worker / _run_mix 校验 / execute() 跳过
M backend/app/executors/http_client.py # login_with_credentials + _extract_token
M backend/app/models/performance.py # per_vu_login 列
M backend/app/schemas/performance.py # per_vu_login 字段(Create/Update/Response)
M backend/app/database.py # _ensure_columns 迁移条目
M backend/tests/test_performance_mix.py # TestPerVuLogin 13 条 + 工具函数
M frontend/src/types/performance.ts # perVuLogin 字段
M frontend/src/views/performance/TaskList.vue # per-VU 开关 + FieldTip + 保存
M HANDOFF_性能测试.md # 本次会话进度
```
> 仓库仍存其它窗口遗留未提交项(projects 项目模块、lzzsh 安全脚本、test_screenshot_cleanup.py、UI 自动化 HANDOFF 等),归属各窗口自行提交。
### 踩坑(本次会话)
| 现象 | 根因 | 正确做法 |
|------|------|---------|
| 现有 `test_mix_worker_schedules_all_apis` 计数为 0 | `_mix_worker` 无条件传 `bearer_token``_send_request`,但老测试的 `fake_send` 不收该 kwarg | `send_kwargs = {"bearer_token": bearer_token} if per_vu_login else {}`,非 per-VU 模式保持 kwarg 不传 |
| 2 个新测试初次失败:重登录 token 断言过严 | 重登录发生时机随机,`request_index` 不一定为 0 | 改为 `tok.startswith(f"new_token_vu{idx}_") or tok == f"vu{idx}_old"`,接受任意 request_index |
| `_run_mix` 缺列校验假阳性 | `_resolve_csv_column_name` 解析 `{{csv.username}}` 始终返回字面量 `"username"`,但测试 CSV 用的是 `name/pwd` | 在 `_run_mix` 校验中加 `user_col in csv_fields` membership 检查;`_resolve_csv_credentials` 同步加固 |
| `urllib` 探测 `nat.ubainsyun.com:25124` 连接拒绝 | NAT URL 在当前 Windows 网络环境不可达 | 探测时直接用 `http://192.168.5.60`(容器直接 IP) |
### 关键代码位置(per-VU 改造)
| 文件 | 关键函数 / 行 |
|------|---------------|
| `backend/app/executors/http_client.py` | `_extract_token`(静态方法,多路径)、`login_with_credentials`(不写缓存) |
| `backend/app/executors/performance_executor.py` | `_is_login_target(url_override=)``_resolve_csv_credentials``_ensure_vu_token`(run_in_executor)、`_build_headers(..., bearer_token=, login_target=)``_send_request(..., bearer_token=)` 返回 `Optional[str]``_mix_worker` 启动登录+重登替换、`_run_mix` 校验、`execute()` 跳过共享预登录 |
| `backend/app/models/performance.py` | `per_vu_login: Mapped[bool] = mapped_column(Boolean, default=False, ...)` |
| `backend/app/schemas/performance.py` | Create / Update / Response 均含 `per_vu_login`(snake_case + camelCase alias) |
| `backend/app/database.py` | `_ensure_columns` 列表新增 `("performance_tasks", "per_vu_login", "BOOLEAN DEFAULT 0")` |
| `frontend/src/types/performance.ts` | PerformanceTask / PerformanceTaskCreate / PerformanceTaskUpdate 含 `perVuLogin` |
| `frontend/src/views/performance/TaskList.vue` | CSV 区块下「每虚拟用户独立登录」开关(仅 mix 可见)、CSV FieldTip 补充 per-VU 列名要求 |
| `backend/tests/test_performance_mix.py` | `TestPerVuLogin` 13 条 + `_make_vu_executor` / `_make_vu_task` 工具函数 |
### 下一步(用户手动执行)
1. 用户提供 100 行 (username, password) CSV → 保存到 `tmp/pervu_accounts.csv`(列名 `username,password``user,pwd` 都可)
2. 用户在 UI 编辑 mix 任务 `perf_1c72c087...`(或新建):
- 「混合场景(Mix)」
- 「每虚拟用户独立登录」开关 ON
- CSV 区块启用参数化 + 粘贴账号 + 映射 `username→{{csv.username}}` `password→{{csv.password}}`
- 虚拟用户数 100 / 时长 1800s / ramp_up 30s
- 随机池:登录接口(权重 10)+ 预定 30 / 模板创建 20 / 模板查询 40
3. 用户在 UI 手动启动执行
4. 验证点:登录接口请求占比 ≈ 10% / 业务接口无 401 / 签名错误 / apiSummary 各接口统计正常
### 部署脚本
- `tmp/deploy_pervu_560.py` — 本次会话新增(5 文件上传 + dist 强制覆盖 + 重启 + 健康检查 + 列迁移确认)
---
## 🆕 上一会话(2026-09-01 — 混合场景(mix)压测 + 执行时长膨胀修复)
**任务来源**:PRD《混合场景压测》—— 数百虚拟用户,每次迭代按权重随机选接口(JMeter 概率分配器/Weighted Switch 语义);登录为**前置步骤**(共享 token,不进随机池)。走完整流程:PRD → 计划执行文档 → 代码 → 测试 → 部署 5.60 → 短时长验证 → **提交推送**
### 已实现并部署 5.60
......@@ -60,6 +179,16 @@
- **复验**:mix 120s/20 → `durationActual=123.96s`(≈1.03×,恢复线性),比例校验 PASS。
- **验证脚本**`tmp/mix_verify_560.py`(本地 `MIX_BASE=http://192.168.5.60:80` 运行)、`tmp/check_exec.py` / `tmp/diag_duration*.py` / `tmp/diag_timeline.py`(DB 诊断)、`tmp/force_upload_dist.py`(前端 dist 强制覆盖)。
### ✅ 已提交并推送(2026-09-01,origin/platform-auto-test)
| 提交 | 内容 |
|------|------|
| `35a0c281` | **docs(performance)**:混合场景压测 PRD 与执行计划 + 5.60 部署验收记录(HANDOFF 更新随 docs 提交) |
| `545ee9b4` | **feat(performance)**:mix 权重随机调度 + 时长膨胀修复 + mix 单测 11 条(后端 config/executor/schemas + 前端 TaskList/ReportPanel/types) |
> 推送经历一次 GitLab 连接失败(`Failed to connect to git.ubainsyun.com port 80`),重试后成功。远程提示可建 MR:`http://git.ubainsyun.com/bing/ubains-module-test/merge_requests/new?merge_request[source_branch]=platform-auto-test`
> 仓库仍有其它窗口遗留未提交项(projects 项目模块、lzzsh 安全脚本、test_screenshot_cleanup.py、UI 自动化 HANDOFF 等),本次提交**未包含**,归属各窗口自行提交。
### 踩坑(本次会话)
| 现象 | 根因 | 正确做法 |
......@@ -160,54 +289,58 @@
### 生产服务器 192.168.5.60
- 容器 `plat-auto-test-app`:healthy,内部端口 80
- **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控 + Transaction 事务处理 + burst/endurance 场景增强 + 报告页场景标签/多接口明细表 + **混合场景(mix)权重随机调度 + 时长膨胀修复(2026-09-01)**
- **未部署**:无(所有功能已部署)
- **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控 + Transaction 事务处理 + burst/endurance 场景增强 + 报告页场景标签/多接口明细表 + **混合场景(mix)权重随机调度 + 时长膨胀修复(2026-09-01)** + **混合场景(mix)per-VU 独立登录(2026-09-01 18:00)**
- **未部署**:无(所有功能已部署;per-VU 端到端验证由用户手动执行
### 本地(Windows 开发机)
- 后端 8001 / 前端 3000 | 数据库 `data/test_platform.db`
- **最新改动(2026-09-01)**:mix 场景后端(schemas/performance.py + executors/performance_executor.py)+ 前端(TaskList 权重编辑器 + ReportPanel 标签 + types)+ 时长膨胀修复(executor run_in_executor + config 默认关目标机 SSH 监控)。**已部署 5.60 并验证**,但**尚未 git 提交**
- **最新改动(2026-09-01 18:00)**:per-VU 独立登录改造 — 后端 `HttpClient.login_with_credentials` + `_extract_token` + `PerformanceExecutor._ensure_vu_token` / `_resolve_csv_credentials` / `_is_login_target(url_override)` / `_build_headers(bearer_token=, login_target=)` / `_send_request(bearer_token=)` / `_mix_worker` 启动登录+重登替换 / `_run_mix` CSV 校验 / `execute()` 跳过共享预登录 + `Model.per_vu_login` + `Schema.per_vu_login` + `database._ensure_columns` 迁移 + 前端 `types/performance.ts perVuLogin` + `TaskList.vue`「每虚拟用户独立登录」开关(仅 mix 显示)+ `TestPerVuLogin` 13 条单测 + `tmp/deploy_pervu_560.py`**已部署 5.60**`performance_tasks.per_vu_login` 自动迁移),**待 git 提交**(未 push)
---
## 未提交的代码
```
# 后端(已修改,均已部署 5.60)
backend/app/config.py # 时长膨胀修复:TARGET_MONITOR_ENABLED 默认 false
backend/app/executors/performance_executor.py # mix 场景 _run_mix/_mix_worker + sample() 丢线程池
backend/app/schemas/performance.py # ScenarioApiConfig.weight + scenario_type=mix
# 后端(新增)
backend/tests/test_performance_mix.py # mix 单测 11 passed
# 前端(已修改,dist 已部署)
frontend/src/views/performance/TaskList.vue # 混合场景下拉 + 权重编辑器 + 序列化
frontend/src/views/performance/ReportPanel.vue # mix 蓝色标签 + 接口明细
frontend/src/types/performance.ts # PerfScenarioType + 'mix' + weight 字段
> ⚠️ **per-VU 独立登录改造已实现并部署 5.60,但未 git 提交推送**(2026-09-01 18:00)— 见上方「当前会话」的「未提交的代码(本次会话)」清单,待 /GitCommit 整理。
> ✅ **mix 混合场景 + 时长膨胀修复已提交推送**(`35a0c281` docs + `545ee9b4` feat,2026-09-01,origin/platform-auto-test)。
# 文档(新增,待提交)
Docs/PRD/性能测试/需求文档/_PRD_混合场景压测.md # PRD
Docs/PRD/性能测试/需求文档/_PRD_混合场景压测_计划执行.md # 计划执行 + 六、5.60 部署验收(含时长膨胀根因/修复/复验)
# 工具脚本(tmp/,不入库)
tmp/mix_verify_560.py / check_exec.py / force_upload_dist.py / diag_duration*.py / diag_timeline.py / deploy_fix.py
```
# 本次会话 per-VU 相关 —— 未提交推送,待整理(见当前会话清单)
backend/app/executors/performance_executor.py + http_client.py + models/performance.py + schemas/performance.py + database.py
backend/tests/test_performance_mix.py
frontend/src/types/performance.ts + views/performance/TaskList.vue
HANDOFF_性能测试.md(本次进度更新)
# 仓库现存其它未提交项(非本次会话,归属其它窗口/待处理)
backend/app/models/project.py + routers/projects.py + schemas/project.py + services/project_service.py # 测试项目管理模块
frontend/src/api/project.ts + types/project.ts + views/Projects.vue # 项目管理前端
Docs/PRD/需求文档/模块管理/(2 篇 PRD) # 模块管理 PRD
Docs/PRD/需求文档/用例管理/_使用指南_语义化用例创建指导.md
backend/tests/test_screenshot_cleanup.py
backend/scripts/lzzsh_compromise_scan.py / lzzsh_host_check.sh / lzzsh_security_test.py # 安全测试脚本
backend/scripts/tmp_wait_deploy_544.py # 临时脚本,建议排除
backend/tmp/(gap_modules_probe*.json / sut_*.png) # 探测临时产物,建议排除
tmp/(deploy_pervu_560.py / diag_*/deploy_fix.py/mix_verify_*.py 等诊断脚本) # 不入库
backend/data/test_platform.db(M) # 数据库文件(gitignored 应忽略)
```
> **注意**:以上 mix 相关改动 + 时长膨胀修复已部署 5.60 并验证通过,但**尚未 git commit/push**。仓库内另有其它窗口的历史未提交项(projects/项目模块、lzzsh 安全脚本、UI 自动化 HANDOFF 等),提交时需按功能拆 commit、区分归属
> 上述非本次会话项目暂**未提交**,提交时需按功能拆 commit、区分归属(走 /GitCommit)
---
## 下一步任务
### P0 — 待办
-**per-VU 独立登录改造(已完成 2026-09-01)**:后端 executor/http_client/model/schema/database + 前端 types/TaskList + 13 单测 + 全量 388 passed + 5.60 部署(含 `performance_tasks.per_vu_login` 自动迁移列)。**端到端验证由用户手动执行**(用户提供 100 行 CSV,在 UI 启动 mix 任务)。
-**per-VU 改造 git 提交推送**:后端 5 文件 + 测试 + 前端 2 文件 + HANDOFF 更新,待 `/GitCommit` 整理 commit(建议拆 2 个 commit:`feat(performance): mix per-VU 独立登录(CSV 账号各自 token + 登录重登替换)` + `test(performance): TestPerVuLogin 13 条 + 全量回归`),暂未执行 commit/push(按"未明确要求时不自动 commit"约定)。
### P1 — 待办
-**mix 相关改动 git 提交(最高优先)**:PRD + 计划执行文档(docs commit)+ mix 后端/前端/单测 + 时长膨胀修复(feat/fix commit)。当前**已部署 5.60 并验证通过**,但尚未入库;仓库另有其它窗口遗留(projects 项目模块、lzzsh 安全脚本、UI 自动化 HANDOFF 等未提交项),提交时按功能拆分、区分归属(走 /GitCommit)。
- ✅ WebSocket 批量进度订阅端点(2026-08-24 本地完成,已包含在既有发布物中)。
### P1 — 按请求序号错开时间段(并发预约类接口关键)
`template_resolver.py` 增加 `INDEX` 后缀支持,如 `{__NOW_+37_H_INDEX__}`(mix 验证任务 body 已用该变量,模板解析已支持)
### P2 — 后续优化
断言规则验证(建议加 `$.success == true`)、Token 过期重试、报告对比、PDF 导出、数据清理
断言规则验证(建议加 `$.success == true`)、Token 过期重试、报告对比、PDF 导出、数据清理**per-VU 登录结果摘要(`login_summary` 字段)展示到报告页**(本期已写入 result dict 但未前端展示)
---
......@@ -223,6 +356,8 @@ tmp/mix_verify_560.py / check_exec.py / force_upload_dist.py / diag_duration*.py
| curl 导入 `[object Object]` | `CurlParseRequest``model_config` camelCase 映射 + Windows `^` 续行符未处理 | 补 `populate_by_name` + `_preprocess_cmd_style()` |
| 保存任务 422 | 非 Optional 字段接收 null → Pydantic v2 拒绝 | 改 `Optional` + `default=None` |
| 部署后文件未生效 | scp 后文件 mtime 未变(实际未覆盖) | 部署后 `grep`/`mtime` 验证关键代码行 |
| 本机探测 `nat.ubainsyun.com:25124` 连接拒绝 | NAT 域名在 Windows 开发机网络环境不可达 | API 探测直接用 `http://192.168.5.60`(宿主直连容器端口映射) |
| 老测试 `fake_send` 不收新 kwarg | `_mix_worker` 无条件传 `bearer_token` 破坏存量测试签名 | 条件传参 `if per_vu_login`,非 per-VU 保持原调用形态 |
---
......
......@@ -177,6 +177,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "login_username", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:自定义登录密码(旧库升级)
("performance_tasks", "login_password", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:per-VU 独立登录(mix 场景各虚拟用户独立 token,旧库升级)
("performance_tasks", "per_vu_login", "BOOLEAN DEFAULT 0"),
# 定时任务:用例类型(旧库升级,区分 UI/安全测试)
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级)
......
......@@ -116,6 +116,28 @@ class HttpClient:
return session
@staticmethod
def _extract_token(data: Any) -> Optional[str]:
"""
从登录响应中提取 Token(多路径兼容)
Args:
data: 登录接口 JSON 响应体
Returns:
str: Token,未找到返回 None
"""
if not isinstance(data, dict):
return None
return (
data.get('accessToken') or
data.get('token') or
data.get('access_token') or
data.get('data', {}).get('accessToken') or
data.get('data', {}).get('token') or
data.get('data', {}).get('access_token')
)
@staticmethod
def encrypt_password(password: str) -> str:
"""
......@@ -313,16 +335,7 @@ class HttpClient:
if resp.status_code == 200:
data = resp.json()
# 尝试从响应中提取 Token
token = None
if isinstance(data, dict):
token = (
data.get('accessToken') or
data.get('token') or
data.get('access_token') or
data.get('data', {}).get('accessToken') or
data.get('data', {}).get('token') or
data.get('data', {}).get('access_token')
)
token = self._extract_token(data)
if token:
self._tokens[account_key] = token
......@@ -338,6 +351,71 @@ class HttpClient:
return None
def login_with_credentials(self, username: str, password: str) -> Optional[str]:
"""
使用明文账号密码登录并获取 Token(per-VU 独立登录用)
与 login() 的区别:凭据由调用方传入(非 config.accounts),
返回的 Token 不写入 self._tokens 缓存(由调用方管理各自的 token)。
Args:
username: 账号用户名
password: 明文密码(内部做 SHA256 加密)
Returns:
str: 访问 Token,失败返回 None
"""
auth_config = self.config.get("auth", {})
login_path = auth_config.get("login_path", "/platform/api/auth/login")
url = f"{self.base_url}{login_path}"
captcha = self.config.get("accounts", {}).get("captcha", "csba")
# 获取验证码 UUID
uuid_val = self.get_captcha_uuid() or ''
# 构造登录请求体
encrypted_pwd = self.encrypt_password(password)
payload = {
"username": username,
"password": encrypted_pwd,
"code": captcha,
"uuid": uuid_val,
}
# 生成签名头部(登录时无 Token,使用随机密钥)
sign_headers = self._generate_sign(body_data=payload, bearer_token="")
# 通用请求头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'referer': '',
}
headers.update(sign_headers)
try:
logger.info(f"per-VU 正在登录: {username}")
resp = self.session.post(
url, json=payload, headers=headers,
verify=self.verify_ssl, timeout=self.timeout
)
if resp.status_code == 200:
data = resp.json()
token = self._extract_token(data)
if token:
logger.info(f"per-VU 登录成功: {username}, Token: {token[:30]}...")
return token
else:
logger.warning(f"per-VU 登录响应中未找到 Token: {json.dumps(data, ensure_ascii=False)[:300]}")
else:
logger.warning(f"per-VU 登录失败: {username}, HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as e:
logger.error(f"per-VU 登录异常 [{username}]: {e}")
return None
def get_token(self, account_key: str = "superadmin") -> Optional[str]:
"""
获取指定账号的 Token(如未登录则自动登录)
......
......@@ -713,6 +713,13 @@ class PerformanceExecutor:
self._request_detail_enabled = "off" # off / on / errors_only
self._request_detail_max = 50000
# per-VU 独立登录状态(mix 场景,2026-09-01 新增)
# {global_thread_idx: token},各虚拟用户持有各自的 token
self._vu_tokens: Dict[int, str] = {}
self._vu_tokens_lock = threading.Lock()
# 启动登录失败的虚拟用户索引集合
self._vu_login_failed: set = set()
def _get_http_client(self) -> HttpClient:
"""获取或创建 HttpClient 实例"""
if not self._http_client:
......@@ -737,19 +744,107 @@ class PerformanceExecutor:
return self._token or ""
def _is_login_target(self, task) -> bool:
def _is_login_target(self, task, url_override: Optional[str] = None) -> bool:
"""
判断目标任务是否为登录接口
登录接口需要特殊处理:请求体包含 username/password/code/uuid,
且请求头需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN/RandomCode)。
Args:
task: PerformanceTask 任务对象
url_override: 覆盖目标任务 URL(mix 场景传入 api_config.url 判断
随机池中的接口是否为登录接口)
Returns:
bool: 是否为登录接口
"""
login_path = self.http_client_config.get("auth", {}).get(
"login_path", "/platform/api/auth/login"
)
url = (task.target_url or "").lower()
url = (url_override or task.target_url or "").lower()
return login_path in url
def _resolve_csv_credentials(self, task, idx: int,
csv_records: Optional[list] = None,
csv_fields: Optional[list] = None,
csv_variable_mapping: Optional[dict] = None) -> tuple:
"""
解析指定虚拟用户索引的 CSV 登录凭据(username, password 明文)
per-VU 启动登录与登录接口重登共用:按线程索引取 CSV 行的
username/password 列(通过 csv_variable_mapping 映射列名)。
CSV 未启用或缺少对应列时回退到 account_key 默认账号。
Args:
task: PerformanceTask 任务对象
idx: 虚拟用户索引(global_thread_idx)
csv_records: CSV 参数化记录列表
csv_fields: CSV 字段名列表
csv_variable_mapping: CSV 列名→变量名映射
Returns:
tuple: (username, password) 明文凭据
"""
accounts = self.http_client_config.get("accounts", {})
account = accounts.get(task.account_key, {})
username = account.get("username", "admin@xty")
password = account.get("password", "Ubains@13579")
if csv_records and csv_fields:
csv_idx = idx % len(csv_records)
csv_row = csv_records[csv_idx]
mapping = csv_variable_mapping or {}
csv_username_col = self._resolve_csv_column_name(mapping.get("username", ""))
csv_password_col = self._resolve_csv_column_name(mapping.get("password", ""))
# 仅当列名真实存在于 CSV 表头时才取该列值,否则保留默认账号
if csv_username_col and csv_username_col in csv_fields and csv_row.get(csv_username_col):
username = csv_row[csv_username_col]
if csv_password_col and csv_password_col in csv_fields and csv_row.get(csv_password_col):
password = csv_row[csv_password_col]
return username, password
async def _ensure_vu_token(self, task, idx: int,
csv_records: Optional[list] = None,
csv_fields: Optional[list] = None) -> Optional[str]:
"""
per-VU 模式:虚拟用户启动时独立登录,获取自己的 token
同步的 requests 登录在线程池中执行(run_in_executor),避免阻塞
事件循环。登录耗时计入 worker 启动阶段(ramp_up 期)。
Args:
task: PerformanceTask 任务对象
idx: 虚拟用户索引(global_thread_idx)
csv_records: CSV 参数化记录列表
csv_fields: CSV 字段名列表
Returns:
str: 该虚拟用户的 token,登录失败返回 None
"""
# 幂等:该 VU 已登录持有 token 则直接返回(worker 重入/测试预置场景)
with self._vu_tokens_lock:
cached = self._vu_tokens.get(idx)
if cached:
return cached
username, password = self._resolve_csv_credentials(
task, idx, csv_records, csv_fields,
getattr(task, "csv_variable_mapping", None) or {}
)
client = self._get_http_client()
loop = asyncio.get_running_loop()
token = await loop.run_in_executor(
None, client.login_with_credentials, username, password
)
if token:
with self._vu_tokens_lock:
self._vu_tokens[idx] = token
else:
self._vu_login_failed.add(idx)
return token
def _get_login_body(self, task, csv_records: Optional[list] = None,
csv_fields: Optional[list] = None,
global_thread_idx: int = 0,
......@@ -820,7 +915,9 @@ class PerformanceExecutor:
match = re.search(r"\{\{csv\.([^}]+)\}\}", str(mapping_value))
return match.group(1) if match else str(mapping_value)
def _build_headers(self, task, body_for_sign: Any = None) -> Dict[str, str]:
def _build_headers(self, task, body_for_sign: Any = None,
bearer_token: Optional[str] = None,
login_target: Optional[bool] = None) -> Dict[str, str]:
"""
构造请求头
......@@ -828,6 +925,9 @@ class PerformanceExecutor:
task: PerformanceTask 任务对象
body_for_sign: 用于签名的实际请求体(替换模板后),
None 时使用 task.body
bearer_token: per-VU 模式透传该虚拟用户自己的 token;
默认为 None → 回落 self._token(共享模式)
login_target: 显式声明是否为登录接口(避免重复判断)
Returns:
dict: 请求头字典
......@@ -839,10 +939,14 @@ class PerformanceExecutor:
headers.setdefault("Content-Type", "application/json")
headers.setdefault("Accept", "application/json, text/plain, */*")
# 优先用透传的 token(per-VU),否则回落 self._token(共享模式)
token = bearer_token if bearer_token is not None else self._token
is_login = login_target if login_target is not None else self._is_login_target(task)
# 登录接口需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN/RandomCode)
# 注意:登录接口不能带 Authorization 头,否则签名校验会失败
# (签名是用 bearer_token="" 生成的,带 Authorization 会导致签名不匹配)
if self._is_login_target(task):
if is_login:
# 优先使用已解析的 body_for_sign(含 CSV 参数化后的用户名/密码)
# 确保签名与实际发送的请求体一致
body = body_for_sign if body_for_sign is not None else self._get_login_body(task)
......@@ -850,15 +954,15 @@ class PerformanceExecutor:
body_data=body, bearer_token=""
)
headers.update(sign_headers)
elif task.auth_required and self._token:
headers["Authorization"] = f"Bearer {self._token}"
elif task.auth_required and token:
headers["Authorization"] = f"Bearer {token}"
# 非登录接口但需要签名(如创建会议接口,sign_request=True)
if task.sign_request and not self._is_login_target(task) and self._token:
if task.sign_request and not is_login and 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}"
body_data=body_for_sign, bearer_token=f"Bearer {token}"
)
headers.update(sign_headers)
......@@ -1021,7 +1125,11 @@ class PerformanceExecutor:
self._request_details.clear()
# 1. 获取 Token
if task.auth_required:
# per-VU 独立登录模式:跳过共享预登录,由各 worker 启动时各自登录
per_vu_login = getattr(task, "per_vu_login", False)
self._vu_tokens.clear()
self._vu_login_failed.clear()
if task.auth_required and not per_vu_login:
token = self._ensure_token(task)
if not token:
self._running = False
......@@ -1115,6 +1223,22 @@ class PerformanceExecutor:
and len(request_details) >= self._request_detail_max
)
result["request_detail_enabled"] = self._request_detail_enabled
# 登录汇总(per-VU 模式记录各虚拟用户独立登录成败,2026-09-01 新增)
if per_vu_login:
vu_total = len(self._vu_tokens) + len(self._vu_login_failed)
result["login_summary"] = {
"mode": "per_vu",
"total": vu_total,
"success": len(self._vu_tokens),
"failed": len(self._vu_login_failed),
}
else:
result["login_summary"] = {
"mode": "shared",
"total": 1 if self._token else 0,
"success": 1 if self._token else 0,
"failed": 0 if self._token else 1,
}
return result
......@@ -1572,8 +1696,12 @@ class PerformanceExecutor:
- 解析 scenario_apis,取各接口 weight 归一化为概率列表
- concurrency 个 `_mix_worker` 协程常驻,每个迭代
random.choices(apis, weights) 选一个接口发请求
- 前置登录由 execute() 的 _ensure_token 完成(auth_required=true),
_build_headers 自动带 Authorization,故登录不应放入随机池
- 默认(非 per-VU):前置登录由 execute() 的 _ensure_token 完成
(auth_required=true),_build_headers 自动带 Authorization
- per-VU 独立登录(per_vu_login=true):每个虚拟用户启动时用 CSV
中的 username/password 各自登录持有独立 token;登录接口可放入
随机池按权重调度(模拟会话过期重登),重登成功后替换该虚拟用户
自己的 token;CSV 账号数不得少于虚拟用户数,否则拒绝执行
- 支持思考时间 / CSV 参数化 / 请求明细 / 接口维度统计(api_summary)
Args:
......@@ -1600,6 +1728,26 @@ class PerformanceExecutor:
if getattr(task, "csv_parameterization_enabled", False):
csv_records, csv_fields = self._parse_csv(task)
# per-VU 独立登录模式:必须通过 CSV 提供 username/password 列
per_vu_login = getattr(task, "per_vu_login", False)
if per_vu_login:
if not csv_records or not csv_fields:
return {"error": True, "error_message": "per-VU 独立登录必须启用 CSV 参数化并导入账号数据"}
mapping = getattr(task, "csv_variable_mapping", None) or {}
user_col = self._resolve_csv_column_name(mapping.get("username", ""))
pwd_col = self._resolve_csv_column_name(mapping.get("password", ""))
# 列名必须真实存在于 CSV 表头;未配置映射或列名不符时按字面 username/password 兜底
if not user_col or user_col not in csv_fields:
user_col = "username" if "username" in csv_fields else None
if not pwd_col or pwd_col not in csv_fields:
pwd_col = "password" if "password" in csv_fields else None
if not user_col or not pwd_col:
return {"error": True,
"error_message": "CSV 缺少 username/password 列(per-VU 独立登录需要)"}
if len(csv_records) < task.concurrency:
return {"error": True,
"error_message": f"CSV 账号数({len(csv_records)})少于虚拟用户数({task.concurrency}),不允许执行"}
try:
async with aiohttp.ClientSession(connector=connector) as session:
# 预热阶段:逐步启动 concurrency 个 worker 协程
......@@ -1673,6 +1821,10 @@ class PerformanceExecutor:
"""
混合场景工作协程:每次迭代按权重随机选一个接口发请求
per-VU 独立登录模式下,worker 启动时先用 CSV 账号各自登录获取
独立 token;随机池中的登录接口命中时执行重登,成功后替换本虚拟
用户的 token。
Args:
session: aiohttp 会话
task: PerformanceTask 任务对象
......@@ -1685,18 +1837,45 @@ class PerformanceExecutor:
weights = self._normalize_mix_weights(scenario_apis)
apis = list(scenario_apis)
# per-VU 独立登录:启动时各自登录获取自己的 token
per_vu_login = getattr(task, "per_vu_login", False)
bearer_token = None
if per_vu_login:
token = await self._ensure_vu_token(task, global_thread_idx,
csv_records, csv_fields)
if not token:
# 启动登录失败:该虚拟用户不发业务请求(计失败)
logger.warning(
f"[mix][VU-{global_thread_idx}] 启动登录失败,跳过业务请求"
)
return
bearer_token = token
request_index = 0
while self._running:
# 按权重随机选取接口(JMeter 概率分配器语义)
chosen = random.choices(apis, weights=weights, k=1)[0]
api_name = chosen.get("name", "") or "mix_api"
await self._send_request(
# 非 per-VU 模式不传 bearer_token(保持原有调用协议不变)
send_kwargs = {}
if per_vu_login:
send_kwargs["bearer_token"] = bearer_token
new_token = await self._send_request(
session, task, request_index,
api_config=chosen, api_name=api_name,
csv_records=csv_records, csv_fields=csv_fields,
global_thread_idx=global_thread_idx,
**send_kwargs,
)
# per-VU 模式:登录接口命中且重登成功 → 替换本虚拟用户 token
if per_vu_login and new_token:
with self._vu_tokens_lock:
self._vu_tokens[global_thread_idx] = new_token
bearer_token = new_token
elif per_vu_login and new_token is None and self._is_login_target(task, url_override=chosen.get("url")):
# 登录接口命中但重登失败:保留旧 token,继续压测(告警在 _send_request 内)
pass
request_index += 1
# 思考时间(Think Time)
......@@ -1843,7 +2022,8 @@ class PerformanceExecutor:
async def _send_request(self, session: aiohttp.ClientSession, task, request_index: int = 0,
api_config: Optional[dict] = None, api_name: str = "",
csv_records: Optional[list] = None, csv_fields: Optional[list] = None,
global_thread_idx: int = 0) -> None:
global_thread_idx: int = 0,
bearer_token: Optional[str] = None) -> Optional[str]:
"""
发送单个 HTTP 请求并记录指标
......@@ -1856,6 +2036,14 @@ class PerformanceExecutor:
csv_records: CSV 参数化记录列表
csv_fields: CSV 字段名列表
global_thread_idx: 全局线程索引(用于 CSV 分配)
bearer_token: per-VU 模式透传该虚拟用户自己的 token;
默认为 None → 回落 self._token(共享模式)
Returns:
Optional[str]:
- 登录接口(2xx)且提取成功:返回新 token
- 登录接口(2xx)但提取失败:返回 None(调用方保留旧 token)
- 非登录接口:返回 None
"""
start = time.time()
status = 0
......@@ -1895,9 +2083,12 @@ class PerformanceExecutor:
api_assertions = task.assertions or []
api_unique_fields = getattr(task, "unique_fields", []) or []
# 登录接口识别(mix 场景:传入 api_config.url 即可识别随机池中的登录接口)
is_login = self._is_login_target(task, url_override=url)
# 登录接口使用自动构造的请求体(含加密密码 + 验证码UUID)
# 支持 CSV 参数化:启用后按线程索引取不同虚拟用户的用户名/密码
if self._is_login_target(task):
if is_login:
json_data = self._get_login_body(
task, csv_records, csv_fields, global_thread_idx,
getattr(task, "csv_variable_mapping", None) or {}
......@@ -1921,7 +2112,11 @@ class PerformanceExecutor:
csv_record = csv_records[csv_idx]
json_data = self._resolve_csv_vars(json_data, csv_record, csv_fields)
headers = self._build_headers(task, body_for_sign=json_data)
# 透传 per-VU token(_build_headers 内部按 login_target 区分签名/Authorization 头)
headers = self._build_headers(
task, body_for_sign=json_data,
bearer_token=bearer_token, login_target=is_login,
)
# 长稳压测:合并 api_config 级请求头
if api_config:
api_extra_headers = api_config.get("headers", {}) or {}
......@@ -1975,6 +2170,19 @@ class PerformanceExecutor:
# 响应捕获(从响应中提取字段保存到 _captured)
self._capture_response(task.capture_rules, body, status)
# per-VU 模式:登录接口命中且成功 → 提取 token 返回给 worker(替换该 VU 自己的 token)
if is_login and 200 <= status < 300:
try:
data = json.loads(body) if body else {}
except (json.JSONDecodeError, TypeError):
data = {}
new_token = HttpClient._extract_token(data)
if not new_token:
logger.warning(
f"[mix][VU-{global_thread_idx}] 登录重登响应中未找到 Token,保留旧 token"
)
return new_token
except asyncio.TimeoutError:
elapsed_ms = (time.time() - start) * 1000
error_type = "timeout"
......
......@@ -226,6 +226,10 @@ class PerformanceTask(Base):
login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码"
)
# per-VU 独立登录(mix 场景,CSV 账号各自登录持有独立 token,2026-09-01 新增)
per_vu_login: Mapped[bool] = mapped_column(
Boolean, default=False, comment="每个虚拟用户独立登录(CSV账号各自获取token)"
)
# 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
......@@ -379,6 +383,7 @@ class PerformanceTask(Base):
"account_key": self.account_key,
"login_username": self.login_username,
"login_password": self.login_password,
"per_vu_login": self.per_vu_login,
"sign_request": self.sign_request,
"preset_id": self.preset_id,
"assertions": self.assertions or [],
......
......@@ -145,6 +145,8 @@ class PerformanceTaskCreate(BaseModel):
account_key: str = Field("superadmin", description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# per-VU 独立登录(mix 场景:CSV 账号各自登录持有独立 token,2026-09-01 新增)
per_vu_login: bool = Field(False, description="每个虚拟用户独立登录(CSV账号各自获取token)")
# 签名配置
sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
......@@ -212,6 +214,8 @@ class PerformanceTaskUpdate(BaseModel):
account_key: Optional[str] = Field(None, description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# per-VU 独立登录(mix 场景,2026-09-01 新增)
per_vu_login: Optional[bool] = Field(None, description="每个虚拟用户独立登录(CSV账号各自获取token)")
sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
preset_id: Optional[str] = Field(None, description="关联的接口预设ID")
assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表")
......@@ -257,6 +261,7 @@ class PerformanceTaskResponse(BaseModel):
account_key: str = "superadmin"
login_username: Optional[str] = None
login_password: Optional[str] = None
per_vu_login: bool = False
sign_request: bool = False
assertions: List[Dict[str, Any]] = []
capture_rules: Optional[List[Dict[str, Any]]] = None
......
......@@ -154,3 +154,263 @@ class TestMixWorkerScheduling:
assert abs(counts["预定"] / total - 0.3333) < 0.15
assert abs(counts["模板创建"] / total - 0.2222) < 0.15
assert abs(counts["模板查询"] / total - 0.4444) < 0.15
# ==================== per-VU 独立登录(2026-09-01 新增) ====================
def _make_vu_executor(http_client=None):
"""构造绕过 __init__ 的 PerformanceExecutor(属性注入)"""
ex = PerformanceExecutor.__new__(PerformanceExecutor)
ex._running = True
ex._token = None
ex._http_client = http_client
ex._vu_tokens = {}
ex._vu_tokens_lock = __import__("threading").Lock()
ex._vu_login_failed = set()
ex.http_client_config = {
"auth": {"login_path": "/platform/api/auth/login"},
"accounts": {"superadmin": {"username": "admin@xty", "password": "Ubains@13579"},
"captcha": "csba"},
}
return ex
def _make_vu_task(**overrides):
"""构造 per-VU mix 任务(SimpleNamespace)"""
from types import SimpleNamespace
defaults = dict(
id="perf_test", target_url="http://x/book", method="PUT", headers={},
body=None, auth_required=True, account_key="superadmin", sign_request=True,
per_vu_login=True, concurrency=2, duration=1, ramp_up=0,
scenario_type="mix",
scenario_apis=[
{"name": "预定", "method": "PUT", "url": "http://x/book", "weight": 30},
{"name": "登录", "method": "POST", "url": "http://x/platform/api/auth/login", "weight": 10},
],
csv_parameterization_enabled=True,
csv_content="username,password\nu1,p1\nu2,p2",
csv_variable_mapping={"username": "{{csv.username}}", "password": "{{csv.password}}"},
think_time_enabled=False,
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
class TestPerVuLogin:
"""per-VU 独立登录:各自 token / 重登替换 / 兼容共享模式"""
def test_resolve_csv_credentials_by_idx(self):
"""CSV 凭据按线程索引分配(idx % len 循环复用)"""
ex = _make_vu_executor()
task = _make_vu_task()
records, fields = PerformanceExecutor._parse_csv(task)
u0, p0 = ex._resolve_csv_credentials(task, 0, records, fields, task.csv_variable_mapping)
u1, p1 = ex._resolve_csv_credentials(task, 1, records, fields, task.csv_variable_mapping)
u2, p2 = ex._resolve_csv_credentials(task, 2, records, fields, task.csv_variable_mapping)
assert (u0, p0) == ("u1", "p1")
assert (u1, p1) == ("u2", "p2")
assert (u2, p2) == ("u1", "p1"), "超出行数循环复用"
@pytest.mark.asyncio
async def test_per_vu_startup_login_each_vu_own_token(self):
"""每个虚拟用户启动时独立登录,各自持有自己的 token"""
class FakeClient:
def __init__(self):
self.calls = []
def login_with_credentials(self, username, password):
self.calls.append((username, password))
return f"token_of_{username}"
client = FakeClient()
ex = _make_vu_executor(http_client=client)
task = _make_vu_task()
records, fields = PerformanceExecutor._parse_csv(task)
t0 = await ex._ensure_vu_token(task, 0, records, fields)
t1 = await ex._ensure_vu_token(task, 1, records, fields)
assert t0 == "token_of_u1"
assert t1 == "token_of_u2"
assert ex._vu_tokens == {0: "token_of_u1", 1: "token_of_u2"}
assert ex._vu_login_failed == set()
@pytest.mark.asyncio
async def test_per_vu_startup_login_failure_recorded(self):
"""启动登录失败 → 记入 _vu_login_failed,返回 None"""
class FakeClient:
def login_with_credentials(self, username, password):
return None
ex = _make_vu_executor(http_client=FakeClient())
task = _make_vu_task()
records, fields = PerformanceExecutor._parse_csv(task)
token = await ex._ensure_vu_token(task, 0, records, fields)
assert token is None
assert 0 in ex._vu_login_failed
assert 0 not in ex._vu_tokens
@pytest.mark.asyncio
async def test_mix_worker_relogin_replaces_own_token(self):
"""登录接口进随机池:命中后替换该 VU 自己的 token,不影响其他 VU"""
captured_tokens = []
class FakeMetrics:
def record(self, *args, **kwargs):
pass
ex = _make_vu_executor()
ex._metrics = FakeMetrics()
task = _make_vu_task(duration=0.1)
async def fake_send(session, task_, request_index, api_config=None, api_name="",
csv_records=None, csv_fields=None, global_thread_idx=0,
bearer_token=None):
captured_tokens.append((global_thread_idx, api_name, bearer_token))
if "login" in api_config["url"]:
return f"new_token_vu{global_thread_idx}_r{request_index}"
await asyncio.sleep(0.01)
return None
ex._send_request = fake_send
# 预置启动 token
ex._vu_tokens = {0: "vu0_old", 1: "vu1_old"}
records, fields = PerformanceExecutor._parse_csv(task)
workers = [
asyncio.create_task(ex._mix_worker(None, task, task.scenario_apis, idx,
records, fields))
for idx in (0, 1)
]
# 给 worker 足够时间执行多轮(含登录接口命中)
await asyncio.sleep(0.5)
ex._running = False
await asyncio.gather(*workers, return_exceptions=True)
# 两个 VU 的 token 都应被替换为各自的新 token
assert ex._vu_tokens[0].startswith("new_token_vu0_")
assert ex._vu_tokens[1].startswith("new_token_vu1_")
# 业务请求携带各自的 per-VU token(非共享;重登可能多次,token 属于该 VU 即可)
business = [(idx, tok) for idx, name, tok in captured_tokens if "login" not in name]
assert business, "应有业务请求"
for idx, tok in business:
assert tok.startswith(f"new_token_vu{idx}_") or tok == f"vu{idx}_old", \
f"VU-{idx} 应使用自己的 token,实际 {tok}"
@pytest.mark.asyncio
async def test_mix_worker_no_send_when_startup_login_fails(self):
"""启动登录失败:该 VU 不发任何业务请求"""
send_count = {"n": 0}
class FakeClient:
def login_with_credentials(self, username, password):
return None
ex = _make_vu_executor(http_client=FakeClient())
ex._metrics = type("M", (), {"record": staticmethod(lambda *a, **k: None)})()
task = _make_vu_task()
async def fake_send(*args, **kwargs):
send_count["n"] += 1
return None
ex._send_request = fake_send
records, fields = PerformanceExecutor._parse_csv(task)
await ex._mix_worker(None, task, task.scenario_apis, 0, records, fields)
assert send_count["n"] == 0, "启动登录失败的 VU 不应发业务请求"
@pytest.mark.asyncio
async def test_run_mix_rejects_when_csv_accounts_less_than_concurrency(self):
"""CSV 账号数少于虚拟用户数 → 拒绝执行(error)"""
ex = _make_vu_executor()
task = _make_vu_task(concurrency=5, csv_content="username,password\nu1,p1\nu2,p2")
result = await ex._run_mix(task)
assert result.get("error") is True
assert "少于" in result.get("error_message", "")
@pytest.mark.asyncio
async def test_run_mix_rejects_when_csv_missing_columns(self):
"""CSV 缺少 username/password 列 → 拒绝执行(error)"""
ex = _make_vu_executor()
task = _make_vu_task(csv_content="name,pwd\nu1,p1")
result = await ex._run_mix(task)
assert result.get("error") is True
assert "username/password" in result.get("error_message", "")
@pytest.mark.asyncio
async def test_run_mix_rejects_when_csv_disabled(self):
"""未启用 CSV 参数化 → per-VU 模式拒绝执行"""
ex = _make_vu_executor()
task = _make_vu_task(csv_parameterization_enabled=False)
result = await ex._run_mix(task)
assert result.get("error") is True
def test_shared_mode_regression_bearer_fallback(self):
"""非 per-VU:bearer_token=None 回落 self._token(共享模式不变)"""
ex = _make_vu_executor()
ex._token = "shared_token"
task = _make_vu_task(per_vu_login=False)
class FakeClientWithSign:
def _generate_sign(self, body_data=None, bearer_token=""):
return {"X-SIGN": "s"}
ex._http_client = FakeClientWithSign()
headers = ex._build_headers(task, body_for_sign={"a": 1}, login_target=False)
assert headers["Authorization"] == "Bearer shared_token"
def test_per_vu_bearer_override(self):
"""per-VU:bearer_token 透传覆盖共享 token"""
ex = _make_vu_executor()
ex._token = "shared_token"
task = _make_vu_task()
class FakeClientWithSign:
def _generate_sign(self, body_data=None, bearer_token=""):
return {"X-SIGN": bearer_token}
ex._http_client = FakeClientWithSign()
headers = ex._build_headers(task, body_for_sign={"a": 1},
bearer_token="vu_own_token", login_target=False)
assert headers["Authorization"] == "Bearer vu_own_token"
def test_login_target_never_carries_authorization(self):
"""登录接口:签名 bearer_token="" 且不带 Authorization 头"""
ex = _make_vu_executor()
ex._token = "shared_token"
task = _make_vu_task()
captured = {}
class FakeClientWithSign:
def _generate_sign(self, body_data=None, bearer_token=""):
captured["bearer"] = bearer_token
return {"X-SIGN": "s"}
ex._http_client = FakeClientWithSign()
headers = ex._build_headers(task, body_for_sign={"username": "u1"},
bearer_token="vu_own", login_target=True)
assert "Authorization" not in headers
assert captured["bearer"] == ""
def test_extract_token_static(self):
"""HttpClient._extract_token 多路径提取"""
from app.executors.http_client import HttpClient
assert HttpClient._extract_token({"accessToken": "t1"}) == "t1"
assert HttpClient._extract_token({"token": "t2"}) == "t2"
assert HttpClient._extract_token({"data": {"accessToken": "t3"}}) == "t3"
assert HttpClient._extract_token({"data": {"token": "t4"}}) == "t4"
assert HttpClient._extract_token({}) is None
assert HttpClient._extract_token("not_dict") is None
def test_is_login_target_url_override(self):
"""_is_login_target 支持 url_override(识别随机池中的登录接口)"""
ex = _make_vu_executor()
task = _make_vu_task(target_url="http://x/book")
assert ex._is_login_target(task) is False
assert ex._is_login_target(task, url_override="http://x/platform/api/auth/login") is True
assert ex._is_login_target(task, url_override="http://x/book") is False
......@@ -350,6 +350,7 @@ export interface PerformanceTask {
accountKey: string | null
loginUsername: string | null
loginPassword: string | null
perVuLogin: boolean
signRequest: boolean
assertions: AssertionRule[]
totalRequests: number | null
......@@ -446,6 +447,7 @@ export interface PerformanceTaskCreate {
accountKey?: string | null
loginUsername?: string | null
loginPassword?: string | null
perVuLogin?: boolean
signRequest?: boolean
assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
......@@ -494,6 +496,7 @@ export interface PerformanceTaskUpdate {
accountKey?: string | null
loginUsername?: string | null
loginPassword?: string | null
perVuLogin?: boolean
signRequest?: boolean
assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null
......
......@@ -459,7 +459,7 @@
<el-form-item>
<template #label>
CSV 数据内容
<FieldTip content="第一行为字段名,后续每行为一条数据。登录多账号场景列名用 username/password,每个虚拟用户取一行" />
<FieldTip content="第一行为字段名,后续每行为一条数据。per-VU 独立登录时列名必须用 username/password,每个虚拟用户取一行;账号数不得少于虚拟用户数" />
</template>
<div style="display: flex; gap: 8px; align-items: flex-start; width: 100%;">
<el-input v-model="form.csvContent" type="textarea" :rows="4" placeholder="第一行为字段名,后续行为数据行&#10;username,password&#10;user1,pass1&#10;user2,pass2" style="flex: 1;" />
......@@ -482,6 +482,14 @@
</template>
<el-input v-model="form.csvVariableMapping" type="textarea" :rows="2" placeholder="{&quot;username&quot;: &quot;{{csv.username}}&quot;, &quot;password&quot;: &quot;{{csv.password}}&quot;}" />
</el-form-item>
<!-- per-VU 独立登录(仅 mix 场景) -->
<el-form-item v-if="form.scenarioType === 'mix'">
<template #label>
每虚拟用户独立登录
<FieldTip content="开启后每个虚拟用户用 CSV 的 username/password 列各自登录获取独立 token;登录接口可加入随机池模拟会话过期重登,重登成功后替换该 VU 自己的 token。CSV 账号数不得少于虚拟用户数" />
</template>
<el-switch v-model="form.perVuLogin" />
</el-form-item>
</template>
</template>
......@@ -761,6 +769,8 @@ interface TaskForm {
csvParameterizationEnabled: boolean
csvContent: string
csvVariableMapping: string
/** per-VU 独立登录(仅 mix 场景,CSV 账号各自登录持有独立 token) */
perVuLogin: boolean
/** 请求详情采集模式 */
requestDetailEnabled: RequestDetailMode
}
......@@ -836,6 +846,7 @@ const defaultForm = (): TaskForm => ({
csvParameterizationEnabled: false,
csvContent: '',
csvVariableMapping: '',
perVuLogin: false,
requestDetailEnabled: 'off',
})
......@@ -959,6 +970,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
csvParameterizationEnabled: fullTask.csvParameterizationEnabled ?? false,
csvContent: fullTask.csvContent || '',
csvVariableMapping: fullTask.csvVariableMapping ? JSON.stringify(fullTask.csvVariableMapping, null, 2) : '',
perVuLogin: fullTask.perVuLogin ?? false,
requestDetailEnabled: fullTask.requestDetailEnabled || 'off',
})
dialogVisible.value = true
......@@ -1268,6 +1280,7 @@ async function handleSave() {
csvContent: form.csvParameterizationEnabled ? form.csvContent : null,
csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping
? safeJsonParse(form.csvVariableMapping) : null,
perVuLogin: form.scenarioType === 'mix' ? form.perVuLogin : false,
requestDetailEnabled: form.requestDetailEnabled || 'off',
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论