feat(material): M1-04 材料项定义与内置模板

发布前置校验里最后缺的一项(40018「未配置必传材料项」)终于能配了。
按计划表这一项是后端(4 人日),材料规则的界面属 M1-09。

交付:
· 材料项 CRUD(《03-接口设计》§7.10):GET/POST /tasks/{id}/materials、
  PUT/DELETE /materials/{id}
· 三个内置模板(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等应用
· GET /material-catalog:材料码/档位/格式白名单/模板的静态清单,
  供界面下拉使用(接口是契约外的,理由见 Controller 注释:把这份常量在前端
  再抄一份会造成两份真相,而别名正是识别历史文件名用的)

落在代码里的三件事:
1) 默认值按两个维度分别取(MaterialCatalog):
   · 按材料码定 名字/别名/默认压缩档/份数/是否合并
   · 按压缩档定 固定尺寸(P1)/长边下限(P2)/目标DPI(P3)/输出格式
   把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",
   而"身份证要正反面"是这件材料的事实,不是档位的规则。
2) WORK_PROOF 的名字就是「工作证明」。机构模板里「职业技能等级个人申报表」
   的规范文件名就是「工作证明」,这是机构既有习惯,不要"修正" —— 改了会让
   导出包与该机构一直以来的归档口径对不上,带教老师只认这个名字。
3) 关联键是 (task_id, material_code),不是 material_def_id(表里没有那个外键)。
   所以 40027「已有文件不可删」必须按码查 —— 用 id 查会永远返回 0,
   这道校验形同虚设,后果是"删了材料定义,文件成了孤儿"。

本轮修掉的 2 个真问题:
1) NOT NULL DEFAULT 与"档位不适用就置空"打架(同一类 bug 的第二次):
   min_long_side(NOT NULL DEFAULT 1400)与 output_format(NOT NULL DEFAULT 'jpg')
   在切档位时被写成 null,PostgreSQL 直接 500。而更根本的是:契约 §3.4 明确写着
   outputFormat 的 null = 保持原格式 —— 这一列本就不该是 NOT NULL。
   已由 V5__material_profile_params_nullable.sql 把四个档位参数统一成
   "不适用时为空"(另两个本来就可空),并同步更正两份 DDL 文档。
   ⚠️ 这与上一轮的 expected_count 是同一类问题:NOT NULL DEFAULT x 看起来无害,
   但它让"未设置/不适用"无法表达,而契约往往又要求能表达。
   动"可选数字/字符串"字段前,先查 information_schema.columns 的 is_nullable。
2) 我的两条 E2E 断言本身是坏的:
   · 把 lower_case 断言成"应被拒" —— 实际服务端把它规范化成 LOWER_CASE 后
     接受了,这是对输入友好的正确行为,错的是断言;
   · "改档位后参数跟着变"用了 P3,而 OTHER_PROOF 的默认档本来就是 P3,
     于是无论改没改都会通过(假通过)。
   教训:断言要能真的失败。

顺带整理:
· 抽出 support/NamingTemplate:任务级 naming_template 与材料级 name_pattern
  必须共用同一套校验规则。两处各写一套会出现"任务级认的变量材料级不认",
  而这类不一致的表现是"发布时通过、导出时文件名叫错",最难在测试里撞上。
  TaskService 已改为委托。
· materialTemplateId(契约里是 int64)不再空转:§7.10 的三个模板在文档与代码里
  都是具名常量,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
  新增 materialTemplateCode(字符串)承接内置模板,materialTemplateId 留给
  M1-05 的 collect_rule_template(那是真正的一行一条,id 才有意义)。

已知缺口(写进了代码注释与交接摘要):
· minSizeKb 恒为 null:契约 §3.4 给了字段但"对应列"是 —,而《04》又用到
  materialDef.minSizeKb,两侧口径没对齐;且具体阈值属待办 D5「一寸照片阈值标定」,
  没有实测数据。编一个数会让它看起来"已实现",卡住真材料时没人说得清为什么。
· 条件显示只做存取与形状校验,求值在填报引擎 M3-10。
· 40030(审核中不可删材料)属 M5,当前删除只查 40027。

