Files
xiaotutongxue/docs/11-填报流程端到端测试计划.md
lovife 2ff17cd0c9 refactor(h5)!: H5 改由 nginx 托管,后端退回纯 API
用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。

后端
- 删掉 dev/test/prod 三份 profile 里的 spring.web.resources.static-locations
- 主配置加 spring.web.resources.add-mappings=false —— 用开关而不是"指到空目录":
  后者会连带启用 Spring Boot 的默认位置(classpath:/static 等),谁丢个文件进去就悄悄对外可见
- StaticCacheHeaderFilter → ApiNoStoreFilter,只剩 /api/** 的 no-store
  (静态资源的缓存策略整体归 nginx,避免"改一处漏一处")
- 补 NoHandlerFoundException → 404:关掉静态映射后未匹配路径不再经过
  ResourceHttpRequestHandler,抛的是 NoHandlerFoundException,只处理
  NoResourceFoundException 时访问 / 会变成 500「系统繁忙」并刷 ERROR 日志

deploy
- 新增 xt-nginx(80):发 frontend-mp/dist/build/h5 + /api 反代 8080(host.docker.internal,
  Linux 靠 extra_hosts host-gateway),端口与产物目录走 .env 的 H5_PORT/H5_DIR
- 三条"删了就会出事"的都已带上理由:client_max_body_size 50m(默认 1m,超了回 413
  而后端日志一个字都没有)、/assets 的 immutable 与 *.html 的 no-cache(§一·十 那个
  "发版后学生看到旧页面"的坑)、健康检查写 127.0.0.1(容器里 localhost 先解析到 IPv6,
  nginx 只听 IPv4 → 永远 starting 而首页是好的)

CI 与文档
- CI:学生端 H5 构建从"e2e 的输入"改为编译检查,不再留 artifact(e2e 不消费它)
- 同步 AGENTS.md / README.md / docs/09(启动步骤、CI 表、发布注意、排障表)/ docs/11
- 交接摘要新增 §一·四十三(含理由、验证数字、要人工做的一步与环境限制)

验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
  /api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
  /api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
  55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
  e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
2026-10-19 17:35:33 +08:00

728 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 填报流程端到端测试计划
> 版本:V1.0 | 日期:2026-10
> 读者:测试、开发(自测)、项目经理(验收)
> 配套:《03-接口设计.md》§9 / §11、《06-验收测试清单.md》、《04-核心模块设计说明.md》
> 执行入口:`python scripts/e2e-m3-flow.py`
> 核心原则:**能用真实数据断言的不靠人眼;能自动化的一次写、长期跑**(与《06》同口径)
---
## 一、目标与范围
### 1.1 一句话
把「学生扫码 → 门禁 → 填报 → 传材料 → 提交 → 我的任务 → 撤回 → 审核 → 驳回 → 重交」这条**完整链路**做成一张**覆盖全、责任清**的用例矩阵,并只把**没人覆盖**的那些用例写成可执行脚本。
### 1.2 为什么不能只靠既有脚本
本仓库已有 **37 个 `scripts/e2e-*.py` / 1617 条断言**,且 **37 个全绿**(见《交接摘要》§一·四十的验证一节)。但它们是**按模块切片的**:门禁一个脚本、草稿一个脚本、提交一个脚本、审核一个脚本。切片测试有三个固定的盲区,本计划就是为它们而写:
| 盲区 | 具体表现 | 本计划的对策 |
|---|---|---|
| **跨步骤的状态一致性没人管** | 「名册行的 `submit_status` 从 0 走到 3」被四个脚本各断言一格,但**没有人断言这条轨迹是连续的** | §3.11 J 组 |
| **切片之间那条缝** | 驳回后「能重新提交」被 `e2e-m5-audit.py:534` 证明了,但「能**先改再**提交」没有人证明 —— 而后者才是驳回的意义 | §3.10 I 组、§6 |
| **脚本自述 ≠ 实际断言** | `e2e-m3-draft.py:11` 自述覆盖「已提交的不能直接改」,实际**全文没有这条断言**(无 `40030` 出现) | §3.1 的核对规程 |
> **规律**:切片测试的通过率越高,「缝里那一块」越安全 —— 因为没有人会去看缝里。F-84(「HTTP 200 + code 0」不等于动作成功,`docs/08-完成度盘点与问题清单.md:452`)就是同一个规律的产物。
### 1.3 分层与执行方式
```
┌──────────────────────────────────────────────────────────┐
│ L3-B 人工/浏览器走查(加分项) │
│ H5 真机形态:门禁 → 填报 → 传图 → 提交 → 进度 │
├──────────────────────────────────────────────────────────┤
│ L3-A 端到端接口链路(自动 · 本计划主证据) │
│ scripts/e2e-m3-flow.py + 既有 37 个 e2e 脚本 │
│ 真实接口 + docker exec xt-pg 查库断言 │
├──────────────────────────────────────────────────────────┤
│ L2 机械校验(自动 · 不需要后端) │
│ check-api-parity / check-entity-columns / verify-docs │
│ check-manual / check-wiring │
└──────────────────────────────────────────────────────────┘
```
### 1.4 成功判据(DoD)
| # | 判据 | 怎么验 |
|:--:|---|---|
| 1 | 覆盖矩阵每行都有「覆盖者」,**没有一行是空的** | 人眼过一遍 §三 |
| 2 | `scripts/e2e-m3-flow.py` 退出码 0 | 跑它 |
| 3 | 该脚本 **至少 90 条断言** | 脚本末尾的 `通过 X 失败 Y 共 Z`(**实测 95**) |
| 4 | `python scripts/verify-all.py --with-e2e` 全绿,脚本数 37 → 38 | 跑它 |
| 5 | 缺陷有 XFAIL 登记 + F 编号,**且缺陷修好后 XPASS 会报红** | §4.5(**实测 6 条 XFAIL**) |
| 6 | 演示态(任务 17 / 2097 / 2215)与名册绑定**不被本脚本破坏** | 跑完查库(**实测残留 0**) |
---
## 二、权威口径与判据来源
### 2.1 期望从哪来(裁决顺序)
1. **契约**:《03-接口设计.md》§9.1~§9.12(学生填报)、§11(审核)、§2.4 提交单状态、§2.5 红黄蓝级别、§2.6「无」的语义。
2. **实现**:`backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/*.java`、`module/file/service/*.java`、`module/mp/service/*.java`。
3. **常量**:`CollectSubmission.java:122-127`、`CollectRoster.java:87-99`、`xt-common/.../api/ErrorCode.java`。
⚠️ **契约与实现不一致时,以契约为期望**,把差异写成 XFAIL + 缺陷记录。**不许**按「实现更合理」把期望改成实现的样子 —— 那等于让测试给实现背书(AGENTS.md 铁律:文档是需求源)。
### 2.2 明确**不测**的部分
| 不测 | 原因 |
|---|---|
| `40004` / `40005` / `40006` / `40043` / `50002` / `50003` / `50004` | M4(OCR、归属校验、串档检测)**一期缓做**,等供应商选型 —— 这些码零引用,见 `scripts/check-wiring.py:130-149` 的例外表 |
| `collectMode=2`(社会收集)/ `certType=2,3` | 2026-10 需求收敛,一律 `40044` |
| 微信真实授权(非打桩) | 需要真人在微信里点「允许」;打桩路径已覆盖同一段发 token 的代码 |
| 导出(M6)/ 通知(M7)/ 压测 | 各自有专门的脚本与文档,不在填报链路 |
### 2.3 判据速查
**提交单状态**(`CollectSubmission.java:122-127`,契约 §2.4)
| 值 | 含义 | 契约允许流转到 |
|:--:|---|---|
| 0 | 草稿 | 1 |
| 1 | 待初审 | 2 / 4 |
| 2 | 待终审 | 3 / 4 |
| 3 | 已通过 | 5 |
| 4 | 已驳回 | **1(学生重提交)** |
| 5 | 已归档 | — |
**名册行 `submit_status`**(`CollectRoster.java:87-99`):`0` 未提交 / `1` 草稿 / `2` 待审 / `3` 已通过 / `4` 已驳回。
**任务状态**(`collect_task.status`):`0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` 暂停。**只有 `1` 能填报**(`SubmissionService.java:339-347`、`FillService.java:71-81`)。
**问题级别**(契约 §2.5):`1` 信息(蓝)/ `2` 警告(黄,可确认后提交)/ `3` 错误(红,必须改)。
---
## 三、覆盖矩阵
### 3.1 怎么读这张表
`覆盖者` 一列只有四种取值:
| 取值 | 含义 |
|---|---|
| `既有脚本 文件:行` | 该断言**已经存在**且已在跑(`verify-all.py --with-e2e`) |
| **本脚本** | 既有脚本**没有**覆盖,由 `scripts/e2e-m3-flow.py` 实现 |
| `人工走查` | 只能靠浏览器/真机看的(本计划的 L3-B 部分) |
| `M4 缓做` | 一期不做,**不写用例** |
⚠️ **「既有脚本」这一列是核对出来的,不是抄脚本自述的。** 核对规程:
```bash
grep -n 'check(\|ok(' scripts/e2e-m3-*.py scripts/e2e-m5-audit.py
```
一条一条对照实际断言文案。**这条规程本身就是必要的**:`e2e-m3-draft.py:11` 的 docstring 自述「已提交的不能直接改」,而该文件全文没有这条断言 —— 自述覆盖了一个它没测的东西。
### 3.2 A 组 · 扫码落地页(匿名)
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| A1 | 合法 scene,返回租户名/任务名/能否填报 | `valid=true`,`canSubmit` 与任务状态一致 | 既有脚本 `scripts/e2e-m1-scene.py` |
| A2 | 非法 scene / 不存在的租户 / 不存在的任务 | 三种**不同的**提示,不泄漏是否存在 | 既有脚本 `scripts/e2e-m1-scene.py` |
| A3 | 跨租户:A 租户的 scene 拼 B 租户的 taskId | 当作「不存在」 | 既有脚本 `scripts/e2e-m1-scene.py` |
| A4 | 带班级的 scene:班级名只在**该班确实在覆盖范围内**时才返回 | 否则不返回 | 既有脚本 `scripts/e2e-m1-scene.py` |
| A5 | 限流(scene 可穷举) | 能挡住批量扫描 | 既有脚本 `scripts/e2e-m1-scene.py` |
### 3.3 B 组 · 登录与鉴权
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| B1 | `GET /mp/h5/stub-login?openid&scene` | 302;`Location` 是 **hash 路由**格式 `...#/pages/index/index?token=..&scene=..`,token **不在** query | 既有脚本 `scripts/e2e-m3-h5-auth.py:163` |
| B2 | stub-login 不带 scene | 302 + `authError=打桩登录需要带合法的 scene`(不猜租户) | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
| B3 | 拿到的 token 真的能用 | `GET /mp/tasks` → `code=0` | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
| B4 | **不带 `Authorization`** 调 `/mp/tasks` | `40101` | **本脚本** |
| B5 | **签名被篡改**的 token | `40101` | **本脚本** |
| B6 | **`exp` 已过期**的 token | `40101` | **本脚本** |
> B4~B6 的意义:既有脚本都在证明「有效 token 能用」(`e2e-m3-mytask.py:140`、`e2e-m3-gate.py:175`),**没有一条证明「无效 token 不能用」**。认证的负向路径从来没人走过。
### 3.4 C 组 · 门禁核验
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| C1 | 姓名 + 证件号与名册一致 | `code=0`,绑定该行,返回姓名与**脱敏**证件号 | 既有脚本 `scripts/e2e-m3-gate.py:339` |
| C2 | 号码不在名册 | `40007` | 既有脚本 `scripts/e2e-m3-gate.py:366` |
| C3 | 姓名不对 | `40007`,且提示与 C2 **逐字相同**(防枚举) | 既有脚本 `scripts/e2e-m3-gate.py:364` |
| C4 | 已绑自己的人再核验 | `code=0`(幂等,刷新页面不该出现「已被登记」) | 既有脚本 `scripts/e2e-m3-gate.py:356` |
| C5 | 另一个账号核同一名册行 | `40072` | 既有脚本 `scripts/e2e-m3-gate.py:379` |
| C6 | 号码格式乱 / 前 10 次失败 / 第 11 次限流 | `40007` × 10 → `40071` | 既有脚本 `scripts/e2e-m3-gate.py:417,430-431` |
| C7 | **明文证件号不落任何地方** | 门禁**响应全文**无明文;库里 `collect_roster` 只有 `id_card_no`(密文)/`id_card_hash`/`id_card_mask`;`sys_operation_log.request_param` 也无明文 | 响应字段侧:既有脚本 `scripts/e2e-m3-gate.py:339`;**全文扫 + 库 + 日志侧:本脚本**(5 条断言,全绿) |
| C8 | **任务已截止/已暂停时核验** | **仍 `code=0`**(`MpRosterService.java:107-175` 不判任务状态,这是**有意的解耦**);但紧随其后的 `GET /mp/tasks/{id}/form` 必须 `40008` | **本脚本** |
> C8 是「钉住一个刻意的设计」。它看起来像 bug(截止了还能核验?),其实绑定与任务状态解耦是对的:学生可能只是回来核对身份。**必须有用例把它钉住**,否则后来者「顺手加个校验」就把它改了,而且没有任何东西会红。
### 3.5 D 组 · 表单结构与三态
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| D1 | 未过门禁取表单 | `40007` | 既有脚本 `scripts/e2e-m3-draft.py:139` |
| D2 | **不存在的 taskId** | `40001「任务不存在或已删除」` | **本脚本** |
| D3 | **任务为草稿(0)** | `40008` | **本脚本** |
| D4 | **任务已暂停(4)/ 已截止(2)/ 已归档(3)** | `40008`(逐状态各一次) | **本脚本** |
| D5 | 收集中:内置字段 **21 项**、分区结构完整 | `len(fields)==21` | 既有脚本 `scripts/e2e-m3-draft.py:153` |
| D6 | 三态:`occupation/worktype/job_level` 锁定且值来自名册;`education` 可改且 `prefilled=true` | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:154-157` |
| D7 | **表单响应里搜不到明文证件号**(全文扫一遍) | 只有 `******` 形式的脱敏值 | **本脚本** |
| D8 | **条件可见性(字段侧)**:`is_student` 真/假切换后,学籍三项与工作证明类字段的可见性 | 与 `SubmissionValidator.visible()` 同口径(未知运算符 **fail-open**) | **本脚本** |
| D9 | 有草稿时取表单带出 `submissionId`,草稿值覆盖名册值 | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:184-192` |
| D10 | **扩展字段要能回显**:用 `extrasJson` 存进去的值,下次取表单要在字段上带出来 | 见期望 → 实测恒为 `null`(**XFAIL · F-128**) | **本脚本**(`EP-D09`) |
> D8 只做**字段侧**:条件**材料**侧的排除已被 `e2e-m3-submit.py:297` 与 `e2e-m3-mytask.py:179` 覆盖,不要重复。
### 3.6 E 组 · 草稿
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| E1 | `POST /mp/submissions` 幂等 | 首次 `created=true`,再次 `created=false` 且 id 相同,库里只有 1 份 | 既有脚本 `scripts/e2e-m3-draft.py:161-171` |
| E2 | `PUT` 合并式保存:只传变化字段 | 其它字段不受影响 | 既有脚本 `scripts/e2e-m3-draft.py:175-192` |
| E3 | 改锁定字段 | `40001`,提示含「老师」「班主任」;库里没被改 | 既有脚本 `scripts/e2e-m3-draft.py:197` |
| E4 | 锁定字段改成**原值** | `code=0`(不算改动) | 既有脚本 `scripts/e2e-m3-draft.py:206` |
| E5 | 传不认识的字段 | `40001「表单里没有这个字段」`(不静默忽略) | 既有脚本 `scripts/e2e-m3-draft.py:211` |
| E6 | 清空预填字段 | 库里真变 `NULL`;回显为空,**不被名册值顶回** | 既有脚本 `scripts/e2e-m3-draft.py:226-234` |
| E7 | `changed_fields` 留痕 | 只含真改过的 | 既有脚本 `scripts/e2e-m3-draft.py:216-221` |
| E8 | **已提交(status=1)后 `PUT`** | `40030` | **本脚本** |
| E9 | **已驳回(status=4)后 `PUT`** | 契约 §2.4 期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
| E10 | 越权:用 B 的 token 改 A 的单 | `40001` | **本脚本** |
| E11 | 不存在的 submissionId | `40001` | **本脚本** |
| E12 | 建草稿不带 `taskId` | `40001「缺少 taskId」` | **本脚本** |
| E13 | **建草稿后把任务暂停,再 `PUT`** | `40008` | **本脚本** |
| E14 | **扩展字段白名单**:未声明的 `extra_*` → 拒;已声明的 → 落 `extras_json` | `40001` / 落库 | **本脚本** |
| E15 | **扩展字段塞错口袋**:已声明的扩展键放进 `formData`(而不是 `extrasJson`) | `code=0` 但值被**静默丢弃**(**F-129**,钉住现状) | **本脚本**(`EP-E16`) |
> E8 是本计划**第一个被核对出来的自述缺口**:`scripts/e2e-m3-draft.py:11` 的 docstring 写着「已提交的不能直接改」,而全仓**没有任何断言涉及 `40030`**(唯一相关的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**那条路,不是学生自己的 `PUT`)。
### 3.7 F 组 · 材料
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| F1 | 上传 → 落库 → `process` | `final_size ≤ maxSizeKb*1024`;`standard_name` 按模板含姓名 + **脱敏号(用 X 不用 `*`)** | 既有脚本 `scripts/e2e-m3-file.py:314-336` |
| F2 | 档位生效(P2 证件翻拍 → 长边限 1600)/ 原始件保留 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py` |
| F3 | `process` 幂等:重复处理不产生第二份规范文件 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py:364` |
| F4 | 格式白名单外的格式 | `40002` | 既有脚本 `scripts/e2e-m3-file.py:374` |
| F5 | 超过份数上限 | `40001` | 既有脚本 `scripts/e2e-m3-file.py:376` |
| F6 | **按字节判格式**:`.txt` 改名 `.jpg` → 拒;`.jpg` 里装 PNG → 收 | 不信后缀 | 既有脚本 `scripts/e2e-m3-file.py:423,429` |
| F7 | **空文件** | `40001「文件是空的」` | **本脚本** |
| F8 | **不存在的 materialCode** | `40001「这个任务里没有该材料项:xxx」` | **本脚本** |
| F9 | **已提交(status=1)后上传 / 删除** | `40030` | **本脚本**(⚠️ **代传**侧的 `40030` 已由 `scripts/e2e-m5-proxy.py:277` 覆盖,学生自己那条路没有) |
| F10 | **已驳回(status=4)后上传 / 删除** | 契约期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
| F11 | 越权:上传到 A 的单 / 删 A 的文件 | `40001` | **本脚本** |
| F12 | `/mp/files/{id}/url`:本人 `code=0` + 非空 url;别人 `40001` | 见期望 | **本脚本** |
| F13 | **§9.5 `upload-token` 预检 + 秒传**:同 hash 第二次 → `existed=true` 且**不给 uploadUrl** | 秒传是真的 | **本脚本** |
| F14 | **§9.7 `batch-process`**:>200 个 fileId → `40011`;正常批量 → 逐份走同一 `process` | 见期望 | **本脚本** |
| F15 | **§9.8 预览**:`prefer=original` 但原始件不存在 → 退到规范件,且 `actualKind` **如实告知** | 不假装是原图 | **本脚本** |
| F16 | 条件材料:在校生不被要求工作证明 | 不阻断提交 | 既有脚本 `scripts/e2e-m3-submit.py:297` |
| F17 | 越权:对**别人的文件**调 `process` → `40001` | 不靠前端隐藏按钮 | **本脚本** |
| F18 | 越权:列**别人的填报单**的材料 → `40001` | 见期望 | **本脚本** |
### 3.8 G 组 · 提交与校验(红黄蓝全覆盖)
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| G1 | 缺必传材料 | `VAL-05` 红 | 既有脚本 `scripts/e2e-m3-submit.py:229` |
| G2 | 缺必填字段 | `VAL-22` 红 | 既有脚本 `scripts/e2e-m3-submit.py:230` |
| G3 | `VAL-01` **不该误报**(`id_card_no` 是密文,不能当明文算校验位) | 不出现 `VAL-01` | 既有脚本 `scripts/e2e-m3-submit.py:231` |
| G4 | 在校生三项缺项 | `VAL-16` 红;缺项要**列全**;补齐后消失 | 既有脚本 `scripts/e2e-m3-submit.py:244,248,252` |
| G5 | `VAL-15`:四级勾 2 科 → 红;勾了补考但 0 科 → **黄** | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py:258,268,279` |
| G6 | 只黄无红 → 先 `submitted=false`;带 `confirmWarnings=true` 再提交 → `submitted=true` | 两次调用 | 既有脚本 `scripts/e2e-m3-submit.py` |
| G7 | 提交后落库 + 名册行 → 2;重复提交被拒 | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py`、`scripts/e2e-m5-audit.py:308` |
| G8 | **一次给全**:多类问题同时返回(不是改一条再拒一次) | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py` |
| G9 | **「无」是合法值**:填「无」不算缺失(契约 §2.6,`docs/03-接口设计.md:258-265`) | 不报 `VAL-22` | **本脚本** |
| G10 | **`VAL-22` 只对「可见 + 必填 + 非锁定」字段报** | 不要求在校生填「补考理论」 | **本脚本** |
| G11 | **`VAL-17`**:工作年限「80」→ 红(0~60 阿拉伯数字) | 红 | **本脚本** |
| G12 | **`VAL-17` 的非阿拉伯数字分支**:「三年」根本存不进去(`work_years` 是 `smallint`)→ 那段校验**没有入口** | 钉住「死分支」这一事实 | **本脚本**(`EP-G11a`) |
| G12 | **`VAL-01` 正向**:名册行证件号为空 → 红「名册里没有你的身份证号」 | 红 | **本脚本**(G3 只测了不该误报) |
| G13 | **`VAL-09`** 同一张图传到两个材料项 → 黄 | — | 既有脚本 `scripts/e2e-m3-submit.py:319` |
| G14 | **`VAL-08` 清晰度偏低 / `VAL-20` 体积偏小** → 黄(不阻断) | 见期望 | **本脚本** |
| G15 | 越权提交别人的单 → `40001`;无 token 提交 → `40101` | 见期望 | **本脚本** |
| G16 | 任务暂停 / 已截止时提交 → `40008`;解绑门禁后提交 → `40007` | 见期望 | **本脚本** |
| G17 | **判据纪律**:断言 `data.submitted` 而非 `code==0`(F-84) | 失败信息带红黄计数与 issues | 全脚本约定 |
| G18 | **红色不能靠 `confirmWarnings` 强提绕过**(契约:它只对黄色有效,`SubmitDtos.java:100-101`) | `submitted=false` 且 `redCount>0` | **本脚本** |
### 3.9 H 组 · 我的任务 / 进度 / 撤回
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| H1 | 可见范围 = 名册绑定(没绑的人看不到该任务) | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:161` |
| H2 | 草稿态:`canFillNow=true`、状态文案「还没开始」、计入 `todoCount` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:154-157` |
| H3 | 审核中 / 已通过 / 已截止:`canFillNow=false` + `fillBlockReason` 各自不同 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:228,287,301-302` |
| H4 | **`progressPercent` 是字符串**(`"0%"` → `"100%"`),分母只算**可见的必传材料** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:189,213` |
| H5 | 材料清单只列本次适用的(在校生 3 项,不含工作证明);`done/required/issueLevel` 正确 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:179-185` |
| H6 | 时间轴只列**确实发生过**的节点 | 「没发生的事不许出现」 | 既有脚本 `scripts/e2e-m3-mytask.py:193,217` |
| H7 | 撤回 status=1 → `status=0`、`submit_time` 清空、**名册行回 1** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:235-241` |
| H8 | 撤回边界:草稿态再撤 → 拒;已通过 → `40031` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:251,279` |
| H9 | 越权:别人看我的进度 / 别人撤回我的单 → 拒 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:200,202` |
| H10 | **status=4 时 `progress.canEdit` / `canSubmit`** | 实测 `true` —— 与 E9/F10 的 `40030` **直接矛盾**(**F-125 的运行时证据**) | **本脚本** |
| H11 | **撤回后再改再提交**,且 `reject_count` 不因撤回增加 | 见期望 | **本脚本** |
| H12 | **撤回 → 再提交**这一对(既有脚本只覆盖了「撤回」那一半,没有一段把它闭合) | 撤回回 0/名册 1;重交回 1/名册 2 | **本脚本** |
> H10 的证据来自 `StudentTaskService.java:311-313`:`setCanEdit(st == null \|\| st == 0 \|\| st == 4)`、`setCanSubmit(同)`。**读模型说「能改能交」,写模型(`saveDraft`/`storeFile`)说「`40030`」** —— 同一个类文件里的两个方法互相矛盾。
### 3.10 I 组 · 审核闭环与驳回重交
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| I1 | 班主任待办:被指定的初审人看得到、同角色但没被指定的看不到;跨班不可见 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:316,336` |
| I2 | 初审通过 → `status=2`、名册行 3、审核记录 `action=1` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:406-436` |
| I3 | 有红的单不允许通过 → `40032` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:385` |
| I4 | 批量通过只带无问题项;红黄一律跳过并说明原因 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:452,485` |
| I5 | 驳回:必填原因码、`R999` 必须带说明 → `status=4`、`reject_count`、名册行 4、审核记录 `action=2` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:496-517` |
| I6 | 驳回达上限 → `escalated=true`(升级管理员,**不是**错误码) | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:528` |
| I7 | **驳回后学生能重新提交** → `status=1`、名册行回 2 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:534,537` |
| I8 | **驳回后学生侧的提示**:「被驳回,请修改后重新提交」+ `canFillNow=true` + 计入 todo | 见期望 | **本脚本** |
| I9 | **驳回后先整改再提交**(改一个字段 + 换一份材料,再提交) | 契约 §2.4 期望可改 → 实测改不动(**XFAIL · F-125**) | **本脚本** |
| I10 | 已通过后撤回 → `40031`;导出后 → `40033`;归档后 → `40034` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:279`、`scripts/e2e-m5-audit.py:557,565` |
> ⚠️ **I7 与 I9 的区别就是本计划存在的理由**:`scripts/e2e-m5-audit.py:531-538` 在重交前**只改了库里的 `reject_count`**(`UPDATE collect_submission SET reject_count=3`),**没有改任何业务字段** —— 它证明的是「原样重交能过」,而驳回的全部意义在于「改了再交」。切片测试把这一格填成了绿色,缝里的东西没人看。
### 3.11 J 组 · 跨步骤一致性(本脚本独有价值)
| 编号 | 场景 | 期望 | 覆盖者 |
|---|---|---|---|
| J1 | **名册行 `submit_status` 全轨迹**:`0` →建草稿`1` →提交`2` →驳回`4` →重交`2` →终审`3`,每一步查库 | 五步全对且**连续** | **本脚本** |
| J2 | **表单升版后旧草稿提交**:`PUT /tasks/{id}/form-schema` 升版并加一个必填 `extra_*`,再提交旧草稿 | **不报 `40025`**;按**当前** schema 校验(新必填项会拦);`collect_submission.form_version` 保持创建时的快照值 | **本脚本** |
| J3 | **并发提交**(两个连接同时 `POST /submit`) | 如实钉住当前行为(`SubmissionService.java:280-282` 明写一期不做并发保护),把「无保护」写成**已知事实**而不是假装有 | **本脚本** |
> **J1 的价值**:`scripts/e2e-m5-audit.py:308,436,517,537` 分别断言过 `2`、`3`、`4`、`2`,但**分散在四个上下文里**,没有任何一条断言「这一行的状态是连续走过来的」。分散断言无法发现「中间跳了一格」。
> **J2 的顺序依赖**:它会改夹具 schema,必须排在 G 组之后(见 §4.2)。
> **J3 的态度**:不做并发保护的**决定是书面的**(代码注释里写了为什么,以及将来要改的话该怎么改)。测试的职责是把这个决定**钉成可观测事实**,而不是替它加锁。
### 3.12 小结(实测口径,2026-10)
| 覆盖者 | 用例数 |
|---|---|
| 既有脚本(已在跑) | 约 55 |
| **本脚本** | **45** |
| 人工走查(L3-B / L3-C,加分项) | L3-B 4 条里走了 3 条(第 4 条未走,见 §5.3);**L3-C 小程序端另走了 10 步**(第 2 条由它补上) |
| M4 缓做(不写用例) | 7 个错误码 |
**实测结果**:
| 跑法 | 通过 | 失败 | XFAIL |
|---|:--:|:--:|:--:|
| `--group chain` | 74 | 0 | 5 |
| `--group errors` | 27 | 0 | 1 |
| 无参数(chain + errors) | **95** | **0** | **6** |
(三种跑法都通过;`共 95` 已超过 DoD 要求的 90 条。)
---
## 四、脚本设计(`scripts/e2e-m3-flow.py`)
### 4.1 夹具
**两个任务**,都带随机 suffix:
| 任务 | 用途 | 状态 |
|---|---|---|
| `taskMain` | 跑 chain 组(完整链路) | 全程保持**收集中** |
| `taskState` | 跑 errors 组里的**状态机边界**(草稿/暂停/截止/归档 → `40008`) | 按用例切换 |
**构造顺序**(每一步都走真实接口,与 `scripts/e2e-m5-audit.py:177-267` 同形):
1. `admin` / `Admin@123` + 验证码 `demo` 登录(学校管理员)
2. 取一个 `orgType=3` 的班级
3. 建初审班主任(`HEAD_TEACHER`、`userType=3`,拿返回的 `initialPassword` 登录)
4. 建两个任务(`collectMode=1`、`certType=1`)
5. 生成 xlsx 名册(2 人,`with_checksum` 补校验位)→ `upload_roster(..., {"orgId": ...})`
6. `PUT /tasks/{id}/orgs`(覆盖班级 + 指定初审人)
7. `POST /tasks/{id}/materials`:必传材料,**`minSizeKb=1`、`minLongSide=100`**
8. `POST /tasks/{id}/publish`
⚠️ 第 7 步的下限必须调小。理由见 `scripts/e2e-m5-audit.py:255-263` 的实测注释:E2E 用的小图会被材料目录的默认下限判成「体积偏小 / 清晰度偏低」→ **黄色警告**,于是「绿色单」这个前提不成立,后面所有断言跟着错。**断言失败的样子像功能坏了,其实只是夹具不满足前提。**
⚠️ **绝不触碰**演示任务 17 / 2097 / 2215 及其名册绑定。跑 `scripts/e2e-m3-*.py` 会清掉 `collect_roster.bind_user_id`(AGENTS.md 点过),本脚本只操作自建任务。
### 4.2 用例顺序依赖
| 依赖 | 说明 |
|---|---|
| `chain` 组必须整体跑 | J1 的轨迹断言依赖前序步骤真的发生过 |
| **J2 必须在 G 组之后** | J2 会升版夹具的表单 schema;提前跑会让后续所有 `VAL-22` 断言多一个必填项 |
| D3/D4/E13/G16 用 `taskState` | 它们会把任务切到暂停/截止/归档;用 `taskMain` 会毁掉 chain 组 |
| `errors` 组内的越权用例需要**两个学生** | 夹具准备 2 行名册即可 |
### 4.3 命令行参数
```bash
python scripts/e2e-m3-flow.py # 全部(chain + errors)
python scripts/e2e-m3-flow.py --group chain # 只跑链路(J1 轨迹在这里)
python scripts/e2e-m3-flow.py --group errors # 只跑错误矩阵
python scripts/e2e-m3-flow.py --fixture-only # 只建夹具,打印 id/token,不清理、不跑用例
python scripts/e2e-m3-flow.py --keep # 跑完不清理(配合人工走查)
python scripts/e2e-m3-flow.py --h5-url # 打印可直接打开的 H5 地址(见 §5.3)
python scripts/e2e-m3-flow.py --clean <taskId> # 只清理指定的夹具任务
python scripts/e2e-m3-flow.py --full # 连限流类慢用例一起跑(默认跳过)
```
### 4.4 清理
放 `finally`,**失败路径也要跑** —— F-85 的教训是「清理只在正常结束时跑,于是失败路径才是残留的来源」。
按 `task_id` 依次删:`collect_audit_record` → `collect_file` → `collect_submission` → `collect_roster` → `collect_material_def` → `collect_task_org` → `collect_export_task` → `collect_task`;再删 `sys_user_role` / `sys_user`(自建班主任)与临时 xlsx。最后**断言残留任务数为 0**。
### 4.5 XFAIL 机制
```python
KNOWN_DEFECTS = {
"EP-E09": "F-125", # 驳回后 PUT 被 40030 拒
"EP-F10": "F-125", # 驳回后上传被 40030 拒
"EP-F10b": "F-125", # 驳回后删除被 40030 拒(与上传是两条独立的守卫)
"EP-I09": "F-125", # 驳回后整改不落库
"EP-D09": "F-128", # 扩展字段存进去却回显不出来
"EP-J02e": "F-128", # 必填扩展字段永远满足不了
}
```
⚠️ **登记之后,用例的断言要写「契约期望」而不是「现状」** —— 否则 XFAIL 会变成一条永远通过的断言,等于把缺陷写成了规格。`EP-I09` 第一版就写错了:它只断言「原样重交能成功」,而那本来就成功(`scripts/e2e-m5-audit.py:534` 已证明),于是它报了个 `XPASS` 说"缺陷已修"。改成断言「整改的值真的落库了」之后才如实变成 XFAIL。
| 情形 | 输出 | 计入失败 |
|---|---|:--:|
| 该用例按契约期望**失败** | `[XFAIL] EP-E09(已知缺陷 F-125)` | 否 |
| 该用例**通过**了 | `[XPASS] EP-E09 缺陷似乎已修 —— 请删掉 KNOWN_DEFECTS 里的登记` | **是** |
为什么要 XPASS 报红:否则缺陷修好之后没有任何信号,登记会永远留在那里,而下一个人会以为它还在。
为什么可以 XFAIL:否则 `verify-all --with-e2e` 长期红,红着红着就没人看了 —— 那比不测更糟。
### 4.6 判据纪律(每一条都有血)
| # | 纪律 | 血的来源 |
|:--:|---|---|
| 1 | 业务错误也是 **HTTP 200**,判 `code`;只有 `401/403/429/5xxxx` 看 HTTP 状态码 | 项目约定,`ErrorCode.httpStatus()` |
| 2 | **「HTTP 200 + code 0」≠ 动作成功**:提交/发布/导入/批量审核一律断 `data` 里的结论字段(`submitted`/`created`…),失败信息带红黄计数与 issues | **F-84**(`docs/08-完成度盘点与问题清单.md:452`) |
| 3 | 每个接口级断言配一条**查库断言** | 「接口说成功了」与「数据库真的变了」是两件事 |
| 4 | 软删字段是 `smallint`:写 `deleted=0`,**不是** `=false` | PG 会报 `operator does not exist: smallint = boolean` |
| 5 | `Map` 结果的列别名会被**折成小写**,查库别用驼峰别名 | **F-54 / F-83** |
| 6 | 清理放 `finally`,并断言残留数 | **F-85** |
| 7 | 末尾必须打印 `通过 X 失败 Y 共 Z` | `scripts/verify-all.py:143` 靠这行正则统计;格式不对会被判成「无汇总」而算失败 |
| 8 | 门禁限流按 openid 计(10 次/小时):学生 uid 每次现造;要清 Redis 键必须带 `-a redis_dev_pwd --no-auth-warning` **并检查返回码** | `scripts/dev-seed-audit.py:123-141`(不看返回码 → 「清了但没清掉」) |
---
## 五、执行步骤
### 5.1 前置
```bash
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
# ⚠️ 不要动本机别的 pg-verify/redis-local/sp-redis/vastbase-pg
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
```
⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置)靠这个相对路径找到。
⚠️ **H5 不在 8080 上**(2026-10 起由 nginx 的 80 端口托管,见 `docs/09` §2.1)——
本计划的脚本全打 `/api`,所以只起后端就够;只有要在浏览器里走查**构建产物**时才需要
`pnpm build:h5` + `docker compose up -d nginx`(走查 dev server 则用 `pnpm dev:h5`)。
⚠️ **「jar 比源码新」不算证据**。启动后必须先探活,再跑一条已知脚本冒烟:
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/captcha # 期望 200
python scripts/e2e-m3-draft.py # 期望 27/27
```
### 5.2 L3-A 主流程
```bash
python scripts/e2e-m3-flow.py --fixture-only # 夹具前提不满足时立刻退出并打印清单
python scripts/e2e-m3-flow.py --group chain # 实测:通过 74 失败 0 XFAIL 5
python scripts/e2e-m3-flow.py --group errors # 实测:通过 27 失败 0 XFAIL 1
python scripts/e2e-m3-flow.py # 实测:通过 95 失败 0 XFAIL 6
python scripts/verify-all.py --with-e2e # 全量回归(38 个脚本,约 10 分钟)
```
⚠️ **跑完确认没有残留**(本脚本自己在 `finally` 里清,但失败路径值得再看一眼):
```bash
docker exec -e PGPASSWORD=fill_dev_pwd xt-pg psql -U fill -d filldb -t -A \
-c "SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E流程%'" # 期望 0
ls scripts/_tmp_flow_* # 期望没有
```
### 5.3 L3-B 浏览器走查(加分项)
```bash
cd frontend-mp && pnpm dev:h5 # → http://localhost:5174
python scripts/e2e-m3-flow.py --keep --h5-url # 打印带 token + scene 的 H5 地址
```
⚠️ **不要直接打开 stub-login 的 302 落点**:它默认跳到 `WX_H5_BASE_URL`(穿透域名 `http://394nb68658le.vicp.fun`),那里是 **nginx 托管的构建产物**(挂的是 `frontend-mp/dist/build/h5`,不是 dev server;产物可能是几天前的构建),不是 dev server。`--h5-url` 的做法是:Python 侧请求 stub-login 但**不跟随跳转**,从 `Location` 里取出 `token` 与 `scene`,拼成 `http://127.0.0.1:5174/#/pages/index/index?token=..&scene=..`。
⚠️ 走查时**名册行必须未绑定**,由界面自己走一遍门禁。原因:L3-A 用的是 `mint()` 手搓的**假 uid**(库里没有对应 `sys_user`),而 stub-login 会**真建** `sys_user` 并拿到真 uid —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。
⚠️ **门禁要手动输入身份证号,而库里只有密文**。`scripts/_reveal-roster-idcard.py` 能解,但它把 task id **写死在 `:59`(1719)**;查别的任务用:
```bash
python scripts/dev-reveal-roster-idcard.py <taskId> # 只读,只打印;解密方式见 AesGcmCipher
```
⚠️ `--fixture-only` 不会触发清理(那是给手工排查用的),走查完要自己收尾:
删任务(`--clean <taskId>`,或按 `docs/11` §4.4 的顺序逐个表删)、
删 stub-login 建出来的 `sys_user`(它不在脚本的夹具清单里)、删 `scripts/_tmp_flow_*.xlsx`。
走查看这四件**接口测不出来**的事(**2026-10 已实测前 3 条,见下表**):
| # | 看什么 | 为什么接口测不出来 | 实测结果 |
|:--:|---|---|---|
| 1 | 无 scene 但有 token 时,是否跳到「我的填报」而不是报错 | `frontend-mp/src/pages/index/index.vue:156-169` 的分支 | ✅ 直达「我的填报」。⚠️ 但**不能靠"去掉 URL 里的 scene"来复现** —— scene 已进本地缓存,落地页仍会用它;要直接开 `#/pages/tasks/tasks?token=..` |
| 2 | 有黄色警告时**弹确认框**,点「仍然提交」才真的提交 | `fill.vue:706-743` 的两次调用;`doSubmit` 不能把事件对象当 `confirmWarnings` 传(`:688-693` 的注释记过这个坑) | ✅ **已在 L3-C 小程序端走到**(H5 侧本轮只造出红色;小程序端用 72×72 的 logo 触发 `VAL-08` 黄单 → 页内「仍然提交」→ 提交成功) |
| 3 | 驳回状态下每次失焦都提示「保存失败:…请先撤回」 | `fill.vue:378-380` —— **这就是 F-125 在学生眼里的样子** | ✅ **复现**(原文见下) |
| 4 | 传图后结果卡显示**命名前后对比**与压缩后体积 | 只能看 | 未走(浏览器自动化无法选本地文件,见下) |
**第 3 条实测到的原话**(把 `status` 改成 4 后改任意字段并失焦):
```
保存失败:已提交的填报单不能直接修改,请先撤回
```
而**同一屏上没有任何「撤回」入口**;退到「我的填报」,卡片上写着
**「被驳回,请修改后重新提交」**、按钮写着**「修改后重新提交」**,
点进去却每一次修改都失败 —— 界面自己的行动号召把学生带进死胡同。
这正是 F-125 的完整用户侧证据链(接口侧是 4 条 XFAIL)。
⚠️ **第 4 条为什么没走**:材料上传要选本地文件,而本轮可用的浏览器自动化**没有文件选择能力**。
所以"传图 → 命名对比 → 压缩体积"这条只能人工走查;接口侧的等价断言在 L3-A(`EP-F05` 系列与 `EP-F15`)。
### 5.4 L3-C 微信小程序端走查(2026-10 补)
> 起因:需求方明确要求「**学生提交的过程要测微信小程序端**」。小程序与 H5 是同一套源码
> (`frontend-mp/`,`#ifdef` 条件编译),但**小程序端此前从未被任何自动化跑过** —— AGENTS.md 的
> 节奏是"先做 H5(可自动化测试、不依赖小程序资质)"。
**环境**(一次性的,`wmp_doctor` 可自检):微信开发者工具已装且已登录、自动化端口 9420、
项目 = `frontend-mp/dist/dev/mp-weixin`(`pnpm dev:mp-weixin` 的产物;`project.config.json` 里
`urlCheck:false`、appid `wxf01d3fa60f87f0b7` **与后端 `fill.mini-program.app-id` 逐字相同**)。
**前置**:① `python scripts/e2e-m3-flow.py --fixture-only --keep` 建夹具(名册行必须**未绑定**);
② `python scripts/dev-reveal-roster-idcard.py <taskId>` 取门禁要手输的明文姓名/身份证。
**实测步骤与结果**(iPhone 12/13,SDK 3.17.3,开发者工具 8.0.5):
| # | 操作(自动化动作) | 结果 |
|:--:|---|---|
| 1 | `reLaunch /pages/index/index?scene=t1-k2313` | 落地页正常:示范职业学校 / E2E流程 / **收集中** / scene 回显 —— **匿名接口在小程序里也是通的** |
| 2 | `tap .land__btn`(开始填报)→ `pages/verify/verify` | 正常 |
| 3 | 两个 `input` 填姓名 + 身份证 → `tap button` | ❌ **「未登录或登录已失效」**(后端 40101)→ **F-130** |
| 4 | 用 `evaluate` 在页面上下文里调 `wx.login()` + `wx.request` 打 `/api/v1/mp/login` | ❌ scene 带 taskId → **HTTP 500 / code 50000**;同秒换 `scene=t1` → ✅ **200 + token** → **F-130** |
| 5 | `wx.setStorageSync('token', …)`(桥接,见下)后重走 1~3 | ✅ 门禁通过:流程甲 / 脱敏号 / 班级 / 报考三项锁定;库里 `bind_user_id=1390`、`bind_type=1` |
| 6 | 进 `pages/fill/fill?taskId=2313` | ✅ 表单 68 个元素全渲染,锁定项标注正确 |
| 7 | `mockWx chooseImage` → `tap`「+ 选择文件」 | ✅ 控制台 `[材料压缩] IDCARD P2 证件翻拍:4KB → 2KB(q88)` → `collect_file` 落库,规范名 `流程甲…-3610292008XXXXXX13-身份证.jpg` |
| 8 | `tap`「提交审核」 | ✅ 页内红单:`VAL-05 缺少必传材料:身份证` + 「知道了,去修改」 |
| 9 | 传材料后再提交 | ✅ 页内黄单:`VAL-08「身份证」清晰度偏低(72×72)` + **「仍然提交」**(= L3-B 第 2 条,H5 那轮没走到的确认框) |
| 10 | `tap`「仍然提交」 | ✅ 提交成功:`collect_submission.status=1`、`submit_time` 写入、`collect_roster.submit_status=2` |
**结论**:小程序端**除登录外整条链路都是好的** —— 落地页、门禁、动态表单、前端压缩上传、
红黄两档校验与确认框、提交后的名册流转,全部在真机模拟器里跑通。
唯一的断点是登录(**F-130**),而且它断在两处、任一处都足以让小程序不可用。
⚠️ **第 5 步是"桥接",不是正路**:直接把 token 塞进 storage 只是为了把「登录坏了」与
「其余链路好不好」分开验证。**修好 F-130 之后这一步必须删掉。**
⚠️ **为什么没有做成 `.flow.mjs` 固化**:本轮可选的小程序自动化动词(`reLaunch`/`tap`/`input`/
`evaluate`/`mockWx`)都实测过,但**流程文件的字段形状**(带 `index` 的 `input`、带选择器的
`assert.text`、设 storage 的 `wx` 动词)没有可验证的样例 —— **宁可留一份可照着做的步骤表,
也不往仓库里放一个跑不起来、却看起来像能跑的脚本**(这与 F-126 的教训是同一条)。
等 F-130 修好、登录不再需要桥接时,再把它固化成 flow 文件更划算。
⚠️ **别忘了收尾**:`--fixture-only` 不触发清理;**登录成功那一次**(本轮是 `scene=t1` 那次)会真建
`sys_user`(本轮 1 个:uid 1390,并被门禁绑到名册行 21695),它不在脚本的夹具清单里,要手工删。
失败的登录**不建账号**(事务回滚,见 F-130 的副作用一节)。
---
## 六、已知缺陷(已定案)
### F-130【P0 · 功能不可用】微信小程序端的学生**完全进不来** —— 登录链路断成两截
**症状**(真机模拟器实测):扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 →
点「核对并继续」→ 屏幕显示 **「未登录或登录已失效」**。
**断点 A(前端):小程序端从来没有调用过 `/mp/login`。**
- 全仓 `frontend-mp/src` 里**唯一**写 token 的地方是 `pages/index/index.vue:222`
(从 **H5 授权回调**的 `options.token` 取),而这条路径是 H5 专有的;
- 9 个页面(`fill` / `verify` / `tasks` / `progress` / `messages` / `dashboard` / `audit` / `audit-detail` / `subscribe`)
的 `token()` 都读 `uni.getStorageSync('token')`;
- 全仓搜不到 `wx.login` / `uni.login` / `/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释
「登录流程见契约 §5.x 的 /mp/login,**属 M3**」);也**没有 `web-view` 兜底**。
- ⇒ 小程序里 token **永远是空串**,`POST /mp/roster/match` 不带 Authorization → 后端 40101。
**断点 B(后端):`/mp/login` 只要 scene 带 taskId 就必然 500。**
- `MpLoginService.login` 在 `:126-130` 有个"顺带把名册绑定带回去"的分支:
`rosterMapper.selectOne(... collect_roster ...)`。`rosterMapper` 是**普通 `BaseMapper`**,
而 `/mp/login` 是 **`@PublicApi`(匿名)** → **租户拦截器默认拒绝** → `BizException(40101)`
→ MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」**。
- 日志铁证(本轮实测):`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389` 成功后紧接着
`### Error querying database. Cause: cn.tianbaotong.fill.common.exception.BizException: 未登录或登录已失效`,
栈顶 `at cn.tianbaotong.fill.module.mp.service.MpLoginService.login(MpLoginService.java:127)`。
- **对照实验**(同秒、同端点、只改 scene):
| scene | 结果 |
|---|---|
| `t1`(不带任务) | **HTTP 200 + token**(`needBindPhone:true`,`user{id,realName:"微信用户",userType:4}`) |
| `t1-k2313`(带任务) | **HTTP 500 / code 50000** |
- ⇒ `/mp/login` **只在"永远用不到的那个场景"下能用** —— 学生扫的码必然带 taskId(不然没有任务可填)。
- **副作用(已核实,不要写成"留下一堆半成品账号")**:日志里明明打着
`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389`,但 `login()` 是 `@Transactional` 的,
账号与 `sys_user_role` 都随异常**回滚**了 ——
`SELECT id FROM sys_user WHERE id IN (1389,1390)` 只有 **1390**(那是本轮用 `scene=t1` 成功调出来的那个)。
所以症状是「学生每次点都 500,什么也没留下」,**不是**「攒下一堆孤儿账号」;
唯一残留是**自增 id 被消耗**(1389 成了空号)。
- 修法方向(**未实施,属改代码**):该分支改成走**专用 Mapper + `@InterceptorIgnore(tenantLine="true")` + 显式带 tenant_id**
—— 这正是 AGENTS.md 里"匿名接口必须走专用 Mapper"那条铁律,`MpH5Mapper` 就是这么做的
(`selectByOpenId(tenantId, openId)`),而这里漏了。另外**异常不该以"系统繁忙"对外**:
40101 被包成 50000,把"没登录"讲成了"服务器坏了"。
**为什么至今没暴露**:
- `/mp/login` **没有任何 E2E 覆盖** —— `scripts/e2e-m3-gate.py:18` 明确写着「登录(`/mp/login`)需要真实的
`wx.login` code —— 脚本拿不到」。**本轮证明它拿得到**:开发者工具自动化里可以在页面上下文调 `wx.login()`
拿到**真的** code(后端真换到了 openid),所以这条链路是可以自动化的。
- 文档**互相矛盾**:`docs/08:147` 写着「~~P1-14~~ 学生端 `/mp/login` + 手机号绑定 → ✅ **已完成**」
(指的是**后端**),而 `docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」。
两边都不算错,但**没有任何一处说"前端没接"** —— 与 F-126 同一类:"宣布完成"与"界面上能用"之间的落差,
`check-wiring.py` 的十项检查**没有一项会发现**(它查权限码/错误码/模板/菜单键,不查"契约端点有没有前端调用方")。
### F-125【P0 · 阻断闭环】学生被驳回后**改不了任何东西**
**现象**:填报单进入 `status=4`(已驳回)后,学生:
| 动作 | 结果 | 代码 |
|---|---|---|
| 改字段(`PUT /mp/submissions/{id}`) | `40030` | `SubmissionService.java:158-161` |
| 传材料(`POST /mp/files/upload`) | `40030` | `MaterialFileService.java:103-105` |
| 删材料(`DELETE /mp/files/{id}`) | `40030` | `MaterialFileService.java:338-341` |
| 撤回(`POST /mp/submissions/{id}/withdraw`) | `40001`(只放行 1/2) | `StudentTaskService.java:190-196` |
| **重新提交**(`POST .../submit`) | **允许** | `SubmissionService.java:293-297` |
**于是闭环断了**:学生只能**原样重交**,而驳回的意义正是「改了再交」。
**而且系统在四处同时告诉他「你可以改」**:
| 信号 | 值 | 代码 |
|---|---|---|
| `GET /mp/tasks` 的 `statusText` | 「被驳回,请修改后重新提交」 | `StudentTaskService.java:142` |
| `GET /mp/tasks` 的 `canFillNow` | `true`(`underReview` 只算 1/2/3,**4 不算**) | `StudentTaskService.java:105-106` |
| `GET /mp/submissions/{id}/progress` 的 `canEdit` | `true`(`st == 0 \|\| st == 4`) | `StudentTaskService.java:311` |
| 同一个接口的 `canSubmit` | `true` | `StudentTaskService.java:313` |
**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 证明过「驳回后能重新提交」,但它在重交前只改了库里的 `reject_count`,**没有改任何业务字段**。切片测试把这一格填成了绿色。
**契约站在哪一边**:`docs/03-接口设计.md:239-248` 明确写 `4 已驳回 → 可流转到 1(学生重提交)`;`docs/03-接口设计.md:2040` 的撤回约束只写 `status ∈ {1,2}`,没有为「驳回后怎么改」留出口。**所以按契约,改不动就是实现缺一条路径。**
**建议的最小改法**(**改业务口径,须用户确认后再动**):把三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`(`SubmissionService.java:293-297`)和 `progress.canEdit`(`:311`)的口径对齐。涉及:`SubmissionService.java:158-161`、`MaterialFileService.java:103-105`、`MaterialFileService.java:338-341`。
**备选方案**(不动写守卫,改撤回守卫):让 `withdraw` 也接受 `status=4`(驳回 → 草稿 → 改 → 重交)。缺点是学生要先点一次「撤回」,而界面在驳回态**不显示撤回按钮**(`StudentTaskService.java:109` 的 `canWithdraw` 只算 1/2)。
### F-128【P0 · 阻断功能】扩展字段存进去却读不出来 —— 必填扩展字段会让整个任务**永久无法提交**
**现象**(`EP-D09` / `EP-J02e`,已实测):
```
PUT /mp/submissions/{id} {"extrasJson": {"extra_upgrade_note": "已补填"}}
→ code=0,库里 extras_json = {"extra_upgrade_note": "已补填"} ← 写成功了
GET /mp/tasks/{task}/form
→ 该字段的 value = null ← 读不出来
POST /mp/submissions/{id}/submit
→ {"submitted": false, "redCount": 1,
"issues":[{"ruleCode":"VAL-22","title":"还有必填项没填:升版新增必填"}]} ← 填了也说没填
```
**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表 `values` 时只有两个来源 ——
1. `rosterValuesOf(roster)`(名册列)
2. 遍历 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的键,读 `collect_submission` 的**列**
**它从来没有把 `draft.extrasJson` 合进 `values`。** 而扩展字段的值只存在 `extras_json` 里,于是:
- `withValue(f, values)` 对扩展字段恒取到 `null` → **学生填的扩展字段重开表单就没了**(以为没保存上,再填一遍);
- `SubmissionValidator.validate` 的 `values`(`SubmissionValidator.java:104-109`)来自同一份装配 → `checkRequiredFields` 的 `blank(values.get(key))` **恒为真** → **必填扩展字段永远满足不了**。
**影响**:
| # | 影响 | 严重度 |
|:--:|---|:--:|
| 1 | 表单里加了**必填**扩展字段 → 该任务对**所有人**永久阻断,没有任何办法满足它 | **P0(功能不可用)** |
| 2 | 学生填的扩展字段保存后在界面上消失(数据在库里,但读不出来) | P1 |
| 3 | 依赖 `values` 的下游(校验、导出前置判断)都看不到扩展字段的值 | P1 |
**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**(`extras` 为空),
而既有 37 个脚本用的都是内置表单 —— 所以这条路径**从来没有数据走过**。它是"表单设计器给任务加了扩展字段"这个真实用法才会踩到的。
**建议的最小改法**(**改业务口径/数据读取口径,须确认后再动**):在 `assemble` 里把 `extrasJson` 合进 `values`,
即对 `schema.getExtras()` 的每个键,从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`。
位置在 `FillService.java:144-158` 之后(那段循环之后、构造 sections 之前)。
⚠️ 注意 `changed_fields` 的语义:扩展字段没有列,不要顺手把它们塞进 `changed_fields`(那是给内置列用的)。
### F-129【P2 · 静默丢数据】把已声明的扩展字段放进 `formData` 不会报错,值直接没了
`EP-E16` 实测:`{"formData": {"extra_upgrade_note": "塞错口袋"}}` → `code=0`,`extras_json` 仍是 `{}`。
对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略")。
而**已声明**的扩展键放进 `formData` 时,`defs` 里查得到(`:169-171` 把 extras 也放进 `defs`),
于是躲过了那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列,静默什么也不做**。
契约要求扩展字段走 `extrasJson`(`DraftDtos.SaveReq.java:44`),所以这属于**用错参数**;
但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。
**建议**:`SubValues.apply` 遇到既不是列、又确实是声明过的扩展键时,抛出 40001 并提示"扩展字段请放在 extrasJson"。
### F-126【P2 · 脚本自述与断言不符】(已处理)
`scripts/e2e-m3-draft.py:11` 的 docstring 自述 `[x] 未过门禁不能建草稿;已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的断言是 `scripts/e2e-m5-proxy.py:277`,测的是代传路径)。
**本轮已补**:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`)已落地并通过。
**建议**:把 `scripts/e2e-m3-draft.py:11` 的自述改准(去掉那半句,或指向本脚本)。
**为什么值得记**:docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`scripts/verify-all.py` 的汇总、多份文档的「37 个脚本 / N 条断言」都建立在它上面)。自述虚高与 F-84 同类,而且**更隐蔽** —— 断言缺失不会让任何东西变红,反而让覆盖率看起来更好。
**教训**:本计划 §3.1 的 `grep 'check(\|ok('` 核对规程**不是走形式** —— 它一上来就抓到了这一条。
### F-127【P2 · 文档口径未登记】§9.10 的「校验失败返回 40001」与实现不一致
- **契约**(`docs/03-接口设计.md:1988,1997-2019`):错误码 `40001 阻断`,`data` 里给 `blocking[]` / `warnings[]` / `canForceSubmit`。
- **实现**(`FillController.java:59-70`):**HTTP 200 + `code:0`**,`data` 是 `{submitted:false, status, statusText, redCount, yellowCount, infoCount, issues[]}`。代码注释给了理由:校验不通过是**正常业务结果**,前端要一次拿到完整清单才能渲染红黄绿。
实现的口径与项目约定(业务错误不占 HTTP 状态码)一致,也已被 37 个脚本按 `data.submitted` 判定 —— **但 `§17.2 有意偏离契约的地方` 里没有它**(D-1~D-12,`docs/03-接口设计.md:2972-2988`)。后果是文档读者会写出 `code != 0` 的判据,而那是**恒假的**,测试会静默变绿。
**建议**:在 §17.2 补一行 **D-13** 记录该偏离(表格仍是 5 列,不影响 `verify-docs.py`)。本轮只提议,不在测试轮里动契约。
### 待定案 → 已定案(实测结论,2026-10)
| 问题 | 定案 | 证据 |
|---|---|---|
| 驳回后**重新提交**能否过校验(内容未变时) | **能过** —— 这也正是 F-125 的欺骗性所在:交得上去,但改不了 | 既有 `scripts/e2e-m5-audit.py:534` + 本脚本 `EP-I09` |
| 表单升版后旧草稿提交是否报 `40025` | **不报**。`submit` 不比对 `form_version`;按**当前** schema 校验;库里 `form_version` 保持建单时快照 | 本脚本 `EP-J02b/J02c/J02d` |
| 并发提交的实际行为 | **无保护**,两个请求都能拿到 `submitted=true`,落库状态仍自洽 | 本脚本 `EP-J03`(钉成已知事实) |
| 任务已截止/暂停时能否核验身份 | **能**(门禁与任务状态**有意解耦**),但紧随其后的取表单会被 `40008` 拦住 | 本脚本 `EP-C08b/C08c` |
| `VAL-17` 的「非阿拉伯数字」分支能否触发 | **触发不了**:`collect_submission.work_years` 是 `smallint`,「三年」在写库前就被类型挡住 → 该分支是死代码 | 本脚本 `EP-G11a`(钉住事实) |
| 扩展字段能不能存、能不能读 | **存得进、读不出**(F-128) | 本脚本 `EP-D09/EP-J02e`(XFAIL) |
---
## 七、与《06-验收测试清单》的映射
《06》是**客户向**清单,含 M4 与已取消二期的条目,本计划**不改它**,只在此建立对应关系。
| 《06》条目 | 本计划 | 说明 |
|---|---|---|
| §3.1 主流程 A(A1~A23) | A/B/C 组 + chain 组 | 其中 A10/A11(材料移动)、A6(`changedFields` 记 phone)依赖 M4,**暂无自动验收** |
| §3.4 异常边界 E1~E27 | §3 各组的错误用例 | E5/E16/E17/E18/E24/E25/E26/E27 属 M4(OCR/归属),**缓做**;E21~E23 随「加入申请」撤回一并作废 |
| §3.2 主流程 B(社会收集) | — | 已作废(`40044`) |
| §3.3 主流程 C(历史整理器) | — | 已取消 |
| §4 功能验收 F-500 / F-800 | §3.9 / §3.10 | 名册与审核侧 |
| §2 Golden Assertions(G-*) | 不在本计划 | 那是引擎级纯函数,用 112 个真实文件做断言,与链路无关 |
---
## 八、变更记录
| 版本 | 日期 | 变更 |
|---|---|---|
| V1.0 | 2026-10 | 首版。覆盖矩阵 40 条本脚本用例 + 约 55 条既有脚本用例;登记 F-125 / F-126 / F-127 |
| V1.1 | 2026-10 | 按实测结果回填:本脚本用例 40 → **45**;新增 **F-128**(扩展字段存得进读不出,必填扩展字段永久阻断,P0)与 **F-129**(扩展字段塞进 `formData` 被静默丢弃);F-126 已补断言;「待定案」六项**全部定案**;三种跑法的实测数字回填 §3.12 / §5.2 |
| V1.2 | 2026-10 | 补 L3-B 真实浏览器走查结论(§5.3):走了 4 条里的 3 条,**F-125 的用户侧证据链拿到**(「保存失败:…请先撤回」+ 同一屏没有撤回入口 + 列表页按钮「修改后重新提交」);记下两个走查坑(scene 会进本地缓存、`--fixture-only` 不触发清理)与新增工具 `scripts/dev-reveal-roster-idcard.py` |
| V1.3 | 2026-10 | **新增 §5.4 L3-C 微信小程序端走查**(需求方要求「学生提交的过程要测微信小程序端」):在开发者工具模拟器里把 10 步走完,**除登录外整条链路都通**(落地页 / 门禁 / 表单 / 前端压缩上传 / 红单 / 黄单确认 / 提交后名册流转);登记 **F-130(P0,小程序端登录断成两截:前端从不调 `/mp/login` + 后端 `MpLoginService.java:127` 匿名查租户表被拦截 → 50000)**;§5.3 第 2 条由 L3-C 补上。**本轮不改业务代码** |