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

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

......@@ -4,7 +4,7 @@
> **创建日期**: 2026-09-09
> **作者**: czj
> **关联问题**: [_问题处理_表格选择器类号不稳定导致回放失败.md](_问题处理_表格选择器类号不稳定导致回放失败.md)
> **状态**: 执行中
> **状态**: 已完成
> **关联执行**: exec_5f866bda62c0466a9db881f8ca501375(手动执行-2026/9/9 14:39:52,5.60 环境)
> **关联用例**: case_aafbdfb50fa5401a92ba3f8145b8bf07「完整预约流程验证」(VNC 录制)
......@@ -22,32 +22,33 @@
### 阶段 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+)?$`(生成类)
- `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 重试一次再判死
### 阶段 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.1 本地修改后 `npm run build` 前端
- [ ] 3.2 scp dist + backend 服务到 5.60
- [ ] 3.3 `docker compose restart app`
- [x] 3.1 本地修改后 `npm run build` 前端
- [x] 3.2 scp dist + backend 服务到 5.60
- [x] 3.3 `docker compose restart app`
### 阶段 4:重新执行验证
- [ ] 4.1 重跑 case_aafbdfb50fa5401a92ba3f8145b8bf07(手动或脚本)
- [ ] 4.2 验证两表勾选通过、无黑边
- [x] 4.1 重跑 case_aafbdfb50fa5401a92ba3f8145b8bf07(手动或脚本)
- [x] 4.2 验证两表勾选通过、时间面板步骤通过、后续步骤全部通过
### 阶段 5:归档
- [ ] 5.1 两份文档状态更新为「已完成」
- [ ] 5.2 HANDOFF_UI自动化.md 增补会话 66 章节
- [x] 5.1 两份文档状态更新为「已完成」
- [x] 5.2 HANDOFF_UI自动化.md 增补会话 66 章节
---
......@@ -55,30 +56,33 @@
### 阶段 1-2(2026-09-09 本地实施)
- recorder_engine.py 两处修改
- recorder_engine.py 三处修改(stableClasses 过滤 + 表格作用域候选 + pickerHost 面板触发)
- playwright_executor.py 两处修改
- fix_case_5f86.py 编写完成(待部署后运行)
- fix_case_5f86_step17.py 编写完成(待部署后运行)
### 阶段 3-4(2026-09-09 部署 5.60)
- dist 部署 + 容器重启
- 重新执行用例通过(表格步骤全部通过)
- fix_case_5f86_step17.py 在容器内运行(36→37 步,插入「点击会议开始时间」+ 时间项稳定选择器)
- 重新执行用例通过(37/37 全部通过,含时间面板打开 + 时间选择步骤)
### 阶段 5(归档)
- 两份文档状态更新
- 归档文档 [_归档_5f866bda62c0466a9db881f8ca501375.md](_归档_5f866bda62c0466a9db881f8ca501375.md) 已生成
- HANDOFF_UI自动化.md 增补
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 录制器 | 新录制的表格勾选步骤候选中不含 `el-table_\d+_column_\d+` |
| 执行器 | 表格选择器在异步渲染后仍能被命中 |
| 用例 | case_aafbdfb50fa5401a92ba3f8145b8bf07 重新执行通过,步骤 13-18 全部通过 |
| 回归 | 非 table 元素的录制候选行为不变 |
| 测试项 | 预期结果 | 实际结果 |
|--------|----------|----------|
| 录制器 | 新录制的表格勾选步骤候选中不含 `el-table_\d+_column_\d+` | ✅ 已过滤 |
| 录制器 | 时间面板触发点击不再被忽略 | ✅ pickerHost 逻辑 + 单测通过 |
| 执行器 | 表格选择器在异步渲染后仍能被命中 | ✅ 2s 重试缓冲 |
| 用例 | case_aafbdfb50fa5401a92ba3f8145b8bf07 重新执行通过,步骤 13-18 全部通过 | ✅ exec_cf9a0c1ff55b47219af3ee01491ff2de 37/37 通过 |
| 回归 | 非 table 元素的录制候选行为不变 | ✅ tests/test_recorder_e2e.py 15/15 通过 |
---
......@@ -86,9 +90,9 @@
| 文件 | 角色 |
|------|------|
| `backend/app/services/recorder_engine.py` | 录制器候选生成(本次唯一改动点) |
| `backend/app/executors/playwright_executor.py` | 执行器快速探测(本次唯一改动点) |
| `backend/scripts/fix_case_5f86.py` | 用例数据修复脚本 |
| `backend/app/services/recorder_engine.py` | 录制器候选生成(本次改动点) |
| `backend/app/executors/playwright_executor.py` | 执行器快速探测(本次改动点) |
| `backend/scripts/fix_case_5f86_step17.py` | 用例数据修复脚本 |
| `backend/scripts/probe_table_classes.py` | 排查脚本(已完成) |
| `backend/scripts/probe_replay_exec5f86.py` | 回放复现脚本(已完成) |
......
# 执行计划 — 5.44 定时执行被取消导致钉钉报告通过率仅 15.25%
> **版本**: 2026-09-09(v2,按用户确认方案更新:停止通知替代静默跳过
> **版本**: 2026-09-09(v3:链接定稿为直达 UI 自动化执行中心 `/execution/ui`
> **模块**: 执行中心 / 定时任务 / 钉钉通知
> **优先级**: 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.
⏰ 停止时间:2026-09-09 11:54:02
📎 查看本次执行报告(超链接,直达 `{PLATFORM_BASE_URL}/api/reports/generate/{exec_id}`)
📎 [访问测试管理平台-执行中心]({PLATFORM_BASE_URL}/execution/ui)
```
设计要点:
- **无通过率行**——中断执行的数据不完整,给通过率必然误导(本次 15.25% 事件的根源);仅计数。
- **标题独立**(「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`(真实失败)仍走常规 ⚠️ 报告。
## 四、执行计划(实际落地)
......@@ -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] 重启容器打断执行 → 恢复后被中断用例状态为 `skipped`,通过率统计不含假失败(单测覆盖)
- [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] 前端取消按钮提示文案生效(本地构建验证)
## 六、风险与缓解
......
# 执行计划文档 - 修复执行中心用例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 @@
> **日期**: 2026-09-09
> **模块**: 执行中心 / 定时任务 / 钉钉通知
> **级别**: 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.
⏰ 停止时间: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.
- [x] 后端全量 pytest 通过(483/483,含取消→停止通知、看门狗→停止通知、真实失败→常规报告用例)
- [x] 中断用例标 `skipped` 不再假失败(启动恢复 + 看门狗路径单测覆盖)
- [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% 式通过率报告
---
......
......@@ -3,11 +3,95 @@
> **生成时间**: 2026-09-10
> **当前分支**: `platform-auto-test`
> **最近提交**: `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 录制画面能否**放大到整个浏览器窗口**显示(便于精细观察/操作小元素);② 录制器从「辅助工具」菜单迁移到「自动化测试」菜单下,名字改为「**用例录制器**」。**关键约束(用户明确)**:全屏放大必须在不降低定位精度的前提下实现——如影响定位准确度则放弃该功能。
......
此差异已折叠。
......@@ -25,6 +25,7 @@ from app.schemas.batch import (
BatchOperationResponse,
)
from app.schemas.test_case import TestCaseResponse
from app.routers.cases import _case_to_response
logger = logging.getLogger(__name__)
......@@ -113,7 +114,8 @@ async def batch_copy_cases(
request.target_module_id
)
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:
logger.error(f"批量复制失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"批量复制失败: {str(e)}")
......
......@@ -9,8 +9,9 @@
最后修改:2026-07-09
"""
import json
import logging
from typing import Optional, List
from typing import Optional, List, Union
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
......@@ -30,6 +31,37 @@ logger = logging.getLogger(__name__)
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:
"""
统一构造用例响应对象(包含 case_type 等所有字段)
......@@ -47,12 +79,12 @@ def _case_to_response(c) -> TestCaseResponse:
description=c.description,
status=c.status,
priority=c.priority,
tags=c.tags or [],
steps=c.steps or [],
config=c.config or {},
tags=_decode_json_field(c.tags, (list,), []),
steps=_decode_json_field(c.steps, (list, dict), []),
config=_decode_json_field(c.config, (dict,), {}),
case_type=getattr(c, "case_type", None) or "ui",
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,
updated_at=c.updated_at,
)
......
......@@ -14,7 +14,7 @@ services:
TZ: Asia/Shanghai
volumes:
# 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
ports:
......@@ -32,6 +32,10 @@ services:
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
- --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:
......@@ -55,10 +59,14 @@ services:
# 目标机资源监控(SSH 采集被测系统 192.168.5.44)。采样已走线程池不阻塞事件循环,
# 关闭会导致压测全程无目标机资源数据(2026-09-02 混合场景无监控数据问题根因)
- TARGET_MONITOR_ENABLED=true
# Xvfb 虚拟桌面(有头模式录制用)
- OPENBLAS_NUM_THREADS=1
- OMP_NUM_THREADS=1
- MKL_NUM_THREADS=1
- XVFB_DISPLAY=:99
- XVFB_SCREEN=1920x1080x24
- NOVNC_PORT=6080
security_opt:
- seccomp:unconfined
volumes:
# 后端源码(更新后端只需重启容器,无需重建镜像)
- /data/third_party/plat-auto-test/backend:/app
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论