# 填报流程端到端测试计划 > 版本: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 # 只清理指定的夹具任务 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 # 只读,只打印;解密方式见 AesGcmCipher ``` ⚠️ `--fixture-only` 不会触发清理(那是给手工排查用的),走查完要自己收尾: 删任务(`--clean `,或按 `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 ` 取门禁要手输的明文姓名/身份证。 **实测步骤与结果**(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 补上。**本轮不改业务代码** |