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,当前传了会被忽略)") @Schema(description = "从表单模板库初始化(模板库属 M1-03,当前传了会被忽略)")
private Long formTemplateId; 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; 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.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper; import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.task.mapper.TaskReadinessMapper; import cn.tianbaotong.fill.module.task.mapper.TaskReadinessMapper;
import cn.tianbaotong.fill.support.NamingTemplate;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -60,33 +61,14 @@ public class TaskService {
/** 与 DDL 默认值一致:{@code naming_template} 的列默认值是它去掉 {@code {后缀}} 的形式。 */ /** 与 DDL 默认值一致:{@code naming_template} 的列默认值是它去掉 {@code {后缀}} 的形式。 */
private static final String DEFAULT_NAMING_TEMPLATE = "{姓名}-{证件号}-{材料名}"; private static final String DEFAULT_NAMING_TEMPLATE = "{姓名}-{证件号}-{材料名}";
/** // 命名模板的校验规则(占位符白名单、必备变量)见
* 命名模板允许的占位符。 // cn.tianbaotong.fill.support.NamingTemplate —— 任务级与材料级 name_pattern 共用同一套。
* // 两处各写一套会出现"任务级认的变量材料级不认",而这类不一致的表现是
* <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("\\{([^}]*)\\}");
private final TaskMapper taskMapper; private final TaskMapper taskMapper;
private final TaskReadinessMapper readinessMapper; 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(), log.info("新建任务 {}(id={}, 批次={}, 专业={})", t.getTaskCode(), t.getId(),
t.getBatchNo(), t.getMajorName()); 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(); TaskCreateVO vo = new TaskCreateVO();
vo.setId(t.getId()); vo.setId(t.getId());
vo.setTaskCode(t.getTaskCode()); vo.setTaskCode(t.getTaskCode());
@@ -545,7 +533,7 @@ public class TaskService {
* (变量名写错、漏掉必备变量),且名册为空时发布本来就会被 {@code 40016} 先拦住。 * (变量名写错、漏掉必备变量),且名册为空时发布本来就会被 {@code 40016} 先拦住。
*/ */
private void assertNamingTemplate(String template) { private void assertNamingTemplate(String template) {
String problem = checkNamingTemplate(template); String problem = NamingTemplate.check(template);
if (problem != null) { if (problem != null) {
throw new BizException(ErrorCode.NAMING_DRYRUN_FAILED, problem); throw new BizException(ErrorCode.NAMING_DRYRUN_FAILED, problem);
} }
@@ -553,26 +541,7 @@ public class TaskService {
/** @return 不合规的原因;合规返回 {@code null} */ /** @return 不合规的原因;合规返回 {@code null} */
private String checkNamingTemplate(String template) { private String checkNamingTemplate(String template) {
if (template == null || template.isBlank()) { return NamingTemplate.check(template);
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;
} }
private static long nz(Long v) { 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';
+4 -4
View File
@@ -211,11 +211,11 @@ CREATE TABLE collect_material_def (
file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png', file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png',
max_size_kb integer NOT NULL DEFAULT 1024, max_size_kb integer NOT NULL DEFAULT 1024,
compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', 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, min_quality smallint NOT NULL DEFAULT 70,
target_dpi integer, target_dpi integer,
fixed_size varchar(32), fixed_size varchar(32),
output_format varchar(16) NOT NULL DEFAULT 'jpg', output_format varchar(16),
max_count smallint NOT NULL DEFAULT 1, max_count smallint NOT NULL DEFAULT 1,
merge_enabled smallint NOT NULL DEFAULT 0, merge_enabled smallint NOT NULL DEFAULT 0,
ocr_type varchar(32), 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.file_types IS '格式白名单';
COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; 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.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.min_quality IS '质量下限,低于65视为不可接受';
COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; 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.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.max_count IS '允许上传份数,身份证正反面可2';
COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张';
COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; 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** | | M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** | | **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** | | **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 + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** | | 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', file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png',
max_size_kb integer NOT NULL DEFAULT 1024, max_size_kb integer NOT NULL DEFAULT 1024,
compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', 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, min_quality smallint NOT NULL DEFAULT 70,
target_dpi integer, target_dpi integer,
fixed_size varchar(32), fixed_size varchar(32),
output_format varchar(16) NOT NULL DEFAULT 'jpg', output_format varchar(16),
max_count smallint NOT NULL DEFAULT 1, max_count smallint NOT NULL DEFAULT 1,
merge_enabled smallint NOT NULL DEFAULT 0, merge_enabled smallint NOT NULL DEFAULT 0,
ocr_type varchar(32), 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.file_types IS '格式白名单';
COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; 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.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.min_quality IS '质量下限,低于65视为不可接受';
COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; 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.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.max_count IS '允许上传份数,身份证正反面可2';
COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张';
COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE';
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-04 材料项定义 验收测试。
覆盖《03-接口设计》§7.10:
[x] 按内置模板初始化(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等
[x] 材料项 CRUD;材料码任务内唯一;材料码不可修改
[x] 默认值按「材料码」与「压缩档位」两个维度分别取(见 MaterialCatalog)
[x] 条件显示(FR-402):在校生→学籍表,非在校生→工作证明 / 学信网备案表
[x] 已有文件的材料项不可删(40027)—— 关联键是 (task_id, material_code)
[x] 校验:档位 / 格式白名单 / 体积范围 / 材料码格式 / 命名模板
[x] 状态限制与权限
为什么这些值得逐条断言:
材料清单直接决定学生要交什么、交多大、压到什么程度。默认值算错的表现是
"身份证被要求只传 1 份"(正反面传不上)或"证件照没被压到 295x413"
(机构退件),而这两种都不会报错,只会让材料收不成。
用法
python scripts/e2e-m1-materials.py
依赖:cryptography;且需要能 `docker exec xt-pg`(造"已有文件"与清理)
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CTX: dict[str, str | None] = {"base": None, "token": None}
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
# ── 断言 ──────────────────────────────────────────────
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def cleanup_all() -> None:
"""幂等清理;崩溃时也会被 __main__ 的 finally 调一次。"""
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_file WHERE task_id IN ({ids})",
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
base, token = CTX.get("base"), CTX.get("token")
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
# ── HTTP ────────────────────────────────────────────
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def main() -> int:
ap = argparse.ArgumentParser(description="M1-04 材料项定义 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"M1-04 材料项定义 E2E base={base} 租户={args.tenant}")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
tenant_id = (body["data"].get("tenant") or {}).get("id")
CTX["base"], CTX["token"] = base, token
ok(f"管理员登录(租户 id={tenant_id})")
# 建任务时直接带内置模板
batch = "2002"
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E材料清单 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
"materialTemplateCode": "TPL_SCHOOL_ROLL",
})
if body.get("code") != 0:
fail("建任务(带材料模板)", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务并带 TPL_SCHOOL_ROLL 模板(id={task_id})")
# ── 1. 模板初始化 ───────────────────────────────────
print("\n[1] 内置模板初始化")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
rows = body.get("data") or []
check("模板铺出 4 个材料项", len(rows) == 4, f"实际 {len(rows)}:{[r.get('materialCode') for r in rows]}")
if len(rows) != 4:
return 1
by_code = {r["materialCode"]: r for r in rows}
check("四个材料码正确(照片/身份证/学籍表/工作证明)",
set(by_code) == {"PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF"}, f"{sorted(by_code)}")
p1 = by_code.get("PHOTO1", {})
check("一寸照片:名称来自 §2.1 材料目录", p1.get("materialName") == "一寸照片",
f"{p1.get('materialName')}")
check("一寸照片:别名自动填了(识别历史文件名要用)",
"证件照" in (p1.get("aliases") or []) and "1寸照片" in (p1.get("aliases") or []),
f"{p1.get('aliases')}")
check("一寸照片:默认档位 P1", p1.get("compressProfile") == "P1", f"{p1.get('compressProfile')}")
check("一寸照片:P1 档补上了固定尺寸 295x413",
(p1.get("fixedSize") or {}).get("width") == 295
and (p1.get("fixedSize") or {}).get("height") == 413,
f"{p1.get('fixedSize')}")
check("一寸照片:输出格式 jpg", p1.get("outputFormat") == "jpg", f"{p1.get('outputFormat')}")
idc = by_code.get("IDCARD", {})
check("身份证:默认档位 P2", idc.get("compressProfile") == "P2", f"{idc.get('compressProfile')}")
check("身份证:份数 2 且允许合并(正反面)",
idc.get("maxCount") == 2 and idc.get("mergeEnabled") is True,
f"maxCount={idc.get('maxCount')} merge={idc.get('mergeEnabled')}")
check("身份证:P2 档补上了长边下限 1400", idc.get("minLongSide") == 1400,
f"{idc.get('minLongSide')}")
check("身份证:P2 档下不该有 P1 的固定尺寸",
idc.get("fixedSize") is None, f"{idc.get('fixedSize')}")
wp = by_code.get("WORK_PROOF", {})
check("工作证明:名称是「工作证明」而不是「个人申报表」(机构既有习惯,不要修正)",
wp.get("materialName") == "工作证明", f"{wp.get('materialName')}")
check("工作证明:默认档位 P3", wp.get("compressProfile") == "P3", f"{wp.get('compressProfile')}")
check("工作证明:P3 档补上了目标 DPI 300", wp.get("targetDpi") == 300, f"{wp.get('targetDpi')}")
sr = by_code.get("SCHOOL_ROLL", {})
check("学籍表:默认档位 P4,输出保 PNG(截图转 JPG 会糊掉表格线)",
sr.get("compressProfile") == "P4" and sr.get("outputFormat") == "png",
f"{sr.get('compressProfile')} / {sr.get('outputFormat')}")
check("FR-402 级联:学籍表仅在校生显示(is_student=true)",
(sr.get("visibleWhen") or {}).get("field") == "is_student"
and (sr.get("visibleWhen") or {}).get("value") is True,
f"{sr.get('visibleWhen')}")
check("FR-402 级联:工作证明仅非在校生显示(is_student=false)",
(wp.get("visibleWhen") or {}).get("field") == "is_student"
and (wp.get("visibleWhen") or {}).get("value") is False,
f"{wp.get('visibleWhen')}")
check("模板里的材料都是必传(互斥由条件显示解决,在校生不会被要求交工作证明)",
all(r.get("required") is True for r in rows), f"{[(r['materialCode'], r.get('required')) for r in rows]}")
# 幂等
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_SCHOOL_ROLL"})
check("重复应用同一模板是幂等的(不报错、不重复插)",
body.get("code") == 0 and (body.get("data") or {}).get("createdCount") == 0,
f"code={body.get('code')} data={body.get('data')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("重复应用后仍是 4 项", len(body.get("data") or []) == 4, f"{len(body.get('data') or [])}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_NOPE"})
check("不存在的模板码被拒且提示可用模板", body.get("code") == 40001 and "TPL_" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
# ── 2. 材料目录 ─────────────────────────────────────
print("\n[2] 材料目录接口")
status, body = call("GET", f"{base}/api/v1/material-catalog", token)
cat = body.get("data") or {}
check("目录返回 6 种材料码", len(cat.get("codes") or []) == 6,
f"{[c.get('code') for c in (cat.get('codes') or [])]}")
check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}")
check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}")
check("目录返回 3 个内置模板", len(cat.get("templates") or []) == 3, f"{cat.get('templates')}")
check("目录返回体积可配范围 307~5120",
cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}")
# ── 3. 新增与默认值 ─────────────────────────────────
print("\n[3] 新增材料项")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料",
"tipText": "如有获奖证书、技能证书请一并上传",
})
check("新增选传材料成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
other_id = body.get("data")
check("返回新增的 id", isinstance(other_id, int), f"{other_id}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "重复的",
})
check("同一个材料码不能配两次(任务内唯一)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 小写码会被规范化为大写 —— 这是对输入友好,也保证"码"只有一个口径
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "lower_case", "materialName": "小写码材料",
})
check("材料码小写会被规范化为大写(不是拒绝)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
lower_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
codes = [r["materialCode"] for r in (body.get("data") or [])]
check("规范化后的码是大写 LOWER_CASE", "LOWER_CASE" in codes, f"{codes}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "1INVALID", "materialName": "数字开头",
})
check("材料码以数字开头被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
# 未知码允许(学校可能有《护士执业证》这类目录外的材料),但名称必须自己填
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
})
check("目录外的材料码允许使用(学校会有目录外的材料)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
custom_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
refetched = {r["materialCode"]: r for r in (body.get("data") or [])}
check("目录外的码默认档位 AUTO、份数 1、不合并、无固定尺寸",
refetched.get("CUSTOM_CERT", {}).get("compressProfile") == "AUTO"
and refetched.get("CUSTOM_CERT", {}).get("maxCount") == 1
and refetched.get("CUSTOM_CERT", {}).get("mergeEnabled") is False
and refetched.get("CUSTOM_CERT", {}).get("fixedSize") is None,
f"{refetched.get('CUSTOM_CERT')}")
# ── 4. 校验 ─────────────────────────────────────────
print("\n[4] 校验:不该放行的都要拦住")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_PROFILE", "materialName": "档位非法", "compressProfile": "P9",
})
check("非法压缩档位被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_TYPE", "materialName": "格式非法", "fileTypes": ["jpg", "exe"],
})
check("格式白名单外的类型被拒且列出可选值",
body.get("code") == 40001 and "exe" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_BIG", "materialName": "体积超范围", "maxSizeKb": 9000,
})
check("体积上限超出 5M 被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_SMALL", "materialName": "体积过小", "maxSizeKb": 100,
})
check("体积上限低于 0.3M 被拒(压得太狠等于把材料压废)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_NAMING", "materialName": "命名错", "namePattern": "{姓名}-{材料名}",
})
check("材料级命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
f"code={body.get('code')} msg={body.get('msg')}")
check("命名模板的错误信息里点明了是哪个材料的问题",
"命名错" in (body.get("msg") or ""), f"msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_VISIBLE", "materialName": "条件缺字段",
"visibleWhen": {"op": "eq", "value": True},
})
check("条件显示规则缺 field 被拒", body.get("code") == 40001 and "field" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("以上失败都没有污染数据(7 项:4 模板 + OTHER_PROOF + CUSTOM_CERT + LOWER_CASE)",
len(body.get("data") or []) == 7, f"实际 {len(body.get('data') or [])}")
# ── 5. 修改 ─────────────────────────────────────────
print("\n[5] 修改材料项")
status, body = call("PUT", f"{base}/api/v1/materials/{other_id}", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料(改名)",
"required": False, "maxSizeKb": 2048,
"compressProfile": "P1",
})
check("修改成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("OTHER_PROOF", {})
check("名称已改", m.get("materialName") == "其它佐证材料(改名)", f"{m.get('materialName')}")
check("改为选传已生效", m.get("required") is False, f"{m.get('required')}")
check("体积上限已改 2048", m.get("maxSizeKb") == 2048, f"{m.get('maxSizeKb')}")
# 注意:这里必须换到与原档位(P3)**不同**的档位,否则断言会因"原值恰好相同"而假通过。
# 之前我用 P3 就踩了这个坑:OTHER_PROOF 的默认档本来就是 P3,测不出任何东西。
check("从 P3 改到 P1 后:P1 的固定尺寸出现、P3 的目标 DPI 消失",
m.get("compressProfile") == "P1"
and (m.get("fixedSize") or {}).get("width") == 295
and (m.get("fixedSize") or {}).get("height") == 413
and m.get("targetDpi") is None,
f"profile={m.get('compressProfile')} fixed={m.get('fixedSize')} dpi={m.get('targetDpi')}")
check("切档位后不适用的长边下限被清空(这正是 500 的那条路径)",
m.get("minLongSide") is None, f"{m.get('minLongSide')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "RENAMED_CODE", "materialName": "护士执业证",
})
check("材料码不可修改(它是文件的关联键)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 清空命名模板:这一条正是"updateById 会跳过 null"会静默失效的地方
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
"namePattern": "{姓名}-{证件号}-{材料名}",
})
check("设置材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证", "namePattern": None,
})
check("清空材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("CUSTOM_CERT", {})
check("清空后读回是 null(清空真的落库,不是被静默忽略)",
m.get("namePattern") is None, f"{m.get('namePattern')}")
# ── 6. 删除 ─────────────────────────────────────────
print("\n[6] 删除与 40027")
status, body = call("DELETE", f"{base}/api/v1/materials/{custom_id}", token)
check("没有文件的材料项可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("删除后列表少了一项", len(body.get("data") or []) == 6, f"{len(body.get('data') or [])}")
# 造一个"已上传的文件"——关联键是 (task_id, material_code),没有 material_def_id
okk, out = sql(
f"INSERT INTO collect_file (tenant_id, task_id, submission_id, material_code, "
f"original_name, file_ext, raw_size, final_size, file_hash) "
f"VALUES ({tenant_id}, {task_id}, 0, 'OTHER_PROOF', '身份证.jpg', 'jpg', 100, 90, 'seedhash')")
check("夹具:写入 1 个文件(关联 material_code=OTHER_PROOF)", okk, out)
status, body = call("DELETE", f"{base}/api/v1/materials/{other_id}", token)
check("已有文件的材料项不可删除 40027", body.get("code") == 40027,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由给出了替代做法(设选传或加条件隐藏)",
"选传" in (body.get("msg") or "") or "隐藏" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
# ── 7. 状态限制与权限 ───────────────────────────────
print("\n[7] 状态限制与权限")
okk, out = sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
okk, out = sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
f"VALUES ({tenant_id}, {task_id}, "
f"(SELECT id FROM sys_org WHERE tenant_id={tenant_id} AND org_type=3 LIMIT 1), 1)")
check("夹具:名册与覆盖班级就位", okk, out)
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
check("任务已发布(说明材料清单不再是发布的阻断项)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_PUBLISH", "materialName": "发布后补的材料",
})
check("收集中仍可补材料(「漏交了,加一项」是真实需求)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_CLOSE", "materialName": "截止后加的",
})
check("已截止不可再加材料", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("已截止仍可读材料清单(只读不等于看不见)", body.get("code") == 0,
f"code={body.get('code')}")
# 班主任只读
ht_user = f"htm{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E材料班主任{suffix}",
"phone": "13900139301", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
ht_id = body["data"]["id"]
ht_pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(ht_id)
status, body = login(base, ht_user, ht_pwd, args.tenant)
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", ht_token)
check("班主任能读材料清单(他要照着检查学生交得对不对)", body.get("code") == 0,
f"code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", ht_token,
{"materialCode": "BY_TEACHER", "materialName": "班主任加的"})
check("班主任不能加材料(无 task:manage)", body.get("code") == 40301,
f"code={body.get('code')}")
# ── 8. 清理 ────────────────────────────────────────
print("\n[8] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号及其夹具数据")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(code)