From d32969d766967098903c15f9e86c15deffafe75c Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 09:42:08 +0800
Subject: [PATCH] =?UTF-8?q?feat(material):=20M1-04=20=E6=9D=90=E6=96=99?=
=?UTF-8?q?=E9=A1=B9=E5=AE=9A=E4=B9=89=E4=B8=8E=E5=86=85=E7=BD=AE=E6=A8=A1?=
=?UTF-8?q?=E6=9D=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
发布前置校验里最后缺的一项(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。
---
.../controller/MaterialDefController.java | 127 +++++
.../material/dto/MaterialDefSaveReq.java | 98 ++++
.../module/material/dto/MaterialDefVO.java | 95 ++++
.../material/entity/CollectMaterialDef.java | 96 ++++
.../material/mapper/MaterialDefMapper.java | 26 +
.../material/service/MaterialCatalog.java | 191 +++++++
.../material/service/MaterialDefService.java | 516 ++++++++++++++++++
.../fill/module/task/dto/TaskUpsertReq.java | 17 +-
.../fill/module/task/service/TaskService.java | 59 +-
.../fill/support/NamingTemplate.java | 76 +++
.../V5__material_profile_params_nullable.sql | 56 ++
docs/db/schema-postgresql.sql | 8 +-
docs/交接摘要.md | 49 +-
docs/数据信息填报系统PRD.md | 8 +-
scripts/e2e-m1-materials.py | 493 +++++++++++++++++
15 files changed, 1860 insertions(+), 55 deletions(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefSaveReq.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefVO.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/entity/CollectMaterialDef.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/mapper/MaterialDefMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialCatalog.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialDefService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/NamingTemplate.java
create mode 100644 backend/xt-server/src/main/resources/db/migration/V5__material_profile_params_nullable.sql
create mode 100644 scripts/e2e-m1-materials.py
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
new file mode 100644
index 0000000..4ff0fcb
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
@@ -0,0 +1,127 @@
+package cn.tianbaotong.fill.module.material.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.module.material.dto.MaterialDefSaveReq;
+import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
+import cn.tianbaotong.fill.module.material.service.MaterialCatalog;
+import cn.tianbaotong.fill.module.material.service.MaterialDefService;
+import cn.tianbaotong.fill.security.PermissionDef;
+import cn.tianbaotong.fill.security.RequirePerm;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 材料项定义(《03-接口设计》§7.10)。
+ *
+ *
路径分两段:任务作用域的用 {@code /tasks/{id}/materials},单项操作用
+ * {@code /materials/{id}} —— 与契约一致。一个 Controller 承载两段路径,
+ * 是为了让"材料"这件事在一个文件里能看全,而不是按 URL 前缀硬拆成两个类。
+ */
+@Tag(name = "材料项定义")
+@RestController
+@RequestMapping("/api/v1")
+@RequiredArgsConstructor
+public class MaterialDefController {
+
+ private final MaterialDefService materialDefService;
+
+ @GetMapping("/tasks/{id}/materials")
+ @RequirePerm(PermissionDef.TASK_VIEW)
+ @Operation(summary = "任务的材料项列表")
+ public ApiResult> list(@PathVariable Long id) {
+ return ApiResult.ok(materialDefService.list(id));
+ }
+
+ @PostMapping("/tasks/{id}/materials")
+ @RequirePerm(PermissionDef.TASK_MANAGE)
+ @Operation(summary = "新增材料项",
+ description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
+ + "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
+ public ApiResult create(@PathVariable Long id,
+ @Valid @RequestBody MaterialDefSaveReq req) {
+ return ApiResult.ok(materialDefService.create(id, req));
+ }
+
+ @PutMapping("/materials/{id}")
+ @RequirePerm(PermissionDef.TASK_MANAGE)
+ @Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
+ public ApiResult update(@PathVariable Long id,
+ @Valid @RequestBody MaterialDefSaveReq req) {
+ materialDefService.update(id, req);
+ return ApiResult.ok();
+ }
+
+ @DeleteMapping("/materials/{id}")
+ @RequirePerm(PermissionDef.TASK_MANAGE)
+ @Operation(summary = "删除材料项",
+ description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
+ + "请设为选传或加条件显示规则隐藏")
+ public ApiResult delete(@PathVariable Long id) {
+ materialDefService.delete(id);
+ return ApiResult.ok();
+ }
+
+ @PostMapping("/tasks/{id}/materials/apply-template")
+ @RequirePerm(PermissionDef.TASK_MANAGE)
+ @Operation(summary = "按内置模板初始化材料清单",
+ description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
+ public ApiResult