提交 5758f3c5 authored 作者: 陈泽健's avatar 陈泽健

Merge remote-tracking branch 'origin/platform-auto-test' (10c8a1a2 另一窗口: 用例JSON修复)

...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
> **创建日期**: 2026-09-09 > **创建日期**: 2026-09-09
> **作者**: czj > **作者**: czj
> **关联问题**: [_问题处理_表格选择器类号不稳定导致回放失败.md](_问题处理_表格选择器类号不稳定导致回放失败.md) > **关联问题**: [_问题处理_表格选择器类号不稳定导致回放失败.md](_问题处理_表格选择器类号不稳定导致回放失败.md)
> **状态**: 执行中 > **状态**: 已完成
> **关联执行**: exec_5f866bda62c0466a9db881f8ca501375(手动执行-2026/9/9 14:39:52,5.60 环境) > **关联执行**: exec_5f866bda62c0466a9db881f8ca501375(手动执行-2026/9/9 14:39:52,5.60 环境)
> **关联用例**: case_aafbdfb50fa5401a92ba3f8145b8bf07「完整预约流程验证」(VNC 录制) > **关联用例**: case_aafbdfb50fa5401a92ba3f8145b8bf07「完整预约流程验证」(VNC 录制)
...@@ -22,32 +22,33 @@ ...@@ -22,32 +22,33 @@
### 阶段 1:代码修改(recorder_engine.py + playwright_executor.py) ### 阶段 1:代码修改(recorder_engine.py + playwright_executor.py)
- [ ] 1.1 `backend/app/services/recorder_engine.py` - [x] 1.1 `backend/app/services/recorder_engine.py`
- `stableClasses()` 过滤 `el-table_\d+(_column_\d+)?$`(生成类) - `stableClasses()` 过滤 `el-table_\d+(_column_\d+)?$`(生成类)
- `buildCandidates()` 新增表格作用域候选(`.el-table >> nth=<表序>` + 结构位) - `buildCandidates()` 新增表格作用域候选(`.el-table >> nth=<表序>` + 结构位)
- **新增 pickerHost 逻辑**:文本输入若位于 `.el-date-editor` / `.el-select` 等容器内,不再忽略聚焦点击(记录容器 click 触发面板)
- [ ] 1.2 `backend/app/executors/playwright_executor.py` - [x] 1.2 `backend/app/executors/playwright_executor.py`
- `L2685 _selector_exists_fast()` 失败后 2s 重试一次再判死 - `L2685 _selector_exists_fast()` 失败后 2s 重试一次再判死
### 阶段 2:用例数据修复 ### 阶段 2:用例数据修复
- [ ] 2.1 写脚本 `backend/scripts/fix_case_5f86.py` 更新 case_aafbdfb50fa5401a92ba3f8145b8bf07 8 个表格步骤为稳定选择器 - [x] 2.1 写脚本 `backend/scripts/fix_case_5f86_step17.py` 更新 case_aafbdfb50fa5401a92ba3f8145b8bf07 表格步骤为稳定选择器 + 插入时间面板触发步骤
### 阶段 3:部署 5.60 ### 阶段 3:部署 5.60
- [ ] 3.1 本地修改后 `npm run build` 前端 - [x] 3.1 本地修改后 `npm run build` 前端
- [ ] 3.2 scp dist + backend 服务到 5.60 - [x] 3.2 scp dist + backend 服务到 5.60
- [ ] 3.3 `docker compose restart app` - [x] 3.3 `docker compose restart app`
### 阶段 4:重新执行验证 ### 阶段 4:重新执行验证
- [ ] 4.1 重跑 case_aafbdfb50fa5401a92ba3f8145b8bf07(手动或脚本) - [x] 4.1 重跑 case_aafbdfb50fa5401a92ba3f8145b8bf07(手动或脚本)
- [ ] 4.2 验证两表勾选通过、无黑边 - [x] 4.2 验证两表勾选通过、时间面板步骤通过、后续步骤全部通过
### 阶段 5:归档 ### 阶段 5:归档
- [ ] 5.1 两份文档状态更新为「已完成」 - [x] 5.1 两份文档状态更新为「已完成」
- [ ] 5.2 HANDOFF_UI自动化.md 增补会话 66 章节 - [x] 5.2 HANDOFF_UI自动化.md 增补会话 66 章节
--- ---
...@@ -55,30 +56,33 @@ ...@@ -55,30 +56,33 @@
### 阶段 1-2(2026-09-09 本地实施) ### 阶段 1-2(2026-09-09 本地实施)
- recorder_engine.py 两处修改 - recorder_engine.py 三处修改(stableClasses 过滤 + 表格作用域候选 + pickerHost 面板触发)
- playwright_executor.py 两处修改 - playwright_executor.py 两处修改
- fix_case_5f86.py 编写完成(待部署后运行) - fix_case_5f86_step17.py 编写完成(待部署后运行)
### 阶段 3-4(2026-09-09 部署 5.60) ### 阶段 3-4(2026-09-09 部署 5.60)
- dist 部署 + 容器重启 - dist 部署 + 容器重启
- 重新执行用例通过(表格步骤全部通过) - fix_case_5f86_step17.py 在容器内运行(36→37 步,插入「点击会议开始时间」+ 时间项稳定选择器)
- 重新执行用例通过(37/37 全部通过,含时间面板打开 + 时间选择步骤)
### 阶段 5(归档) ### 阶段 5(归档)
- 两份文档状态更新 - 两份文档状态更新
- 归档文档 [_归档_5f866bda62c0466a9db881f8ca501375.md](_归档_5f866bda62c0466a9db881f8ca501375.md) 已生成
- HANDOFF_UI自动化.md 增补 - HANDOFF_UI自动化.md 增补
--- ---
## 四、验收标准 ## 四、验收标准
| 测试项 | 预期结果 | | 测试项 | 预期结果 | 实际结果 |
|--------|----------| |--------|----------|----------|
| 录制器 | 新录制的表格勾选步骤候选中不含 `el-table_\d+_column_\d+` | | 录制器 | 新录制的表格勾选步骤候选中不含 `el-table_\d+_column_\d+` | ✅ 已过滤 |
| 执行器 | 表格选择器在异步渲染后仍能被命中 | | 录制器 | 时间面板触发点击不再被忽略 | ✅ pickerHost 逻辑 + 单测通过 |
| 用例 | case_aafbdfb50fa5401a92ba3f8145b8bf07 重新执行通过,步骤 13-18 全部通过 | | 执行器 | 表格选择器在异步渲染后仍能被命中 | ✅ 2s 重试缓冲 |
| 回归 | 非 table 元素的录制候选行为不变 | | 用例 | case_aafbdfb50fa5401a92ba3f8145b8bf07 重新执行通过,步骤 13-18 全部通过 | ✅ exec_cf9a0c1ff55b47219af3ee01491ff2de 37/37 通过 |
| 回归 | 非 table 元素的录制候选行为不变 | ✅ tests/test_recorder_e2e.py 15/15 通过 |
--- ---
...@@ -86,9 +90,9 @@ ...@@ -86,9 +90,9 @@
| 文件 | 角色 | | 文件 | 角色 |
|------|------| |------|------|
| `backend/app/services/recorder_engine.py` | 录制器候选生成(本次唯一改动点) | | `backend/app/services/recorder_engine.py` | 录制器候选生成(本次改动点) |
| `backend/app/executors/playwright_executor.py` | 执行器快速探测(本次唯一改动点) | | `backend/app/executors/playwright_executor.py` | 执行器快速探测(本次改动点) |
| `backend/scripts/fix_case_5f86.py` | 用例数据修复脚本 | | `backend/scripts/fix_case_5f86_step17.py` | 用例数据修复脚本 |
| `backend/scripts/probe_table_classes.py` | 排查脚本(已完成) | | `backend/scripts/probe_table_classes.py` | 排查脚本(已完成) |
| `backend/scripts/probe_replay_exec5f86.py` | 回放复现脚本(已完成) | | `backend/scripts/probe_replay_exec5f86.py` | 回放复现脚本(已完成) |
......
# 执行计划 — 5.44 定时执行被取消导致钉钉报告通过率仅 15.25% # 执行计划 — 5.44 定时执行被取消导致钉钉报告通过率仅 15.25%
> **版本**: 2026-09-09(v2,按用户确认方案更新:停止通知替代静默跳过 > **版本**: 2026-09-09(v3:链接定稿为直达 UI 自动化执行中心 `/execution/ui`
> **模块**: 执行中心 / 定时任务 / 钉钉通知 > **模块**: 执行中心 / 定时任务 / 钉钉通知
> **优先级**: P0(立即修复,误导性告警) > **优先级**: P0(立即修复,误导性告警)
> **状态**: 已完成(代码 + 单测 483/483 通过,待三台部署) > **状态**: 已完成(代码 + 单测 483/483 通过;**5.60 已部署 + 实测**,5.44/5.202 待部署)
--- ---
...@@ -92,13 +92,14 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1. ...@@ -92,13 +92,14 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1.
⏰ 停止时间:2026-09-09 11:54:02 ⏰ 停止时间:2026-09-09 11:54:02
📎 查看本次执行报告(超链接,直达 `{PLATFORM_BASE_URL}/api/reports/generate/{exec_id}`) 📎 [访问测试管理平台-执行中心]({PLATFORM_BASE_URL}/execution/ui)
``` ```
设计要点: 设计要点:
- **无通过率行**——中断执行的数据不完整,给通过率必然误导(本次 15.25% 事件的根源);仅计数。 - **无通过率行**——中断执行的数据不完整,给通过率必然误导(本次 15.25% 事件的根源);仅计数。
- **标题独立**(「UI自动化执行已停止」),与常规报告「UI自动化定时报告」区分,群成员一眼分辨。 - **标题独立**(「UI自动化执行已停止」),与常规报告「UI自动化定时报告」区分,群成员一眼分辨。
- **@策略**:手动取消不@人(操作方本人已知);异常中断(看门狗/进程重启/引擎异常)按配置@负责人。 - **@策略**:手动取消不@人(操作方本人已知);异常中断(看门狗/进程重启/引擎异常)按配置@负责人。
- **链接定稿(v3,用户 3 连纠正)**:v1 直达报告生成接口 `/api/reports/generate/{exec_id}` → 用户否决「怎么会是跳转到报告界面?」;v2 平台根路径 → 用户否决「应该是跳转到UI自动化模块下的执行中心才对」;**v3 定稿 `{PLATFORM_BASE_URL}/execution/ui`**(前端路由「自动化测试 → 执行中心」,见 `frontend/src/router/index.ts`)。
- 中断判定:`cancelled` → 手动取消;`failed` + error_message 命中中断关键词(看门狗/进程重启/被中断等)→ 异常中断;`failed` 且无用例结果 → 引擎异常。有完整结果统计的 `failed`(真实失败)仍走常规 ⚠️ 报告。 - 中断判定:`cancelled` → 手动取消;`failed` + error_message 命中中断关键词(看门狗/进程重启/被中断等)→ 异常中断;`failed` 且无用例结果 → 引擎异常。有完整结果统计的 `failed`(真实失败)仍走常规 ⚠️ 报告。
## 四、执行计划(实际落地) ## 四、执行计划(实际落地)
...@@ -135,10 +136,16 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1. ...@@ -135,10 +136,16 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1.
## 五、验收标准 ## 五、验收标准
- [x] 手动取消一条运行中执行 → 钉钉群收到「🛑 执行已停止」通知(非通过率报告),报告中心带 cancelled 标识(单测覆盖)
- [x] 重启容器打断执行 → 恢复后被中断用例状态为 `skipped`,通过率统计不含假失败(单测覆盖)
## 五、验收标准
- [x] 手动取消一条运行中执行 → 钉钉群收到「🛑 执行已停止」通知(非通过率报告),报告中心带 cancelled 标识(单测覆盖) - [x] 手动取消一条运行中执行 → 钉钉群收到「🛑 执行已停止」通知(非通过率报告),报告中心带 cancelled 标识(单测覆盖)
- [x] 重启容器打断执行 → 恢复后被中断用例状态为 `skipped`,通过率统计不含假失败(单测覆盖) - [x] 重启容器打断执行 → 恢复后被中断用例状态为 `skipped`,通过率统计不含假失败(单测覆盖)
- [x] 后端全量 pytest 通过(483/483) - [x] 后端全量 pytest 通过(483/483)
- [ ] 三台同步部署(5.44 / 5.202 / 5.60)——**待执行** - [x] **5.60 部署完成**`deploy_to_60.py` 同步核心服务 `dingtalk_notify_service.py` + `execution_service.py`(MD5 一致)→ 重启 → `/health` healthy → 容器内 md5sum 复核一致
- [x] **5.60 停止通知实测**`send_test_stop_notice_60.py` 推送 🛑 停止通知成功(`{'errcode': 0, 'errmsg': 'ok'}`),末尾链接 `[访问测试管理平台-执行中心](http://192.168.5.60/execution/ui)` 直达执行中心
- [ ] **5.44 / 5.202 部署**——待执行
- [x] 前端取消按钮提示文案生效(本地构建验证) - [x] 前端取消按钮提示文案生效(本地构建验证)
## 六、风险与缓解 ## 六、风险与缓解
......
# 执行计划文档 - 修复执行中心用例JSON字段双重编码报错
> **文档类型**: 计划执行文档
> **创建日期**: 2026-09-10
> **作者**: czj
> **优先级**: P0
> **关联问题**: `_问题处理_5.60执行中心用例JSON双重编码报错.md`
---
## 一、改动总览
| 文件 | 改动类型 | 改动量 | 说明 |
|------|---------|--------|------|
| `backend/app/routers/cases.py` | 修改 | ~40 行 | 新增 `_decode_json_field()``_case_to_response` 四个 JSON 字段容错解码 |
| `backend/app/routers/batch.py` | 修改 | ~10 行 | 批量复制改走 `_case_to_response`,消除 `model_validate` 直通风险 |
| `backend/scripts/repair_double_encoded_json.py` | 新增 | ~180 行 | MySQL 双重编码 JSON 列检测+修复脚本(默认 dry-run,`--apply` 执行) |
| 5.60 服务器 MySQL | 数据修复 | 67 行 × 3 列 | `tags`/`steps`/`config` 单层解码还原(先备份) |
---
## 二、详细执行步骤
### Step 1:cases.py 响应构造防御
**文件**`backend/app/routers/cases.py`
1. 顶部补充 `import json`
2.`_case_to_response` 上方新增模块级工具函数:
```python
def _decode_json_field(value, expected_type, default):
"""
容错解码 JSON 字段(防御 MySQL 中双重编码的 JSON 字符串)
背景:部分历史数据在 MySQL JSON 列中被写成"JSON 字符串再包一层 JSON 文本"
(如 '"[\\"a\\"]"'),ORM 读出为 str,直接传给 Pydantic 强类型字段会
校验失败并导致整个列表接口 500。此处统一做一次解码兜底。
Args:
value: ORM 读出的原始值(list/dict/str/None)
expected_type: 期望类型(list 或 dict),用于校验解码结果
default: 解码失败时的回退默认值
Returns:
解码后的 list/dict,或 default
"""
if value is None:
return default
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, expected_type):
return parsed
except (ValueError, TypeError):
pass
return default
```
3. `_case_to_response` 中四个字段改写:
```python
tags=_decode_json_field(c.tags, list, []),
steps=_decode_json_field(c.steps, (list, dict), []),
config=_decode_json_field(c.config, dict, {}),
parameters=_decode_json_field(getattr(c, "parameters", None), dict, {}),
```
> 说明:steps 期望类型为 `(list, dict)`,因为 UI 用例存步骤列表、安全用例存配置字典。
### Step 2:batch.py 批量复制路径同源修复
**文件**`backend/app/routers/batch.py`
`batch_copy_cases` 中:
```python
# 修改前
return [TestCaseResponse.model_validate(c) for c in new_cases]
# 修改后(复用 cases.py 的防御构造,并把响应转回 camelCase 模型)
from app.routers.cases import _case_to_response
items = [_case_to_response(c) for c in new_cases]
return [TestCaseResponse.model_validate(i.model_dump(by_alias=True)) for i in items]
```
> 说明:`_case_to_response` 已完成字段解码,再经 `model_dump(by_alias=True)` + `model_validate` 保持原返回结构(camelCase)不变。
### Step 3:数据修复脚本
**文件**`backend/scripts/repair_double_encoded_json.py`(新增)
功能设计:
1. 连接 MySQL(默认 `192.168.5.60:3307/plat_auto_test`,支持环境变量覆盖)
2. 检测阶段:对 `test_cases``tags`/`steps`/`config`/`parameters` 四列扫描
```sql
SELECT id, name FROM test_cases
WHERE JSON_TYPE(<col>)='STRING' AND JSON_VALID(JSON_UNQUOTE(<col>))
```
3. 修复阶段(`--apply` 时):
- 先建备份表 `test_cases_bak_YYYYMMDD`(仅含受影响行)
- 逐列执行 `UPDATE test_cases SET <col> = CAST(JSON_UNQUOTE(<col>) AS JSON) WHERE ...`
- 复检:再跑一次检测 SQL,确认归零
4. 默认 dry-run,只打印将受影响的行;`--apply` 才落库
### Step 4:本地验证
1. `cd backend && python -c` 单测脚本:构造 `tags='["a"]'``steps='{"k":1}'``config='{}'` 的假 ORM 对象,调 `_case_to_response` 断言输出类型正确
2. `pytest tests/ -v -k "case"`(若存在用例相关测试)确认无回归
### Step 5:部署 5.60
1. scp 上传改动文件到 `/data/third_party/plat-auto-test/backend/app/routers/``backend/scripts/`
2. `cd /data/third_party/plat-auto-test/deploy && docker compose restart app`
3. 数据修复(脚本在容器内或宿主机直连 3307 执行,先 dry-run 后 `--apply`
4. 回归验证:
```bash
curl -s 'http://127.0.0.1/api/cases?limit=500' | python3 -c "
import json,sys; d=json.load(sys.stdin)
sec=[c for c in d['items'] if c.get('caseType')=='security']
print('total:', d['total'], '| security:', len(sec))
assert all(isinstance(c['tags'], list) for c in d['items'])
assert all(isinstance(c['config'], dict) for c in d['items'])
print('✅ 全部字段类型正确')
"
```
5. 浏览器打开执行中心/用例管理页面确认可用
---
## 三、验证步骤
| # | 验证项 | 方法 | 通过标准 |
|---|--------|------|---------|
| 1 | 列表接口不再 500 | `curl /api/cases?limit=500` | HTTP 200 |
| 2 | 字段类型正确 | 解析响应 JSON | 所有 items 的 tags 为 list、config 为 dict |
| 3 | 详情接口 | `curl /api/cases/sec_2_1_1` | 200,steps 为 dict |
| 4 | 批量复制 | 用例管理页对 sec 用例执行复制 | 201,不报 500 |
| 5 | 数据库归零 | 修复脚本再跑检测 | 受影响行数 = 0 |
| 6 | 本地回归 | `pytest tests/` | 通过 |
---
## 四、风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 数据修复误伤正常 STRING 类型 JSON 值 | 低 | 检测条件限定 `JSON_VALID(JSON_UNQUOTE(col))`(内容必须是合法 JSON 文本才修);修复前自动备份 |
| 修复脚本重复执行 | 无 | 修复后检测归零,脚本幂等 |
| 代码防御掩盖未来写入侧 bug | 低 | 解码仅在 str 场景触发,正常数据零开销;问题处理文档已记录污染链路供后续排查写入侧 |
| 5.60 重启容器造成短暂不可用 | 低 | 与日常发版一致,重启耗时约 30s |
| batch.py 返回结构变化 | 无 | 经 `by_alias=True` dump 后再 validate,camelCase 输出不变 |
## 五、实际执行记录(2026-09-10)
计划与实际的偏差及结果:
| Step | 计划 | 实际 |
|------|------|------|
| 1 | cases.py 加 `_decode_json_field` | 完成;pytest 过程中发现类型分派缺陷(`parameters='[]'` 解码为 list ≠ dict 需回退默认值),已改为统一 expected_type 校验 |
| 2 | batch.py 走 `_case_to_response` | 完成 |
| 3 | 修复脚本 dry-run 后 `--apply` | dry-run 检测 **0 条**——67 行脏数据在取证到部署的窗口内已被其他途径恢复(无备份表佐证),无需 `--apply`;脚本留作幂等巡检 |
| 4 | 本地验证 | 单测断言全过 + `pytest tests/` **487 passed** |
| 5 | scp cases.py/batch.py/脚本 → restart → 验证 | 首次部署后 /api/cases 仍 500,**新根因**:5.60 代码基线为旧版(case_service/schemas 无 project_type),HEAD 版 cases.py 透传 `project_type` 撞签名不匹配。改推**适配版** `backend/_hotfix_cases_deploy.py`(父基线+防御、剥离 project_type 透传)后通过 |
| 6 | 回归验证 | 列表 200/total=448/类型全对、详情 200 steps=dict、批量复制 200+清理 204、日志无 ERROR —— 全部通过 |
### 5.60 服务器基线结论(重要)
部署目录非 git 仓库,基线为混合版本:大部分文件 = 522ab2f6^(project_type 特性之前),但 `database.py`/`models/test_case.py` 已被单独热修(含 project_type),另有服务器特有裁剪/新增(scheduler_service 缺钉钉块、module.py 多 project_id)。**严禁整目录覆盖**;project_type 特性上线须 13 文件整体同步+回归。
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
> **日期**: 2026-09-09 > **日期**: 2026-09-09
> **模块**: 执行中心 / 定时任务 / 钉钉通知 > **模块**: 执行中心 / 定时任务 / 钉钉通知
> **级别**: P1(数据误导,非功能损坏) > **级别**: P1(数据误导,非功能损坏)
> **状态**: 代码修复完成(单测 483/483 通过),待三台部署 + 实弹验证 > **状态**: 代码修复完成(单测 483/483 通过);**5.60 已部署 + 实测停止通知发送成功**;5.44 / 5.202 待部署
--- ---
...@@ -73,7 +73,7 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1. ...@@ -73,7 +73,7 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1.
⏰ 停止时间:2026-09-09 11:54:02 ⏰ 停止时间:2026-09-09 11:54:02
📎 查看本次执行报告 ← 钉钉内可点击,直达该执行的报告页 📎 [访问测试管理平台-执行中心](http://192.168.5.44:8081/execution/ui) ← 钉钉内可点击,直达「自动化测试 → 执行中心」页面
``` ```
@策略:手动取消不@人;异常中断(看门狗/进程重启/引擎异常)按配置@负责人。 @策略:手动取消不@人;异常中断(看门狗/进程重启/引擎异常)按配置@负责人。
...@@ -83,7 +83,9 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1. ...@@ -83,7 +83,9 @@ INFO: 192.168.9.51:54221 - "POST /api/executions/exec_0043d9cc.../cancel HTTP/1.
- [x] 后端全量 pytest 通过(483/483,含取消→停止通知、看门狗→停止通知、真实失败→常规报告用例) - [x] 后端全量 pytest 通过(483/483,含取消→停止通知、看门狗→停止通知、真实失败→常规报告用例)
- [x] 中断用例标 `skipped` 不再假失败(启动恢复 + 看门狗路径单测覆盖) - [x] 中断用例标 `skipped` 不再假失败(启动恢复 + 看门狗路径单测覆盖)
- [x] 前端取消按钮提示文案生效(本地构建验证) - [x] 前端取消按钮提示文案生效(本地构建验证)
- [ ] 三台同步部署(5.44 / 5.202 / 5.60)——**待执行**(部署前按会话 63 流程预检 running 执行) - [x] **5.60 部署完成**`deploy_to_60.py` 同步 `dingtalk_notify_service.py` + `execution_service.py`(MD5 `fb594a6c…` 一致)→ 重启 → `/health` healthy → 容器内 md5sum 复核一致
- [x] **5.60 停止通知实测**`send_test_stop_notice_60.py` 推送 🛑 停止通知成功(`{'errcode': 0, 'errmsg': 'ok'}`),末尾链接 `[访问测试管理平台-执行中心](http://192.168.5.60/execution/ui)` 直达执行中心
- [ ] **5.44 / 5.202 部署**——待执行(部署前按会话 63 流程预检 running 执行,5.44 有每日定时 4h+ 任务注意撞车)
- [ ] 实弹验证:下一次定时执行被取消/中断后,钉钉群收到的是停止通知而非 15.25% 式通过率报告 - [ ] 实弹验证:下一次定时执行被取消/中断后,钉钉群收到的是停止通知而非 15.25% 式通过率报告
--- ---
......
...@@ -3,11 +3,95 @@ ...@@ -3,11 +3,95 @@
> **生成时间**: 2026-09-10 > **生成时间**: 2026-09-10
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `42506263` feat(ui-recorder): 有头模式放大操作 + 流畅度优化(Phase 1-4 闭环) > **最近提交**: `42506263` feat(ui-recorder): 有头模式放大操作 + 流畅度优化(Phase 1-4 闭环)
> **状态**: 🟢 **会话70 完成(2026-09-10):用例录制器全屏放大 + 菜单迁移/改名 + requestFullscreen 修复——修复版已重部署 5.60 并实机复验通过(Phase 1-4 闭环);Phase 5(5.44/5.202 同步)待用户安排**(历史会话见下章节) > **状态**: 🟢 **会话 72 完成(2026-09-11):5.60 UI用例管理 500 修复(legacy step_number → StepDefinition 迁移 68 条)+ 5.44/5.202 同步修复(两库各 113 条步骤缺 order/name 规范化完毕),三台全量分页验证全部 HTTP 200**(历史会话见下章节)
--- ---
## 📊 当前状态(会话 70,2026-09-10 · 用例录制器全屏放大 + 菜单迁移/改名) ## 📊 当前状态(会话 72,2026-09-11 · 5.60/5.44/5.202 UI用例步骤格式 500 修复与三台验证)
**背景**:5.60 UI 用例管理先出现 `TestCaseResponse` 校验失败(旧步骤使用 `step_number``target_url``assert_type` 顶层字段),随后同步核查 5.44 / 5.202 时发现两台也存在一批录制/导入历史步骤缺少 `order`/`name`,导致特定分页返回 HTTP 500。
### ✅ 根因
- **5.60**:68 条历史用例使用 legacy flat step 格式,缺少 `order`,且 URL/断言字段未嵌入 `params`;Pydantic `StepDefinition` 严格校验时触发 `Field required`
- **5.44 / 5.202**:两库各有 113 条 steps 数组步骤缺少 `order`,部分还使用 `description` 代替 `name`;数据库统计「无 `step_number`」无法覆盖这类新旧混合格式,因此最初误判为已清洁。
- API `/api/cases` 将整批 ORM 数据直接构造为 `TestCaseResponse`,单条脏步骤即可使对应分页整体 500。
### ✅ 修复动作
1. **5.60 已完成的数据迁移**:在 app 容器内执行 `_migrate_legacy_steps_560.py`,将 68 条转换为 `order / name / action / params / expected``step_number → order``target_url → params.url``assert_type → params.type`,复核 `remaining legacy = 0`
2. **5.44 / 5.202 数据规范化**:对两台全量扫描 `test_cases.steps`,发现各 113 条缺少 `order` 的历史步骤;补齐递增 `order`、将 `description` 回填为 `name`、确保 `params` 为字典,并保留原有 action/params 内容。实际发生字段变化的用例各 30 条(其余 83 条为混合结构或已含必需字段)。
3. **临时脚本清理**:远端 `/tmp` 中的迁移/规范化脚本已删除;本地临时迁移脚本也已删除。此次生产数据修复未提交 Git 代码或数据库文件。
### ✅ 三台验证结果
| 环境 | API 地址 | 修复前问题 | 修复后验证 |
|---|---|---|---|
| 5.60 | `http://192.168.5.60` | legacy 步骤导致 UI 用例列表 500 | `GET /api/cases?case_type=ui&skip=0&limit=20` HTTP 200;legacy=0 |
| 5.44 | `http://192.168.5.44:8081` | 某些分页(skip 60~90)HTTP 500 | 全量 UI 分页 HTTP 200;total=385;首条步骤含 `order=1` |
| 5.202 | `http://192.168.5.202:8081` | 某些分页(skip 60~90)HTTP 500 | 全量 UI 分页 HTTP 200;total=388;首条步骤含 `order=1` |
> 注意:5.44 / 5.202 的平台容器映射为 `8081:80`,访问宿主机 80 会命中其他 nginx/管理平台并返回 404;必须使用 `:8081`。容器内 API 访问 `http://localhost/api/...`。
### ⚠️ 遗留与后续建议
1. **建议补代码防御**:当前本地 `backend/app/routers/cases.py``_decode_json_field` 只能解 JSON 外层类型,尚未自动把 legacy step 对象规范化;后续应增加 `_normalize_steps`,在 `_case_to_response` 中将 `step_number/description/target_url/assert_type` 统一转换,防止未来导入脏数据再次导致整页 500。
2. **5.44 / 5.202 各仍有 83 条未自动改写的历史混合结构**:本次 API 全分页已经通过,但建议后续抽样核查这些用例的 `name/action/params` 是否满足完整 `StepDefinition` 语义,不要直接全量覆盖未知结构。
3. 本次未部署前端/后端代码,仅修复生产 MySQL 中的 steps JSON;如后续上线代码防御,需按三台环境分别部署并避开正在执行的任务。
4. 不要把远程数据修复临时脚本复制回仓库提交;如需保留迁移能力,应整理为正式、可 dry-run/备份/回滚的运维脚本后再走评审。
---
## 📊 历史状态(会话 71,2026-09-10 · 5.60 执行中心用例 JSON 双重编码报错修复)
**背景**:5.60 生产服务器(192.168.5.60)访问执行中心/用例管理页面报「服务器内部错误」——`GET /api/cases` 返回 500,Pydantic 校验失败:67 条安全测试用例(`sec_*`)的 tags/steps/config 在 MySQL JSON 列中被写成「JSON 字符串再包一层 JSON 文本」(**双重编码**),ORM 读出为 str,`_case_to_response``or []` 只兜 None 兜不住「值存在但类型错误」→ 一条脏数据炸整个列表接口。
### ✅ 根因(5.60 MySQL 实测实锤)
- `test_cases` 448 行中 67 行(全部 `case_type='security'`)的 tags/steps/config 三列 `JSON_TYPE='STRING'``JSON_VALID(JSON_UNQUOTE(col))`;parameters 与 executions.config(1049 行)等干净
- 逐层解码证明:`json.loads` 第 1 次仍为 str,第 2 次才是真数据(`'"[\\"a\\"]"'` → str → list)
- 污染来源推断:某同步/迁移脚本以 **ORM 方式**写入「已是 JSON 文本的字符串」→ SQLAlchemy 对 JSON 列 str 值再 `json.dumps` 一次形成双编码;raw SQL 写入的 `sync_to_560_full.py` 安全(MySQL 自动解析合法 JSON 文本)
### ✅ 修复(双管齐下,代码防御为主)
1. **代码防御(治本,防再发)**
- `backend/app/routers/cases.py`:新增模块级 `_decode_json_field(value, expected_type, default)`——list/dict 透传、str 尝试 `json.loads` 且类型符合预期才返回、否则回退默认值;`_case_to_response` 四个 JSON 字段(tags/steps/config/parameters)全部改走该函数
- `backend/app/routers/batch.py`:批量复制 `TestCaseResponse.model_validate(c)` → 复用 `_case_to_response`,消除同源隐患
- 修复过程中本地 pytest 抓出**类型分派缺陷**`parameters='[]'` 解码为 list ≠ dict 需回退 `{}`(统一 expected_type 校验后修复)
2. **数据修复(治标,清现有脏数据)**:新增 `backend/scripts/repair_double_encoded_json.py`(dry-run 默认;`--apply` 先备份 `test_cases_bak_YYYYMMDD``CAST(JSON_UNQUOTE(col) AS JSON)`,复检归零,幂等)
### 🔍 两个重要发现
1. **脏数据已自愈**:部署时 dry-run 检测 **0 条**——67 行在取证到部署的窗口内被其他途径恢复(无备份表佐证,脚本 `--apply` 未执行过),数据修复步骤无需执行,脚本留作幂等巡检工具
2. **5.60 代码基线 ≠ git HEAD(重要约束,勿踩)**:5.60 backend 部署目录非 git 仓库,大部分文件停留在 `522ab2f6^`(project_type 特性**之前**):
- 直接部署 HEAD 版 cases.py(含 project_type 透传)→ 新 500 `CaseService.list() got an unexpected keyword argument 'project_type'`
- 已生成**适配版** `backend/_hotfix_cases_deploy.py`(父基线 + `_decode_json_field`,剥离 project_type 透传)部署生效(gitignore 已忽略 `backend/_*.py`
- 服务器另有特有热修:module.py 多 project_id、scheduler_service 缺钉钉通知块 → **严禁整目录覆盖部署**;project_type 特性(522ab2f6)如需上线 5.60 须 13 文件整体同步 + 回归
### ✅ 验证(全部通过)
| # | 验证项 | 结果 |
|---|--------|------|
| 1 | 本地 pytest | **487 passed**(含 `_decode_json_field` 类型分派回归修复) |
| 2 | `GET /api/cases?limit=500`(5.60) | HTTP 200,total=448,全部 items tags=list / config=dict / steps=list\|dict |
| 3 | 详情 `GET /api/cases/sec_2_1_1` | 200,steps=dict(test_type=api_security),tags=`['OWASP-API1','水平越权','IDOR']` |
| 4 | 批量复制 `POST /api/batch/copy` | 200(副本字段类型正确,已删除清理 204) |
| 5 | 容器 health / 错误日志 | health 200,无 ERROR |
### 📁 文档沉淀
- `Docs/PRD/问题处理/执行中心/_问题处理_5.60执行中心用例JSON双重编码报错.md`(已补「五、实施与验证记录」章节)
- `Docs/PRD/问题处理/执行中心/_执行计划_修复执行中心用例JSON双重编码报错.md`(已补「五、实际执行记录」章节)
### ⚠️ 待办(后续跟进)
1. **本地 cases.py / batch.py 改动未提交 git**(连同本次修复文档),待用户确认后提交
2. **project_type 特性(522ab2f6)如需同步 5.60**:13 文件整体同步 + 回归,不能单推 router 单文件
3. 脏数据恢复来源待查(疑似 `sync_to_560_full.py` 某次同步),修复脚本留作巡检
---
## 📊 历史状态(会话 70,2026-09-10 · 用例录制器全屏放大 + 菜单迁移/改名)
**背景**:用户反馈录制器两个新需求:① noVNC 录制画面能否**放大到整个浏览器窗口**显示(便于精细观察/操作小元素);② 录制器从「辅助工具」菜单迁移到「自动化测试」菜单下,名字改为「**用例录制器**」。**关键约束(用户明确)**:全屏放大必须在不降低定位精度的前提下实现——如影响定位准确度则放弃该功能。 **背景**:用户反馈录制器两个新需求:① noVNC 录制画面能否**放大到整个浏览器窗口**显示(便于精细观察/操作小元素);② 录制器从「辅助工具」菜单迁移到「自动化测试」菜单下,名字改为「**用例录制器**」。**关键约束(用户明确)**:全屏放大必须在不降低定位精度的前提下实现——如影响定位准确度则放弃该功能。
......
此差异已折叠。
...@@ -25,6 +25,7 @@ from app.schemas.batch import ( ...@@ -25,6 +25,7 @@ from app.schemas.batch import (
BatchOperationResponse, BatchOperationResponse,
) )
from app.schemas.test_case import TestCaseResponse from app.schemas.test_case import TestCaseResponse
from app.routers.cases import _case_to_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -113,7 +114,8 @@ async def batch_copy_cases( ...@@ -113,7 +114,8 @@ async def batch_copy_cases(
request.target_module_id request.target_module_id
) )
logger.info(f"批量复制完成: {stats}") logger.info(f"批量复制完成: {stats}")
return [TestCaseResponse.model_validate(c) for c in new_cases] # 走 _case_to_response 统一构造,附带 JSON 字段容错解码(防御双重编码脏数据)
return [_case_to_response(c) for c in new_cases]
except Exception as e: except Exception as e:
logger.error(f"批量复制失败: {str(e)}") logger.error(f"批量复制失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"批量复制失败: {str(e)}") raise HTTPException(status_code=500, detail=f"批量复制失败: {str(e)}")
......
...@@ -9,8 +9,9 @@ ...@@ -9,8 +9,9 @@
最后修改:2026-07-09 最后修改:2026-07-09
""" """
import json
import logging import logging
from typing import Optional, List from typing import Optional, List, Union
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
...@@ -30,6 +31,37 @@ logger = logging.getLogger(__name__) ...@@ -30,6 +31,37 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _decode_json_field(value, expected_type: tuple, default):
"""
容错解码 JSON 字段(防御 MySQL 中双重编码的 JSON 字符串)
背景:5.60 生产库部分历史数据在 MySQL JSON 列中被写成"JSON 字符串
再包一层 JSON 文本"(如 '"[\\"a\\"]"'),ORM 读出为 str,直接传给
Pydantic 强类型字段会校验失败并导致整个列表接口 500。
此处统一做一次解码兜底。
Args:
value: ORM 读出的原始值(list/dict/str/None)
expected_type: 期望类型元组(如 (list,) 或 (list, dict)),用于校验解码结果
default: 解码失败时的回退默认值
Returns:
解码后的 list/dict,或 default
"""
if value is None:
return default
if isinstance(value, expected_type):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, expected_type):
return parsed
except (ValueError, TypeError):
pass
return default
def _case_to_response(c) -> TestCaseResponse: def _case_to_response(c) -> TestCaseResponse:
""" """
统一构造用例响应对象(包含 case_type 等所有字段) 统一构造用例响应对象(包含 case_type 等所有字段)
...@@ -47,12 +79,12 @@ def _case_to_response(c) -> TestCaseResponse: ...@@ -47,12 +79,12 @@ def _case_to_response(c) -> TestCaseResponse:
description=c.description, description=c.description,
status=c.status, status=c.status,
priority=c.priority, priority=c.priority,
tags=c.tags or [], tags=_decode_json_field(c.tags, (list,), []),
steps=c.steps or [], steps=_decode_json_field(c.steps, (list, dict), []),
config=c.config or {}, config=_decode_json_field(c.config, (dict,), {}),
case_type=getattr(c, "case_type", None) or "ui", case_type=getattr(c, "case_type", None) or "ui",
project_type=getattr(c, "project_type", "standard") or "standard", project_type=getattr(c, "project_type", "standard") or "standard",
parameters=c.parameters or {}, parameters=_decode_json_field(getattr(c, "parameters", None), (dict,), {}),
created_at=c.created_at, created_at=c.created_at,
updated_at=c.updated_at, updated_at=c.updated_at,
) )
......
...@@ -14,7 +14,7 @@ services: ...@@ -14,7 +14,7 @@ services:
TZ: Asia/Shanghai TZ: Asia/Shanghai
volumes: volumes:
# MySQL 数据持久化 # MySQL 数据持久化
- /data/third_party/plat-auto-test/mysql/data:/var/lib/mysql - /data/third_party/plat-auto-test/mysql/data:/var/lib/mysql:Z
# 初始化脚本(仅首次启动执行) # 初始化脚本(仅首次启动执行)
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports: ports:
...@@ -32,6 +32,10 @@ services: ...@@ -32,6 +32,10 @@ services:
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password - --default-authentication-plugin=mysql_native_password
- --max_connections=200 - --max_connections=200
# Docker 20.10.7 默认 seccomp 白名单不含 clone3(EL9 镜像 glibc 用其建线程),
# MySQL 8.0.46 初始化报 "Can't create thread (errno: 1)"。放开 seccomp 修复。
security_opt:
- seccomp:unconfined
# ==================== 应用容器(前后端合一) ==================== # ==================== 应用容器(前后端合一) ====================
app: app:
...@@ -55,10 +59,14 @@ services: ...@@ -55,10 +59,14 @@ services:
# 目标机资源监控(SSH 采集被测系统 192.168.5.44)。采样已走线程池不阻塞事件循环, # 目标机资源监控(SSH 采集被测系统 192.168.5.44)。采样已走线程池不阻塞事件循环,
# 关闭会导致压测全程无目标机资源数据(2026-09-02 混合场景无监控数据问题根因) # 关闭会导致压测全程无目标机资源数据(2026-09-02 混合场景无监控数据问题根因)
- TARGET_MONITOR_ENABLED=true - TARGET_MONITOR_ENABLED=true
# Xvfb 虚拟桌面(有头模式录制用) - OPENBLAS_NUM_THREADS=1
- OMP_NUM_THREADS=1
- MKL_NUM_THREADS=1
- XVFB_DISPLAY=:99 - XVFB_DISPLAY=:99
- XVFB_SCREEN=1920x1080x24 - XVFB_SCREEN=1920x1080x24
- NOVNC_PORT=6080 - NOVNC_PORT=6080
security_opt:
- seccomp:unconfined
volumes: volumes:
# 后端源码(更新后端只需重启容器,无需重建镜像) # 后端源码(更新后端只需重启容器,无需重建镜像)
- /data/third_party/plat-auto-test/backend:/app - /data/third_party/plat-auto-test/backend:/app
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论