验收:e2e-m1-materials.py 68/68;五条 E2E 合计 278 项断言全通过;
Flyway 迁移 1~5 全部 success。
This commit is contained in:
2026-09-29 09:42:08 +08:00
parent 7523ebe789
commit d32969d766
15 changed files with 1860 additions and 55 deletions
+4 -4
View File
@@ -211,11 +211,11 @@ CREATE TABLE collect_material_def (
file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png',
max_size_kb integer NOT NULL DEFAULT 1024,
compress_profile varchar(8) NOT NULL DEFAULT 'AUTO',
min_long_side integer NOT NULL DEFAULT 1400,
min_long_side integer,
min_quality smallint NOT NULL DEFAULT 70,
target_dpi integer,
fixed_size varchar(32),
output_format varchar(16) NOT NULL DEFAULT 'jpg',
output_format varchar(16),
max_count smallint NOT NULL DEFAULT 1,
merge_enabled smallint NOT NULL DEFAULT 0,
ocr_type varchar(32),
@@ -240,11 +240,11 @@ COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式';
COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单';
COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB';
COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3';
COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)';
COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(长边px),仅 P2 证件翻拍档生效;其余档位为 NULL 表示不适用';
COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受';
COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)';
COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)';
COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png';
COMMENT ON COLUMN collect_material_def.output_format IS '输出格式;NULL 表示保持原格式。P4 截图档固定 png';
COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2';
COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张';
COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE';
+48 -1
View File
@@ -49,8 +49,9 @@
| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** |
| **M1-04 材料项定义** | ✅ 材料项 CRUD(§7.10)+ 三个内置模板(`TPL_SCHOOL_ROLL`/`TPL_SOCIAL`/`TPL_ZHUANXIANG`)+ 材料目录接口;**`e2e-m1-materials.py` → 68/68** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
| 待做 | M1 剩余(M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
| 待做 | M1 剩余(M1-03 表单 schema、M1-05 规则模板、M1-06 二维码、**M1-07~M1-10 前端**——材料规则的界面按计划属 **M1-09**);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
---
@@ -219,6 +220,52 @@
---
## 一·七、M1-04 材料项定义(2026-09-29 上午,未提交)
> 范围:`GET/POST /tasks/{id}/materials`、`PUT/DELETE /materials/{id}`(《03-接口设计》§7.10)、
> 三个内置材料模板、材料目录接口 `GET /material-catalog`。
> **按计划表,材料规则的界面属 M1-09(6 人日,前端),本轮只做后端。**
**验收**:`python scripts/e2e-m1-materials.py` → **68/68**;
其余四条回归通过(`e2e-m0.py` 36 + `e2e-m0-user.py` 55 + `e2e-m1-task.py` 71 + `e2e-m1-orgs.py` 48),
合计 **278 项断言**。
### 落在代码里的三件事
| # | 事 | 说明 |
|:--:|---|---|
| 1 | **默认值按两个维度分别取** | 见 `MaterialCatalog`:**按材料码**定名字/别名/默认压缩档/**份数与是否合并**;**按压缩档**定固定尺寸(P1)/长边下限(P2)/目标 DPI(P3)/输出格式。把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",而"身份证要正反面"是**这件材料的事实**,不是档位的规则 |
| 2 | **`WORK_PROOF` 的名字是「工作证明」** | 机构模板里「职业技能等级个人申报表」的规范文件名就是「工作证明」。这是机构既有习惯,**不要"修正"** —— 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上 |
| 3 | **关联键是 `(task_id, material_code)`** | `collect_file` 表里**没有** `material_def_id`。所以:①「已有文件不可删」(`40027`)必须按码查,用 id 查会永远返回 0、这道校验形同虚设;②修改**不允许改码**(改了等于把已有文件迁到别的材料名下) |
### 本轮修掉的 2 个真问题
| # | 问题 | 说明 |
|:--:|---|---|
| 1 | **`NOT NULL DEFAULT` 与"档位不适用就置空"打架(同一类 bug 第二次)** | `min_long_side`(`NOT NULL DEFAULT 1400`)与 `output_format`(`NOT NULL DEFAULT 'jpg'`)在切档位时被写成 null → PostgreSQL 报 `null value in column "min_long_side" violates not-null constraint`,接口 500。而更根本的是:**契约 §3.4 明确写着 `outputFormat` 的 `null` = 保持原格式** —— 这一列本就不该是 NOT NULL。已由 `V5__material_profile_params_nullable.sql` 把四个档位参数统一成"**不适用时为空**"(另外两个 `target_dpi`/`fixed_size` 本来就可空),并同步更正两份 DDL 文档。<br>⚠️ **这与 `expected_count`(§一·六)是同一类问题**:`NOT NULL DEFAULT x` 看起来无害,但它让"未设置/不适用"无法表达,而契约往往又要求能表达。**动"可选数字/字符串"字段前,先查 `information_schema.columns` 的 `is_nullable`,不要只读 DDL** |
| 2 | **我的两条 E2E 断言是坏的** | ①把 `lower_case` 断言成"应被拒"——实际上服务端把它规范化成 `LOWER_CASE` 后接受了,这是**对输入友好**的正确行为,错的是断言;②"改档位后参数跟着变"用了 `P3`,而 `OTHER_PROOF` 的默认档本来就是 `P3`,于是**无论改没改都通过**(假通过)。教训:**断言要能真的失败** —— 换档位要换到与原值不同的档位,才测得出东西 |
### 顺带做的两处整理
- **抽出 `support/NamingTemplate`**:任务级 `naming_template` 与材料级 `name_pattern` 用**同一套**校验规则。
两处各写一套会出现"任务级认的变量材料级不认",而这类不一致的表现是
**发布时通过、导出时文件名叫错**,最难在测试里撞上。`TaskService` 已改为委托。
- **`materialTemplateId`(契约里是 int64)不再空转**:§7.10 的三个模板在文档与代码里都是
**具名常量**,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
所以新增 `materialTemplateCode`(字符串)承接这三个内置模板,
`materialTemplateId` 留给 M1-05 的 `collect_rule_template`(那是真正的一行一条,id 才有意义)。
### 已知缺口(有意留着)
| 项 | 说明 |
|---|---|
| **`minSizeKb` 恒为 `null`** | 契约 §3.4 给了这个字段但"对应列"写的是 `—`(没有列),而《04-核心模块设计说明》又确实用到了 `materialDef.minSizeKb` —— 两侧口径没对齐。而且**具体阈值属于待办 **D5「一寸照片阈值标定」**,还没有实测数据**。编一个数(比如 30KB)会让它看起来"已实现",但那个数没有依据,卡住真材料时没人说得清为什么。等 D5 出结果再接 |
| **材料规则的界面未做** | 按计划表属 **M1-09(材料规则配置,6 人日,前端)**。所以**目前材料清单只能通过接口配置**,界面上还没有入口 —— 也就是说**发布仍无法在界面上走通**(还差材料清单与名册两块) |
| 条件显示的求值引擎 | 本轮只做规则的存取与形状校验(`field` 必填);真正的求值在填报引擎 M3-10 |
| `40030`(审核中不可删材料) | 属 M5,当前删除只查 `40027`(已有文件) |
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
+4 -4
View File
@@ -1669,11 +1669,11 @@ CREATE TABLE collect_material_def (
file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png',
max_size_kb integer NOT NULL DEFAULT 1024,
compress_profile varchar(8) NOT NULL DEFAULT 'AUTO',
min_long_side integer NOT NULL DEFAULT 1400,
min_long_side integer,
min_quality smallint NOT NULL DEFAULT 70,
target_dpi integer,
fixed_size varchar(32),
output_format varchar(16) NOT NULL DEFAULT 'jpg',
output_format varchar(16),
max_count smallint NOT NULL DEFAULT 1,
merge_enabled smallint NOT NULL DEFAULT 0,
ocr_type varchar(32),
@@ -1698,11 +1698,11 @@ COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式';
COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单';
COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB';
COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3';
COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)';
COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(长边px),仅 P2 证件翻拍档生效;其余档位为 NULL 表示不适用';
COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受';
COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)';
COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)';
COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png';
COMMENT ON COLUMN collect_material_def.output_format IS '输出格式;NULL 表示保持原格式。P4 截图档固定 png';
COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2';
COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张';
COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE';