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
@@ -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)。
*
* <p>路径分两段:任务作用域的用 {@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<MaterialDefVO>> 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<Long> 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<Void> 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<Void> 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<Map<String, Object>> applyTemplate(@PathVariable Long id,
@RequestBody Map<String, String> body) {
String templateCode = body == null ? null : body.get("templateCode");
int created = materialDefService.applyTemplate(id,
templateCode == null ? "" : templateCode.trim().toUpperCase());
Map<String, Object> out = new LinkedHashMap<>();
out.put("createdCount", created);
return ApiResult.ok(out);
}
/**
* 材料目录 —— 界面下拉用的静态清单(材料类型、压缩档位、格式白名单、内置模板)。
*
* <p>契约里没有这个接口,但界面需要这份清单:材料码要不要给用户选?
* 给了就必须有名称与默认档,否则用户得先记住 {@code SCHOOL_ROLL} 是学籍表。
* 把这些常量<b>在前端再抄一份</b>会造成两份真相,迟早不一致(服务端改了别名、
* 界面还是老的,而别名正是识别历史文件名用的)。
*/
@GetMapping("/material-catalog")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "材料目录(材料类型 / 档位 / 格式白名单 / 内置模板)")
public ApiResult<Map<String, Object>> catalog() {
Map<String, Object> out = new LinkedHashMap<>();
List<Map<String, Object>> codes = new java.util.ArrayList<>();
for (String code : MaterialCatalog.allCodes()) {
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
Map<String, Object> m = new LinkedHashMap<>();
m.put("code", code);
m.put("name", kind.name());
m.put("aliases", kind.aliases());
m.put("defaultProfile", kind.profile());
m.put("defaultMaxCount", kind.maxCount());
m.put("defaultMerge", kind.merge());
codes.add(m);
}
out.put("codes", codes);
out.put("profiles", List.of("AUTO", "P1", "P2", "P3", "P4", "P5"));
out.put("fileTypes", MaterialCatalog.FILE_TYPES.stream().sorted().toList());
out.put("ocrTypes", MaterialCatalog.OCR_TYPES.stream().sorted().toList());
out.put("templates", MaterialCatalog.templateOptions());
out.put("maxSizeRange", List.of(MaterialCatalog.MAX_SIZE_MIN_KB,
MaterialCatalog.MAX_SIZE_MAX_KB));
return ApiResult.ok(out);
}
}
@@ -0,0 +1,98 @@
package cn.tianbaotong.fill.module.material.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 材料项新增 / 修改请求(《03-接口设计》§7.10)。
*
* <p>只列<b>需要客户端传</b>的字段。压缩相关的参数({@code minLongSide} /
* {@code targetDpi} / {@code fixedSize} / {@code minQuality})不在这里 ——
* 它们的默认值由<b>压缩档位</b>决定(见 {@code MaterialCatalog}),
* 由服务端按档位补齐。让界面同时填"档位"和"档位参数"是自相矛盾的:
* 选了 P1 却把 fixedSize 填成别的尺寸,系统该听谁的?
* 二期真要开放这些,应该做成"高级设置"并明确它们只在对应档位生效。
*/
@Getter
@Setter
@Schema(description = "材料项新增或修改")
public class MaterialDefSaveReq {
/**
* 材料码。
*
* <p>修改时<b>忽略</b>(码是文件的关联键,改了等于把文件迁到另一个材料下)。
* 想换码请删掉重建 —— 而且删之前要先确认没有文件({@code 40027})。
*/
@NotBlank(message = "请选择材料类型")
@Size(max = 32, message = "材料码过长")
@Schema(description = "材料码,见 §2.1;修改时忽略",
example = "IDCARD", requiredMode = Schema.RequiredMode.REQUIRED)
private String materialCode;
@NotBlank(message = "请填写材料名称")
@Size(max = 64, message = "材料名称过长")
@Schema(description = "规范材料名(学生端与导出文件名都用它)", example = "身份证",
requiredMode = Schema.RequiredMode.REQUIRED)
private String materialName;
@Schema(description = "别名,用于从历史文件名识别")
private List<String> aliases = new ArrayList<>();
@Size(max = 256, message = "命名模板过长")
@Schema(description = "命名模板;留空继承任务级", example = "{姓名}-{证件号}-{材料名}")
private String namePattern;
@Schema(description = "是否必传,默认 true")
private Boolean required;
@Schema(description = "条件显示规则;null 表示无条件显示",
example = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}")
private Map<String, Object> visibleWhen;
@Schema(description = "格式白名单,默认 [\"jpg\",\"jpeg\",\"png\"]")
private List<String> fileTypes;
@Min(value = 307, message = "体积上限不能低于 307KB(0.3M)")
@Max(value = 5120, message = "体积上限不能高于 5120KB(5M)")
@Schema(description = "体积上限(KB),默认 1024;可配范围 307~5120(PRD FR-605)")
private Integer maxSizeKb;
@Schema(description = "压缩档位:AUTO/P1/P2/P3/P4/P5,默认 AUTO(自动判定)", example = "P2")
private String compressProfile;
@Size(max = 32, message = "固定尺寸格式应为 295x413")
@Schema(description = "固定尺寸,格式 {@code 宽x高};仅 P1 生效", example = "295x413")
private String fixedSize;
@Min(value = 1, message = "最多可传份数至少为 1")
@Max(value = 10, message = "最多可传份数最多 10 份")
@Schema(description = "最多可传份数,默认 1;身份证正反面为 2")
private Integer maxCount;
@Schema(description = "多份是否合并为一份,默认 false")
private Boolean mergeEnabled;
@Schema(description = "OCR 类型:IDCARD/GENERAL/NONE")
private String ocrType;
@Size(max = 512, message = "示例图地址过长")
@Schema(description = "示例图")
private String sampleUrl;
@Size(max = 512, message = "提示语过长")
@Schema(description = "来源引导语,显示在学生端", example = "拍身份证人像面,四角要拍全")
private String tipText;
@Schema(description = "排序号,越小越靠前")
private Integer sortNo;
}
@@ -0,0 +1,95 @@
package cn.tianbaotong.fill.module.material.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 材料项定义(《03-接口设计》§3.4)。
*
* <p>⚠️ 三个字段在库里是字符串、在这里是结构化值,转换在 Service 里做:
* <ul>
* <li>{@code aliases}:库里逗号分隔 → VO 里数组</li>
* <li>{@code visibleWhen}:库里 JSON 字符串 → VO 里对象</li>
* <li>{@code fixedSize}:库里 {@code "295x413"} → VO 里 {@code {width, height}}</li>
* </ul>
* 反过来也一样。让接口保留结构化的形状是值得的:界面要按它的类型渲染,
* 而不是自己 split 字符串 —— 那样每多一个调用方就多一份解析逻辑,迟早不一致。
*/
@Getter
@Setter
@Schema(description = "材料项定义")
public class MaterialDefVO {
private Long id;
/** 材料码,见《03-接口设计》§2.1。任务内唯一,且是文件的关联键。 */
private String materialCode;
private String materialName;
/** 别名集合,用于从历史文件名识别材料种类。 */
private List<String> aliases = new ArrayList<>();
/** 命名模板;为空则继承任务级。 */
private String namePattern;
private Boolean required;
/** 条件显示规则,如 {@code {"field":"is_student","op":"eq","value":true}};null 表示无条件显示。 */
private Map<String, Object> visibleWhen;
private List<String> fileTypes = new ArrayList<>();
private Integer maxSizeKb;
/**
* 体积下限保护:低于此值提示"可能不是原图"。
*
* <p>⚠️ <b>当前恒为 {@code null},即"未启用下限检查"</b>,这不是漏了实现:
* 《03-接口设计》§3.4 给这一行的"对应列"写的是 {@code —}(没有列),
* 而 {@code 04-核心模块设计说明} 又确实用到了 {@code materialDef.minSizeKb}。
* 两侧口径没对齐,且**具体阈值属于待办事项 D5「一寸照片阈值标定」,
* 还没有实测数据支撑**。编一个数(比如 30KB)会让它看起来"已实现",
* 但那个数没有任何依据,卡住真材料时没人说得清为什么。
* 所以这里诚实地返回 null,等 D5 出结果再接。
*/
private Integer minSizeKb;
/** 压缩档位:{@code AUTO} / {@code P1}~{@code P5},见 §6.3。 */
private String compressProfile;
/** 分辨率下限(长边 px)。仅 P2 证件翻拍档生效。 */
private Integer minLongSide;
/** 压缩质量下限。低于 65 视为不可接受。 */
private Integer minQuality;
/** 目标 DPI。仅 P3 扫描件档生效。 */
private Integer targetDpi;
/** 固定像素尺寸,如 {@code {"width":295,"height":413}}。仅 P1 证件照档生效。 */
private Map<String, Object> fixedSize;
/** 输出格式;null 表示保持原格式。 */
private String outputFormat;
private Integer maxCount;
/** 是否允许多份合并为一份(如身份证正反面)。 */
private Boolean mergeEnabled;
/** {@code IDCARD} / {@code GENERAL} / {@code NONE}。 */
private String ocrType;
private String sampleUrl;
/** 来源引导语,显示在学生端。 */
private String tipText;
private Integer sortNo;
}
@@ -0,0 +1,96 @@
package cn.tianbaotong.fill.module.material.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 材料项定义(对应 {@code collect_material_def})。
*
* <p>一行 = 这次收集要求学生交的<b>一种</b>材料,以及它的全部规则:
* 怎么命名、必不必传、什么条件下才出现、格式与体积限制、压到哪一档。
*
* <p>⚠️ <b>{@code materialCode} 在任务内唯一</b>({@code uk_task_code UNIQUE (task_id, material_code)})。
* 而且 {@code collect_file} 是用 <b>(task_id, material_code)</b> 关联材料的 ——
* 它没有 {@code material_def_id}。这意味着:
* <ul>
* <li>删掉材料定义再新建一个同码的,<b>会"继承"原来那批文件</b>。
* 这是对的(文件属于这个码,不属于某一行配置),但改码必须当成"迁移"来对待。</li>
* <li>"这个材料有没有文件"只能按 {@code task_id + material_code} 查 ——
* 判断能不能删({@code 40027})时用的是这个口径。</li>
* </ul>
*
* <p>⚠️ <b>压缩档位与"档位对应的参数"是两回事</b>:
* {@code compress_profile} 决定<b>怎么压</b>(P1 证件照固定尺寸 / P2 翻拍保分辨率 /
* P3 扫描件按 DPI / P4 截图保 PNG / P5 兜底),而
* {@code min_long_side} / {@code target_dpi} / {@code fixed_size} 只在<b>对应档位下才生效</b>
* (DDL 注释逐条写明:"仅 P2 生效"、"仅 P3 生效"、"仅 P1")。
* 所以这些字段的默认值即使留着,也不会影响别的档位 —— 不必为了"干净"把它们清空,
* 清空反而会让切换到那个档位时丢掉用户填过的值。
*/
@Getter
@Setter
@TableName("collect_material_def")
public class CollectMaterialDef extends TenantEntity {
private Long taskId;
/** 材料码,如 {@code IDCARD} / {@code PHOTO1}。任务内唯一,且是文件的关联键。 */
private String materialCode;
/** 规范材料名,如「身份证」。学生端与导出文件名都用它。 */
private String materialName;
/** 别名,逗号分隔。用于从历史文件名识别这是哪种材料。 */
private String aliases;
/** 命名模板。为空则继承任务级 {@code collect_task.naming_template}。 */
private String namePattern;
/** {@code 1} 必传 / {@code 0} 选传。 */
private Integer required;
/** 条件显示表达式,如 {@code {"field":"is_student","op":"eq","value":true}}。 */
private String visibleWhen;
/** 格式白名单,逗号分隔,如 {@code jpg,jpeg,png}。 */
private String fileTypes;
/** 体积上限(KB)。 */
private Integer maxSizeKb;
/** 压缩档位:{@code AUTO} / {@code P1}~{@code P5}。 */
private String compressProfile;
/** 分辨率下限(长边 px)。<b>仅 P2 证件翻拍档生效。</b> */
private Integer minLongSide;
/** 压缩质量下限。<b>低于 65 视为不可接受</b>(DDL 注释口径)。 */
private Integer minQuality;
/** 目标 DPI。<b>仅 P3 扫描件档生效</b>,建议 150~300。 */
private Integer targetDpi;
/** 固定像素尺寸,如 {@code 295x413}。<b>仅 P1 证件照档生效。</b> */
private String fixedSize;
/** 输出格式;{@code null} 表示保持原格式。P4 截图档固定输出 png。 */
private String outputFormat;
/** 允许上传的份数。身份证正反面是 2。 */
private Integer maxCount;
/** {@code 1} 多份合并为一张 / {@code 0} 不合。 */
private Integer mergeEnabled;
/** OCR 类型:{@code IDCARD} / {@code GENERAL} / {@code NONE}。 */
private String ocrType;
private String sampleUrl;
/** 来源引导语,显示在学生端("去哪儿找这份材料")。 */
private String tipText;
private Integer sortNo;
}
@@ -0,0 +1,26 @@
package cn.tianbaotong.fill.module.material.mapper;
import cn.tianbaotong.fill.module.material.entity.CollectMaterialDef;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface MaterialDefMapper extends BaseMapper<CollectMaterialDef> {
/**
* 某个材料项下已经有多少个文件 —— 用于删除前置校验({@code 40027})。
*
* <p>⚠️ <b>关联键是 {@code (task_id, material_code)},不是 {@code material_def_id}</b>:
* {@code collect_file} 表里根本没有指向材料定义行的外键,只有材料码。
* 所以这里不能用 id 去查 —— 用 id 查会永远返回 0,等于这道校验形同虚设,
* 而后果是"删了材料定义,文件成了孤儿"(文件还在,但界面上再也看不到入口)。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM collect_file
WHERE task_id = #{taskId} AND material_code = #{materialCode} AND deleted = 0
""")
Long countFiles(@Param("taskId") Long taskId,
@Param("materialCode") String materialCode);
}
@@ -0,0 +1,191 @@
package cn.tianbaotong.fill.module.material.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 材料目录:把《03-接口设计》§2.1(材料类型表)、§7.10(材料项模板)与
* §6.3(压缩档位)落到一处,作为新建材料项时的默认值来源。
*
* <p>为什么集中在常量类里而不是散在 Service 的分支里:这些默认值来自<b>文档的表格</b>,
* 是"机构既有习惯"的一部分(比如「工作证明」这个名字,见下),
* 一旦散进代码分支,改一处漏一处,就会出现"从模板加的身份证是 P2、手动加的是 AUTO"。
*
* <p><b>两个维度要分清</b>:
* <ul>
* <li>{@link Kind}:<b>按材料码</b>定的默认 —— 名字、别名、默认压缩档,
* 以及<b>份数与是否合并</b>(身份证正反面要 2 份并合并成一份,
* 这是「身份证」这件材料的事实,不是「P2 档」的规则)。</li>
* <li>{@link #profileDefaults}:<b>按压缩档</b>定的默认 —— 固定尺寸(P1)、
* 分辨率下限(P2)、目标 DPI(P3)、输出格式。DDL 注释逐条写了"仅某档生效"。</li>
* </ul>
* 把份数塞进档位默认里是很容易犯的错:那样所有 P2 材料都会变成"要传 2 份"。
*/
public final class MaterialCatalog {
private MaterialCatalog() {
}
/**
* 一种材料类型的固有属性(《03-接口设计》§2.1)。
*
* @param code 材料码,任务内唯一,且是 {@code collect_file} 的关联键
* @param name 规范材料名
* @param aliases 别名,用于从历史文件名识别材料种类
* @param profile 默认压缩档
* @param maxCount 允许上传份数
* @param merge 多份是否合并为一份
*/
public record Kind(String code, String name, List<String> aliases, String profile,
int maxCount, boolean merge) {
}
/**
* §2.1 的表,逐行照抄。
*
* <p>⚠️ <b>{@code WORK_PROOF} 的名称陷阱</b>:机构模板里「职业技能等级个人申报表」
* 的规范文件名就是「<b>工作证明</b>」。这是机构既有习惯,**不要"修正"它** ——
* 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上,
* 而带教老师只认"工作证明"这个名字。
*/
private static final List<Kind> KINDS = List.of(
new Kind("PHOTO1", "一寸照片",
List.of("一寸电子照片", "证件照", "免冠照", "1寸照片"), "P1", 1, false),
new Kind("IDCARD", "身份证",
List.of("身份证正面", "身份证反面", "身份证人像面"), "P2", 2, true),
new Kind("SCHOOL_ROLL", "学籍表",
List.of("学籍证明", "在读证明", "在校证明"), "P4", 1, false),
new Kind("XJW_BA", "学信网备案表",
List.of("学信网", "学历备案表", "电子注册备案表"), "P4", 1, false),
new Kind("WORK_PROOF", "工作证明",
List.of("个人申报表", "职业技能等级个人申报表"), "P3", 1, false),
new Kind("OTHER_PROOF", "其它佐证材料",
List.of("其他", "补充材料"), "P3", 1, false));
private static final Map<String, Kind> BY_CODE = new LinkedHashMap<>();
static {
for (Kind kind : KINDS) {
BY_CODE.put(kind.code(), kind);
}
}
/** 全部已知材料码,按 §2.1 的顺序。 */
public static List<String> allCodes() {
return KINDS.stream().map(Kind::code).toList();
}
public static Kind of(String code) {
return code == null ? null : BY_CODE.get(code);
}
/** 压缩档位全集。 */
public static final Set<String> PROFILES = Set.of("AUTO", "P1", "P2", "P3", "P4", "P5");
/** 格式白名单全集(§3.4 的示例就是这五个)。 */
public static final Set<String> FILE_TYPES = Set.of("jpg", "jpeg", "png", "pdf", "heic");
/** OCR 类型全集。 */
public static final Set<String> OCR_TYPES = Set.of("IDCARD", "GENERAL", "NONE");
/** 体积上限的可配范围(PRD FR-605:材料级 0.3M~5M)。 */
public static final int MAX_SIZE_MIN_KB = 307;
public static final int MAX_SIZE_MAX_KB = 5120;
/** 压缩质量下限:低于 65 视为不可接受(DDL 注释口径)。 */
public static final int MIN_QUALITY_FLOOR = 65;
public static final int MIN_QUALITY_DEFAULT = 70;
/** 压缩档位的默认参数。 */
public record ProfileDefaults(String fixedSize, Integer minLongSide, Integer targetDpi,
String outputFormat) {
}
private static final Map<String, ProfileDefaults> PROFILE_DEFAULTS = Map.of(
// P1 证件照:固定 295x413(一寸),输出 jpg
"P1", new ProfileDefaults("295x413", null, null, "jpg"),
// P2 证件翻拍:不缩放,只保证长边不低于 1400(低于它翻拍件就看不清了)
"P2", new ProfileDefaults(null, 1400, null, "jpg"),
// P3 A4 扫描件:按 300dpi 处理
"P3", new ProfileDefaults(null, null, 300, "jpg"),
// P4 截图:**不缩放、保 PNG** —— 实测学籍表是 16:9 截图,转 JPG 会把表格线糊掉
"P4", new ProfileDefaults(null, null, null, "png"),
// P5 文档兜底:保持原格式
"P5", new ProfileDefaults(null, null, null, null),
// AUTO 自动判定:不预设任何档位参数,交给引擎按内容判
"AUTO", new ProfileDefaults(null, null, null, null));
public static ProfileDefaults profileDefaults(String profile) {
return PROFILE_DEFAULTS.getOrDefault(profile == null ? "AUTO" : profile,
PROFILE_DEFAULTS.get("AUTO"));
}
// ── 材料项模板(§7.10)────────────────────────
/**
* 模板里的一个材料项。
*
* @param code 材料码
* @param visibleWhen 条件显示的 JSON;{@code null} 表示无条件显示
*/
public record TemplateItem(String code, String visibleWhen) {
}
/** §7.10 的三个内置模板。 */
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>();
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明 / 学信网备案表。 */
private static final String WHEN_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}";
static {
// 学校收集:四个都要,但「学籍表」和「工作证明」按在校生互斥 ——
// 在校生开得出学籍表、开不出工作证明;社会人员正相反。
// 两条都设成"必传",学生只会看到自己该交的那一个。
TEMPLATES.put("TPL_SCHOOL_ROLL", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
new TemplateItem("WORK_PROOF", WHEN_NOT_STUDENT)));
// 社会收集:没有学籍表,多一张学信网备案表(非在校生才有)
TEMPLATES.put("TPL_SOCIAL", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null),
new TemplateItem("XJW_BA", WHEN_NOT_STUDENT)));
// 专项职业能力:只要三样
TEMPLATES.put("TPL_ZHUANXIANG", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null)));
}
public static List<TemplateItem> template(String templateCode) {
return TEMPLATES.get(templateCode);
}
public static Set<String> allTemplateCodes() {
return TEMPLATES.keySet();
}
/** 供界面下拉用的模板清单(码 + 展示名 + 包含的材料名)。 */
public static List<Map<String, Object>> templateOptions() {
List<Map<String, Object>> out = new ArrayList<>();
TEMPLATES.forEach((code, items) -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("templateCode", code);
m.put("label", switch (code) {
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
default -> code;
});
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
out.add(m);
});
return out;
}
}
@@ -0,0 +1,516 @@
package cn.tianbaotong.fill.module.material.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.material.dto.MaterialDefSaveReq;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.entity.CollectMaterialDef;
import cn.tianbaotong.fill.module.material.mapper.MaterialDefMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.support.NamingTemplate;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 材料项定义(《03-接口设计》§7.10)。
*
* <p>规则与约束的集中说明见 {@link MaterialCatalog}。这里守三件事:
* <ol>
* <li><b>材料码在任务内唯一</b>({@code uk_task_code})。而且它是
* {@code collect_file} 的关联键 —— 所以<b>修改时不允许改码</b>,
* 改了等于把已有文件迁到另一个材料名下。</li>
* <li><b>已有文件的材料项不可删</b>({@code 40027})。查的是
* {@code (task_id, material_code)},不是 id(表里根本没有那个外键)。</li>
* <li><b>默认值按"材料码"和"压缩档位"两个维度分别取</b>,见 {@link MaterialCatalog}。
* 把份数塞进档位默认里会让所有 P2 材料都变成"要传 2 份"。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MaterialDefService {
/**
* 可编辑材料清单的任务状态:草稿 / 收集中 / 已暂停。
*
* <p>与覆盖班级({@code TaskOrgService})的口径一致,但**刻意各写一份**:
* 两者现在是碰巧相同,将来未必(比如"审核已开始就不许加材料",
* 对应错误码 {@code 40030},属 M5)。共用一个常量会让改动一处影响两处,
* 而"材料能不能加"和"班级能不能改"本来就不是同一条业务规则。
*/
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
CollectTask.STATUS_DRAFT,
CollectTask.STATUS_COLLECTING,
CollectTask.STATUS_PAUSED);
/** 材料码格式:大写字母开头,后接大写字母/数字/下划线。 */
private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{0,31}$");
/** 固定尺寸的存储格式:{@code 295x413}。 */
private static final Pattern FIXED_SIZE_PATTERN = Pattern.compile("^\\d{2,5}x\\d{2,5}$");
private static final List<String> DEFAULT_FILE_TYPES = List.of("jpg", "jpeg", "png");
private static final int DEFAULT_MAX_SIZE_KB = 1024;
private final MaterialDefMapper materialDefMapper;
private final TaskMapper taskMapper;
// ── 查询 ──────────────────────────────────────
public List<MaterialDefVO> list(Long taskId) {
requireTask(taskId);
List<CollectMaterialDef> rows = materialDefMapper.selectList(
Wrappers.<CollectMaterialDef>lambdaQuery()
.eq(CollectMaterialDef::getTaskId, taskId)
.orderByAsc(CollectMaterialDef::getSortNo)
.orderByAsc(CollectMaterialDef::getId));
return rows.stream().map(MaterialDefService::toVo).toList();
}
// ── 新增 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public Long create(Long taskId, MaterialDefSaveReq req) {
requireEditableTask(taskId);
String code = req.getMaterialCode().trim().toUpperCase();
assertCodeFormat(code);
Long dup = materialDefMapper.selectCount(Wrappers.<CollectMaterialDef>lambdaQuery()
.eq(CollectMaterialDef::getTaskId, taskId)
.eq(CollectMaterialDef::getMaterialCode, code));
if (dup != null && dup > 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这个任务里已经有「" + code + "」了;一种材料只能配一次");
}
CollectMaterialDef entity = new CollectMaterialDef();
entity.setTaskId(taskId);
entity.setMaterialCode(code);
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
// 名称:客户端没填就从目录取(一寸照片 / 身份证 / …)
entity.setMaterialName(req.getMaterialName() != null && !req.getMaterialName().isBlank()
? req.getMaterialName().trim()
: (kind == null ? code : kind.name()));
// 别名:客户端没填就从目录取 —— 别名是"从历史文件名识别材料"的依据,
// 空着等于放弃识别能力,而目录里这份清单是现成的
entity.setAliases(join(req.getAliases() != null && !req.getAliases().isEmpty()
? req.getAliases()
: (kind == null ? List.of() : kind.aliases())));
applyCommonFields(entity, req, kind);
materialDefMapper.insert(entity);
log.info("任务 {} 新增材料项 {}({})", taskId, code, entity.getMaterialName());
return entity.getId();
}
// ── 修改 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public void update(Long id, MaterialDefSaveReq req) {
CollectMaterialDef exist = materialDefMapper.selectById(id);
if (exist == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "材料项不存在或已删除");
}
requireEditableTask(exist.getTaskId());
// 材料码不可改:它是 collect_file 的关联键,改了等于把已有文件迁到别的材料名下。
// 客户端可能不知道这一点仍然把它传上来,所以这里只在"确实改了"时报错,
// 而不是无条件拒绝(表单回显后原样提交是常见行为)。
if (req.getMaterialCode() != null && !req.getMaterialCode().isBlank()) {
String code = req.getMaterialCode().trim().toUpperCase();
if (!code.equals(exist.getMaterialCode())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"材料码不可修改(它是文件的关联键)。如需换码,请删掉后重新添加");
}
}
MaterialCatalog.Kind kind = MaterialCatalog.of(exist.getMaterialCode());
CollectMaterialDef patch = new CollectMaterialDef();
patch.setId(id);
patch.setMaterialName(req.getMaterialName() == null || req.getMaterialName().isBlank()
? exist.getMaterialName() : req.getMaterialName().trim());
if (req.getAliases() != null) {
patch.setAliases(join(req.getAliases()));
}
// 为了复用同一套默认值逻辑,先把可空的 patch 用现值填满再交给 applyCommonFields
patch.setNamePattern(exist.getNamePattern());
patch.setRequired(exist.getRequired());
patch.setVisibleWhen(exist.getVisibleWhen());
patch.setFileTypes(exist.getFileTypes());
patch.setMaxSizeKb(exist.getMaxSizeKb());
patch.setCompressProfile(exist.getCompressProfile());
patch.setMinLongSide(exist.getMinLongSide());
patch.setMinQuality(exist.getMinQuality());
patch.setTargetDpi(exist.getTargetDpi());
patch.setFixedSize(exist.getFixedSize());
patch.setOutputFormat(exist.getOutputFormat());
patch.setMaxCount(exist.getMaxCount());
patch.setMergeEnabled(exist.getMergeEnabled());
patch.setOcrType(exist.getOcrType());
patch.setSampleUrl(exist.getSampleUrl());
patch.setTipText(exist.getTipText());
patch.setSortNo(exist.getSortNo());
applyCommonFields(patch, req, kind);
// ⚠️ 必须用 UpdateWrapper 显式写每个字段:updateById 会跳过 null,
// 而"清空命名模板/条件显示/提示语"正是把字段置为 null 的操作。
// 用 updateById 会静默不生效(接口成功、库里没变),是最难查的一类 bug。
materialDefMapper.update(null, Wrappers.<CollectMaterialDef>lambdaUpdate()
.eq(CollectMaterialDef::getId, id)
.set(CollectMaterialDef::getMaterialName, patch.getMaterialName())
.set(CollectMaterialDef::getAliases, patch.getAliases())
.set(CollectMaterialDef::getNamePattern, patch.getNamePattern())
.set(CollectMaterialDef::getRequired, patch.getRequired())
.set(CollectMaterialDef::getVisibleWhen, patch.getVisibleWhen())
.set(CollectMaterialDef::getFileTypes, patch.getFileTypes())
.set(CollectMaterialDef::getMaxSizeKb, patch.getMaxSizeKb())
.set(CollectMaterialDef::getCompressProfile, patch.getCompressProfile())
.set(CollectMaterialDef::getMinLongSide, patch.getMinLongSide())
.set(CollectMaterialDef::getMinQuality, patch.getMinQuality())
.set(CollectMaterialDef::getTargetDpi, patch.getTargetDpi())
.set(CollectMaterialDef::getFixedSize, patch.getFixedSize())
.set(CollectMaterialDef::getOutputFormat, patch.getOutputFormat())
.set(CollectMaterialDef::getMaxCount, patch.getMaxCount())
.set(CollectMaterialDef::getMergeEnabled, patch.getMergeEnabled())
.set(CollectMaterialDef::getOcrType, patch.getOcrType())
.set(CollectMaterialDef::getSampleUrl, patch.getSampleUrl())
.set(CollectMaterialDef::getTipText, patch.getTipText())
.set(CollectMaterialDef::getSortNo, patch.getSortNo()));
}
// ── 删除 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
CollectMaterialDef exist = materialDefMapper.selectById(id);
if (exist == null) {
return;
}
requireEditableTask(exist.getTaskId());
long files = nz(materialDefMapper.countFiles(exist.getTaskId(), exist.getMaterialCode()));
if (files > 0) {
throw new BizException(ErrorCode.MATERIAL_HAS_FILES,
"「" + exist.getMaterialName() + "」下已经有 " + files + " 个文件,不能删除;"
+ "如果是不想再收,请把它设为选传或加条件显示规则隐藏掉");
}
materialDefMapper.deleteById(id);
log.info("任务 {} 删除材料项 {}({})", exist.getTaskId(), exist.getMaterialCode(),
exist.getMaterialName());
}
// ── 按模板初始化 ───────────────────────────────
/**
* 按内置模板初始化材料清单(§7.10 的 {@code materialTemplateId})。
*
* <p>幂等:已经存在的材料码会被跳过而不是报错 —— 建任务时带模板、
* 发布前又手动加了一遍,是正常的操作顺序,不该撞出错误。
*
* @return 实际新增的数量
*/
@Transactional(rollbackFor = Exception.class)
public int applyTemplate(Long taskId, String templateCode) {
List<MaterialCatalog.TemplateItem> items = MaterialCatalog.template(templateCode);
if (items == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"没有这个材料模板:" + templateCode + ";可用模板:"
+ String.join(", ", MaterialCatalog.allTemplateCodes()));
}
requireEditableTask(taskId);
Set<String> existing = new LinkedHashSet<>();
for (CollectMaterialDef row : materialDefMapper.selectList(
Wrappers.<CollectMaterialDef>lambdaQuery().eq(CollectMaterialDef::getTaskId, taskId))) {
existing.add(row.getMaterialCode());
}
int created = 0;
int sort = 0;
for (MaterialCatalog.TemplateItem item : items) {
sort += 10;
if (existing.contains(item.code())) {
continue;
}
MaterialCatalog.Kind kind = MaterialCatalog.of(item.code());
CollectMaterialDef entity = new CollectMaterialDef();
entity.setTaskId(taskId);
entity.setMaterialCode(item.code());
entity.setMaterialName(kind == null ? item.code() : kind.name());
entity.setAliases(kind == null ? null : join(kind.aliases()));
entity.setSortNo(sort);
MaterialDefSaveReq req = new MaterialDefSaveReq();
// 模板里的材料全部设为必传;条件互斥的(学籍表 / 工作证明)由 visible_when 决定
// 学生实际看到哪一个,所以"必传"不会让在校生被要求交工作证明
req.setRequired(true);
req.setVisibleWhen(item.visibleWhen() == null
? null : JSONUtil.toBean(item.visibleWhen(), Map.class));
applyCommonFields(entity, req, kind);
materialDefMapper.insert(entity);
created++;
}
log.info("任务 {} 按模板 {} 初始化材料清单,新增 {} 项(跳过 {} 项已存在)",
taskId, templateCode, created, items.size() - created);
return created;
}
// ── 内部 ──────────────────────────────────────
/**
* 把请求里的"可选"字段落到实体上,缺省值按<b>材料码</b>与<b>压缩档位</b>补齐。
*
* <p>调用方负责先填好像名称、别名这些与码强相关的值。
*/
private void applyCommonFields(CollectMaterialDef entity, MaterialDefSaveReq req,
MaterialCatalog.Kind kind) {
if (req.getNamePattern() != null && !req.getNamePattern().isBlank()) {
String pattern = req.getNamePattern().trim();
String problem = NamingTemplate.check(pattern);
if (problem != null) {
throw new BizException(ErrorCode.NAMING_DRYRUN_FAILED,
"材料「" + entity.getMaterialName() + "」的命名模板有问题:" + problem);
}
entity.setNamePattern(pattern);
} else {
entity.setNamePattern(null); // 留空 = 继承任务级
}
entity.setRequired(boolToShort(req.getRequired(), true));
entity.setVisibleWhen(normalizeVisibleWhen(req.getVisibleWhen()));
entity.setFileTypes(join(normalizeFileTypes(req.getFileTypes())));
int maxSizeKb = req.getMaxSizeKb() == null ? DEFAULT_MAX_SIZE_KB : req.getMaxSizeKb();
if (maxSizeKb < MaterialCatalog.MAX_SIZE_MIN_KB
|| maxSizeKb > MaterialCatalog.MAX_SIZE_MAX_KB) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"体积上限应在 " + MaterialCatalog.MAX_SIZE_MIN_KB + "~"
+ MaterialCatalog.MAX_SIZE_MAX_KB + " KB 之间(0.3M~5M)");
}
entity.setMaxSizeKb(maxSizeKb);
String profile = req.getCompressProfile() == null || req.getCompressProfile().isBlank()
? (kind == null ? "AUTO" : kind.profile())
: req.getCompressProfile().trim().toUpperCase();
if (!MaterialCatalog.PROFILES.contains(profile)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"压缩档位只能是 " + String.join(" / ", MaterialCatalog.PROFILES) + " 之一");
}
entity.setCompressProfile(profile);
// 档位参数按档位补齐(DDL 注释:固定尺寸仅 P1、分辨率下限仅 P2、DPI 仅 P3)
MaterialCatalog.ProfileDefaults pd = MaterialCatalog.profileDefaults(profile);
entity.setFixedSize(pd.fixedSize());
entity.setMinLongSide(pd.minLongSide());
entity.setTargetDpi(pd.targetDpi());
entity.setOutputFormat(pd.outputFormat());
entity.setMinQuality(MaterialCatalog.MIN_QUALITY_DEFAULT);
// 份数与是否合并是"材料"的事实(身份证正反面),不是"档位"的规则。
// 客户端显式传了就用客户端的。
entity.setMaxCount(req.getMaxCount() != null
? req.getMaxCount() : (kind == null ? 1 : kind.maxCount()));
entity.setMergeEnabled(boolToShort(req.getMergeEnabled(),
kind != null && kind.merge()));
String ocrType = req.getOcrType() == null || req.getOcrType().isBlank()
? null : req.getOcrType().trim().toUpperCase();
if (ocrType != null && !MaterialCatalog.OCR_TYPES.contains(ocrType)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"OCR 类型只能是 " + String.join(" / ", MaterialCatalog.OCR_TYPES) + " 之一");
}
entity.setOcrType(ocrType);
entity.setSampleUrl(trimToNull(req.getSampleUrl()));
entity.setTipText(trimToNull(req.getTipText()));
entity.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
}
/** 条件显示规则:只做形状校验,真正的求值在填报引擎(M3-10)。 */
private String normalizeVisibleWhen(Map<String, Object> visibleWhen) {
if (visibleWhen == null || visibleWhen.isEmpty()) {
return null;
}
Object field = visibleWhen.get("field");
if (field == null || String.valueOf(field).isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"条件显示规则缺少 field(它依赖哪个字段),例如 "
+ "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}");
}
return JSONUtil.toJsonStr(visibleWhen);
}
private List<String> normalizeFileTypes(List<String> fileTypes) {
if (fileTypes == null || fileTypes.isEmpty()) {
return DEFAULT_FILE_TYPES;
}
List<String> out = new ArrayList<>();
for (String raw : fileTypes) {
if (raw == null || raw.isBlank()) {
continue;
}
String t = raw.trim().toLowerCase().replace(".", "");
if (!MaterialCatalog.FILE_TYPES.contains(t)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不支持的文件格式「" + raw + "」;可选:"
+ String.join(" / ", MaterialCatalog.FILE_TYPES));
}
if (!out.contains(t)) {
out.add(t);
}
}
if (out.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "格式白名单不能为空");
}
return out;
}
private CollectTask requireTask(Long taskId) {
CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
return task;
}
private void requireEditableTask(Long taskId) {
CollectTask task = requireTask(taskId);
int status = task.getStatus() == null ? -1 : task.getStatus();
if (!EDITABLE_TASK_STATUS.contains(status)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"任务已" + statusText(status) + ",材料清单不可再改");
}
}
private static void assertCodeFormat(String code) {
if (!CODE_PATTERN.matcher(code).matches()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"材料码应是大写字母开头、由大写字母/数字/下划线组成,如 IDCARD、SCHOOL_ROLL");
}
}
// ── 转换 ──────────────────────────────────────
private static MaterialDefVO toVo(CollectMaterialDef e) {
MaterialDefVO vo = new MaterialDefVO();
vo.setId(e.getId());
vo.setMaterialCode(e.getMaterialCode());
vo.setMaterialName(e.getMaterialName());
vo.setAliases(split(e.getAliases()));
vo.setNamePattern(e.getNamePattern());
vo.setRequired(e.getRequired() != null && e.getRequired() == 1);
vo.setVisibleWhen(readJsonMap(e.getVisibleWhen()));
vo.setFileTypes(split(e.getFileTypes()));
vo.setMaxSizeKb(e.getMaxSizeKb());
// 见字段注释:库里没有这一列,阈值待 D5 标定,当前返回 null = 未启用检查
vo.setMinSizeKb(null);
vo.setCompressProfile(e.getCompressProfile());
vo.setMinLongSide(e.getMinLongSide());
vo.setMinQuality(e.getMinQuality());
vo.setTargetDpi(e.getTargetDpi());
vo.setFixedSize(parseFixedSize(e.getFixedSize()));
vo.setOutputFormat(e.getOutputFormat());
vo.setMaxCount(e.getMaxCount());
vo.setMergeEnabled(e.getMergeEnabled() != null && e.getMergeEnabled() == 1);
vo.setOcrType(e.getOcrType());
vo.setSampleUrl(e.getSampleUrl());
vo.setTipText(e.getTipText());
vo.setSortNo(e.getSortNo());
return vo;
}
private static Map<String, Object> readJsonMap(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
Map<String, Object> m = JSONUtil.toBean(json, Map.class);
return m == null || m.isEmpty() ? null : m;
} catch (Exception e) {
// 库里的 JSON 坏了不该让整个列表 500
log.warn("材料项 visible_when 解析失败:{}", json);
return null;
}
}
private static Map<String, Object> parseFixedSize(String raw) {
if (raw == null || raw.isBlank() || !FIXED_SIZE_PATTERN.matcher(raw.trim()).matches()) {
return null;
}
String[] parts = raw.trim().split("x");
Map<String, Object> m = new LinkedHashMap<>();
m.put("width", Integer.parseInt(parts[0]));
m.put("height", Integer.parseInt(parts[1]));
return m;
}
private static String join(List<String> values) {
if (values == null || values.isEmpty()) {
return null;
}
List<String> cleaned = new ArrayList<>();
for (String v : values) {
if (v != null && !v.isBlank() && !cleaned.contains(v.trim())) {
cleaned.add(v.trim());
}
}
return cleaned.isEmpty() ? null : String.join(",", cleaned);
}
private static List<String> split(String raw) {
List<String> out = new ArrayList<>();
if (raw == null || raw.isBlank()) {
return out;
}
for (String part : raw.split(",")) {
if (!part.isBlank()) {
out.add(part.trim());
}
}
return out;
}
private static Integer boolToShort(Boolean value, boolean defaultValue) {
boolean v = value == null ? defaultValue : value;
return v ? 1 : 0;
}
private static String trimToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
private static long nz(Long v) {
return v == null ? 0L : v;
}
private static String statusText(int status) {
return switch (status) {
case CollectTask.STATUS_DRAFT -> "草稿";
case CollectTask.STATUS_COLLECTING -> "收集中";
case CollectTask.STATUS_CLOSED -> "截止";
case CollectTask.STATUS_ARCHIVED -> "归档";
case CollectTask.STATUS_PAUSED -> "暂停";
default -> "未知状态";
};
}
}
@@ -81,6 +81,21 @@ public class TaskUpsertReq {
@Schema(description = "从表单模板库初始化(模板库属 M1-03,当前传了会被忽略)")
private Long formTemplateId;
@Schema(description = "从材料模板初始化(模板库属 M1-04,当前传了会被忽略)")
/**
* 按内置材料模板初始化材料清单,取值 {@code TPL_SCHOOL_ROLL} / {@code TPL_SOCIAL} /
* {@code TPL_ZHUANXIANG}(见 §7.10)。
*
* <p>为什么不是契约里的 {@code materialTemplateId}(int64):
* 那三个内置模板在文档与代码里都是<b>具名常量</b>,库里也没有"材料模板表",
* 拿一个 id 去指代常量就得先造一张表放它们,属于为了对齐签名而制造结构。
* {@code materialTemplateId} 保留给 M1-05 的规则模板库({@code collect_rule_template},
* 那是真正的一行一条记录,id 才有意义)。
*/
@Size(max = 32, message = "材料模板码过长")
@Schema(description = "内置材料模板码:TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG",
example = "TPL_SCHOOL_ROLL")
private String materialTemplateCode;
@Schema(description = "从规则模板库初始化(模板库属 M1-05,当前传了会被忽略)")
private Long materialTemplateId;
}
@@ -14,6 +14,7 @@ import cn.tianbaotong.fill.module.task.dto.TaskVO;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.task.mapper.TaskReadinessMapper;
import cn.tianbaotong.fill.support.NamingTemplate;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -60,33 +61,14 @@ public class TaskService {
/** 与 DDL 默认值一致:{@code naming_template} 的列默认值是它去掉 {@code {后缀}} 的形式。 */
private static final String DEFAULT_NAMING_TEMPLATE = "{姓名}-{证件号}-{材料名}";
/**
* 命名模板允许的占位符。
*
* <p>来源:《PRD》§命名规则配置的"可用变量"一栏。
* 多一个会生成出 {@code {未知变量}} 这种字面量文件名,少一个会拒掉合法模板,
* 所以这份清单必须与 PRD 逐字对齐。
*/
private static final Set<String> NAMING_PLACEHOLDERS = Set.of(
"姓名", "证件号", "证件类型", "材料名", "班级", "专业", "工种",
"等级", "报名点", "序号", "日期", "后缀");
/** {@code {自定义字段.xxx}} 的前缀 —— 扩展字段是动态的,不能枚举。 */
private static final String CUSTOM_FIELD_PREFIX = "自定义字段.";
/**
* 命名模板<b>必须</b>含这三个变量。
*
* <p>来源:《PRD》§5.6「命名模板必须含姓名/证件号/材料名」。
* 缺了任意一个,文件名就无法唯一归属到人 —— 而"文件归属"正是这个产品的根基
* (实测 52.7% 的原始文件名是违规的,靠的正是这三个变量重建归属)。
*/
private static final List<String> NAMING_REQUIRED = List.of("姓名", "证件号", "材料名");
private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]*)\\}");
// 命名模板的校验规则(占位符白名单、必备变量)见
// cn.tianbaotong.fill.support.NamingTemplate —— 任务级与材料级 name_pattern 共用同一套。
// 两处各写一套会出现"任务级认的变量材料级不认",而这类不一致的表现是
// "发布时通过、导出时文件名叫错",是最难在测试里撞上的一种。
private final TaskMapper taskMapper;
private final TaskReadinessMapper readinessMapper;
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
// ── 列表 ──────────────────────────────────────
@@ -192,6 +174,12 @@ public class TaskService {
log.info("新建任务 {}(id={}, 批次={}, 专业={})", t.getTaskCode(), t.getId(),
t.getBatchNo(), t.getMajorName());
// 按内置模板把材料清单先铺好。放在建任务之后做,是因为材料行要挂 task_id;
// 它和任务是同一个事务,失败一起回滚 —— 不会出现"任务建好了但材料是空的"。
if (req.getMaterialTemplateCode() != null && !req.getMaterialTemplateCode().isBlank()) {
materialDefService.applyTemplate(t.getId(), req.getMaterialTemplateCode().trim().toUpperCase());
}
TaskCreateVO vo = new TaskCreateVO();
vo.setId(t.getId());
vo.setTaskCode(t.getTaskCode());
@@ -545,7 +533,7 @@ public class TaskService {
* (变量名写错、漏掉必备变量),且名册为空时发布本来就会被 {@code 40016} 先拦住。
*/
private void assertNamingTemplate(String template) {
String problem = checkNamingTemplate(template);
String problem = NamingTemplate.check(template);
if (problem != null) {
throw new BizException(ErrorCode.NAMING_DRYRUN_FAILED, problem);
}
@@ -553,26 +541,7 @@ public class TaskService {
/** @return 不合规的原因;合规返回 {@code null} */
private String checkNamingTemplate(String template) {
if (template == null || template.isBlank()) {
return "命名模板不能为空";
}
Set<String> used = new LinkedHashSet<>();
Matcher m = PLACEHOLDER.matcher(template);
while (m.find()) {
String name = m.group(1).trim();
used.add(name);
if (NAMING_PLACEHOLDERS.contains(name) || name.startsWith(CUSTOM_FIELD_PREFIX)) {
continue;
}
return "命名模板里有不认识的变量 {" + name + "},可用变量:" + String.join(" ", NAMING_PLACEHOLDERS)
+ "," + CUSTOM_FIELD_PREFIX + "xxx";
}
for (String need : NAMING_REQUIRED) {
if (!used.contains(need)) {
return "命名模板必须包含 {" + need + "}(缺了它,文件名无法唯一归属到人)";
}
}
return null;
return NamingTemplate.check(template);
}
private static long nz(Long v) {
@@ -0,0 +1,76 @@
package cn.tianbaotong.fill.support;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 命名模板的校验规则(任务级与材料级共用)。
*
* <p>为什么必须共用:任务上有 {@code naming_template},每个材料还可以用
* {@code name_pattern} 覆盖它。如果两处各写一套校验,迟早出现
* "任务级模板加了新变量,材料级不认"——而这类不一致的表现是
* <b>发布时通过、导出时文件名叫错</b>,是最难在测试里撞上的一种。
*
* <p>规则有两条,都来自文档:
* <ol>
* <li>必须含 {@code {姓名}} {@code {证件号}} {@code {材料名}}
* (《PRD》§5.6:缺了任一项,文件名就无法唯一归属到人)。</li>
* <li>所有占位符都要在可用变量表里(《PRD》命名规则配置的"可用变量"一栏)。
* 写错一个变量名(比如 {@code {身证号}})不会报错,只会让每个文件名
* 都带上一个字面量 {@code {身证号}},而且要到导出的那一刻才会被发现。</li>
* </ol>
*/
public final class NamingTemplate {
private NamingTemplate() {
}
/** 允许的占位符,与 PRD「可用变量」一栏逐字对齐。 */
public static final Set<String> PLACEHOLDERS = Set.of(
"姓名", "证件号", "证件类型", "材料名", "班级", "专业", "工种",
"等级", "报名点", "序号", "日期", "后缀");
/** {@code {自定义字段.xxx}} 的前缀 —— 扩展字段是动态的,不能枚举。 */
public static final String CUSTOM_FIELD_PREFIX = "自定义字段.";
/** 必须包含的三个变量。 */
public static final List<String> REQUIRED = List.of("姓名", "证件号", "材料名");
private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]*)\\}");
/**
* 校验一个命名模板。
*
* @return 不合规的原因;合规返回 {@code null}
*/
public static String check(String template) {
if (template == null || template.isBlank()) {
return "命名模板不能为空";
}
Set<String> used = new LinkedHashSet<>();
Matcher m = PLACEHOLDER.matcher(template);
while (m.find()) {
String name = m.group(1).trim();
used.add(name);
if (PLACEHOLDERS.contains(name) || name.startsWith(CUSTOM_FIELD_PREFIX)) {
continue;
}
return "命名模板里有不认识的变量 {" + name + "},可用变量:"
+ String.join(" ", PLACEHOLDERS) + "," + CUSTOM_FIELD_PREFIX + "xxx";
}
for (String need : REQUIRED) {
if (!used.contains(need)) {
return "命名模板必须包含 {" + need + "}(缺了它,文件名无法唯一归属到人)";
}
}
return null;
}
/** 可用变量的展示串,用于错误信息与界面提示。 */
public static String placeholderHint() {
return String.join(" ", PLACEHOLDERS) + " " + CUSTOM_FIELD_PREFIX + "xxx";
}
}
@@ -0,0 +1,56 @@
-- ============================================================================
-- collect_material_def:让四个「压缩档位参数」口径一致(不适用时为空)
-- ============================================================================
--
-- 背景:这张表有四个"只在某个档位下生效"的参数:
-- fixed_size 仅 P1 证件照(295x413)
-- min_long_side 仅 P2 证件翻拍(长边下限)
-- target_dpi 仅 P3 扫描件
-- output_format 输出的图片格式
--
-- V1 建表时给了它们不一致的约束:
-- fixed_size varchar(32) -- 可空
-- target_dpi integer -- 可空
-- min_long_side integer NOT NULL DEFAULT 1400 -- 不可空
-- output_format varchar(16) NOT NULL DEFAULT 'jpg' -- 不可空
--
-- 于是"切到某个档位时把不适用的参数置空"这件事,对两个字段成立、对另两个字段报错
-- (实测:改档位到 P3 时直接 500,PostgreSQL 报
-- null value in column "min_long_side" violates not-null constraint)。
--
-- 为什么改成"都可空"而不是"都存默认值":
--
-- 1) **output_format 的 null 是有语义的**。《03-接口设计》§3.4 明确写着
-- 「输出格式;null = 保持原格式」。列上的 NOT NULL 让这个契约根本无法表达 ——
-- 契约优先,这一条没有商量余地。
--
-- 2) **min_long_side 存 1400 会误导**。它的注释写着「仅 P2 生效」,
-- 但在 P1 的材料行里读出 1400,无论是人看还是代码看都会以为"这张证件照有长边下限"。
-- 而真相是"这个参数对本档位不适用"。**"不适用"和"有一个下限"是两件事**,
-- 用同一个值表示它们,迟早有人据此写出错误的判断。
--
-- 3) **一致性本身就是价值**。四个同类参数两套规则,下次再加一个参数时
-- 没人说得清该跟随哪一套。定成"档位参数不适用时为空",只有一条规则。
--
-- ⚠️ 历史行里已有的 1400 / 'jpg' 保持原样,不动 ——
-- 无法判断它是"当时不适用被写了默认值"还是"确实设置过"。猜错会让
-- 某张证件照突然多出一个长边下限。开发者本地的测试数据由
-- scripts/e2e-m1-materials.py 的清理段收拾。
-- ============================================================================
ALTER TABLE collect_material_def
ALTER COLUMN min_long_side DROP NOT NULL;
ALTER TABLE collect_material_def
ALTER COLUMN min_long_side DROP DEFAULT;
ALTER TABLE collect_material_def
ALTER COLUMN output_format DROP NOT NULL;
ALTER TABLE collect_material_def
ALTER COLUMN output_format DROP DEFAULT;
COMMENT ON COLUMN collect_material_def.min_long_side IS
'分辨率下限(长边px),仅 P2 证件翻拍档生效;其余档位为 NULL 表示不适用';
COMMENT ON COLUMN collect_material_def.output_format IS
'输出格式;NULL 表示保持原格式(见《03-接口设计》§3.4)。P4 截图档固定 png';