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

57 KiB
Raw Permalink Blame History

填报流程端到端测试计划

版本: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 缓做 一期不做,不写用例

⚠️ 「既有脚本」这一列是核对出来的,不是抄脚本自述的。 核对规程:

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 命令行参数

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 机制

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 前置

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 比源码新」不算证据。启动后必须先探活,再跑一条已知脚本冒烟:

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 主流程

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 里清,但失败路径值得再看一眼):

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 浏览器走查(加分项)

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);查别的任务用:

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 补上。本轮不改业务代码