diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java new file mode 100644 index 0000000..4ff0fcb --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java @@ -0,0 +1,127 @@ +package cn.tianbaotong.fill.module.material.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.material.dto.MaterialDefSaveReq; +import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; +import cn.tianbaotong.fill.module.material.service.MaterialCatalog; +import cn.tianbaotong.fill.module.material.service.MaterialDefService; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.RequirePerm; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 材料项定义(《03-接口设计》§7.10)。 + * + *

路径分两段:任务作用域的用 {@code /tasks/{id}/materials},单项操作用 + * {@code /materials/{id}} —— 与契约一致。一个 Controller 承载两段路径, + * 是为了让"材料"这件事在一个文件里能看全,而不是按 URL 前缀硬拆成两个类。 + */ +@Tag(name = "材料项定义") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class MaterialDefController { + + private final MaterialDefService materialDefService; + + @GetMapping("/tasks/{id}/materials") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "任务的材料项列表") + public ApiResult> list(@PathVariable Long id) { + return ApiResult.ok(materialDefService.list(id)); + } + + @PostMapping("/tasks/{id}/materials") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "新增材料项", + description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。" + + "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传") + public ApiResult create(@PathVariable Long id, + @Valid @RequestBody MaterialDefSaveReq req) { + return ApiResult.ok(materialDefService.create(id, req)); + } + + @PutMapping("/materials/{id}") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)") + public ApiResult update(@PathVariable Long id, + @Valid @RequestBody MaterialDefSaveReq req) { + materialDefService.update(id, req); + return ApiResult.ok(); + } + + @DeleteMapping("/materials/{id}") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "删除材料项", + description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收," + + "请设为选传或加条件显示规则隐藏") + public ApiResult delete(@PathVariable Long id) { + materialDefService.delete(id); + return ApiResult.ok(); + } + + @PostMapping("/tasks/{id}/materials/apply-template") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "按内置模板初始化材料清单", + description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*") + public ApiResult> applyTemplate(@PathVariable Long id, + @RequestBody Map body) { + String templateCode = body == null ? null : body.get("templateCode"); + int created = materialDefService.applyTemplate(id, + templateCode == null ? "" : templateCode.trim().toUpperCase()); + Map out = new LinkedHashMap<>(); + out.put("createdCount", created); + return ApiResult.ok(out); + } + + /** + * 材料目录 —— 界面下拉用的静态清单(材料类型、压缩档位、格式白名单、内置模板)。 + * + *

契约里没有这个接口,但界面需要这份清单:材料码要不要给用户选? + * 给了就必须有名称与默认档,否则用户得先记住 {@code SCHOOL_ROLL} 是学籍表。 + * 把这些常量在前端再抄一份会造成两份真相,迟早不一致(服务端改了别名、 + * 界面还是老的,而别名正是识别历史文件名用的)。 + */ + @GetMapping("/material-catalog") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "材料目录(材料类型 / 档位 / 格式白名单 / 内置模板)") + public ApiResult> catalog() { + Map out = new LinkedHashMap<>(); + + List> codes = new java.util.ArrayList<>(); + for (String code : MaterialCatalog.allCodes()) { + MaterialCatalog.Kind kind = MaterialCatalog.of(code); + Map 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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefSaveReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefSaveReq.java new file mode 100644 index 0000000..e112ad6 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefSaveReq.java @@ -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)。 + * + *

只列需要客户端传的字段。压缩相关的参数({@code minLongSide} / + * {@code targetDpi} / {@code fixedSize} / {@code minQuality})不在这里 —— + * 它们的默认值由压缩档位决定(见 {@code MaterialCatalog}), + * 由服务端按档位补齐。让界面同时填"档位"和"档位参数"是自相矛盾的: + * 选了 P1 却把 fixedSize 填成别的尺寸,系统该听谁的? + * 二期真要开放这些,应该做成"高级设置"并明确它们只在对应档位生效。 + */ +@Getter +@Setter +@Schema(description = "材料项新增或修改") +public class MaterialDefSaveReq { + + /** + * 材料码。 + * + *

修改时忽略(码是文件的关联键,改了等于把文件迁到另一个材料下)。 + * 想换码请删掉重建 —— 而且删之前要先确认没有文件({@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 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 visibleWhen; + + @Schema(description = "格式白名单,默认 [\"jpg\",\"jpeg\",\"png\"]") + private List 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefVO.java new file mode 100644 index 0000000..fb47bad --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/dto/MaterialDefVO.java @@ -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)。 + * + *

⚠️ 三个字段在库里是字符串、在这里是结构化值,转换在 Service 里做: + *

+ * 反过来也一样。让接口保留结构化的形状是值得的:界面要按它的类型渲染, + * 而不是自己 split 字符串 —— 那样每多一个调用方就多一份解析逻辑,迟早不一致。 + */ +@Getter +@Setter +@Schema(description = "材料项定义") +public class MaterialDefVO { + + private Long id; + + /** 材料码,见《03-接口设计》§2.1。任务内唯一,且是文件的关联键。 */ + private String materialCode; + + private String materialName; + + /** 别名集合,用于从历史文件名识别材料种类。 */ + private List aliases = new ArrayList<>(); + + /** 命名模板;为空则继承任务级。 */ + private String namePattern; + + private Boolean required; + + /** 条件显示规则,如 {@code {"field":"is_student","op":"eq","value":true}};null 表示无条件显示。 */ + private Map visibleWhen; + + private List fileTypes = new ArrayList<>(); + + private Integer maxSizeKb; + + /** + * 体积下限保护:低于此值提示"可能不是原图"。 + * + *

⚠️ 当前恒为 {@code null},即"未启用下限检查",这不是漏了实现: + * 《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 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/entity/CollectMaterialDef.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/entity/CollectMaterialDef.java new file mode 100644 index 0000000..f113488 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/entity/CollectMaterialDef.java @@ -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})。 + * + *

一行 = 这次收集要求学生交的一种材料,以及它的全部规则: + * 怎么命名、必不必传、什么条件下才出现、格式与体积限制、压到哪一档。 + * + *

⚠️ {@code materialCode} 在任务内唯一({@code uk_task_code UNIQUE (task_id, material_code)})。 + * 而且 {@code collect_file} 是用 (task_id, material_code) 关联材料的 —— + * 它没有 {@code material_def_id}。这意味着: + *

    + *
  • 删掉材料定义再新建一个同码的,会"继承"原来那批文件。 + * 这是对的(文件属于这个码,不属于某一行配置),但改码必须当成"迁移"来对待。
  • + *
  • "这个材料有没有文件"只能按 {@code task_id + material_code} 查 —— + * 判断能不能删({@code 40027})时用的是这个口径。
  • + *
+ * + *

⚠️ 压缩档位与"档位对应的参数"是两回事: + * {@code compress_profile} 决定怎么压(P1 证件照固定尺寸 / P2 翻拍保分辨率 / + * P3 扫描件按 DPI / P4 截图保 PNG / P5 兜底),而 + * {@code min_long_side} / {@code target_dpi} / {@code fixed_size} 只在对应档位下才生效 + * (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)。仅 P2 证件翻拍档生效。 */ + private Integer minLongSide; + + /** 压缩质量下限。低于 65 视为不可接受(DDL 注释口径)。 */ + private Integer minQuality; + + /** 目标 DPI。仅 P3 扫描件档生效,建议 150~300。 */ + private Integer targetDpi; + + /** 固定像素尺寸,如 {@code 295x413}。仅 P1 证件照档生效。 */ + 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/mapper/MaterialDefMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/mapper/MaterialDefMapper.java new file mode 100644 index 0000000..5822d53 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/mapper/MaterialDefMapper.java @@ -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 { + + /** + * 某个材料项下已经有多少个文件 —— 用于删除前置校验({@code 40027})。 + * + *

⚠️ 关联键是 {@code (task_id, material_code)},不是 {@code material_def_id}: + * {@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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialCatalog.java new file mode 100644 index 0000000..2549f81 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialCatalog.java @@ -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(压缩档位)落到一处,作为新建材料项时的默认值来源。 + * + *

为什么集中在常量类里而不是散在 Service 的分支里:这些默认值来自文档的表格, + * 是"机构既有习惯"的一部分(比如「工作证明」这个名字,见下), + * 一旦散进代码分支,改一处漏一处,就会出现"从模板加的身份证是 P2、手动加的是 AUTO"。 + * + *

两个维度要分清: + *

    + *
  • {@link Kind}:按材料码定的默认 —— 名字、别名、默认压缩档, + * 以及份数与是否合并(身份证正反面要 2 份并合并成一份, + * 这是「身份证」这件材料的事实,不是「P2 档」的规则)。
  • + *
  • {@link #profileDefaults}:按压缩档定的默认 —— 固定尺寸(P1)、 + * 分辨率下限(P2)、目标 DPI(P3)、输出格式。DDL 注释逐条写了"仅某档生效"。
  • + *
+ * 把份数塞进档位默认里是很容易犯的错:那样所有 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 aliases, String profile, + int maxCount, boolean merge) { + } + + /** + * §2.1 的表,逐行照抄。 + * + *

⚠️ {@code WORK_PROOF} 的名称陷阱:机构模板里「职业技能等级个人申报表」 + * 的规范文件名就是「工作证明」。这是机构既有习惯,**不要"修正"它** —— + * 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上, + * 而带教老师只认"工作证明"这个名字。 + */ + private static final List 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 BY_CODE = new LinkedHashMap<>(); + + static { + for (Kind kind : KINDS) { + BY_CODE.put(kind.code(), kind); + } + } + + /** 全部已知材料码,按 §2.1 的顺序。 */ + public static List 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 PROFILES = Set.of("AUTO", "P1", "P2", "P3", "P4", "P5"); + + /** 格式白名单全集(§3.4 的示例就是这五个)。 */ + public static final Set FILE_TYPES = Set.of("jpg", "jpeg", "png", "pdf", "heic"); + + /** OCR 类型全集。 */ + public static final Set 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 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> 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 template(String templateCode) { + return TEMPLATES.get(templateCode); + } + + public static Set allTemplateCodes() { + return TEMPLATES.keySet(); + } + + /** 供界面下拉用的模板清单(码 + 展示名 + 包含的材料名)。 */ + public static List> templateOptions() { + List> out = new ArrayList<>(); + TEMPLATES.forEach((code, items) -> { + Map 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialDefService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialDefService.java new file mode 100644 index 0000000..4f8d1ea --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/service/MaterialDefService.java @@ -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)。 + * + *

规则与约束的集中说明见 {@link MaterialCatalog}。这里守三件事: + *

    + *
  1. 材料码在任务内唯一({@code uk_task_code})。而且它是 + * {@code collect_file} 的关联键 —— 所以修改时不允许改码, + * 改了等于把已有文件迁到另一个材料名下。
  2. + *
  3. 已有文件的材料项不可删({@code 40027})。查的是 + * {@code (task_id, material_code)},不是 id(表里根本没有那个外键)。
  4. + *
  5. 默认值按"材料码"和"压缩档位"两个维度分别取,见 {@link MaterialCatalog}。 + * 把份数塞进档位默认里会让所有 P2 材料都变成"要传 2 份"。
  6. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MaterialDefService { + + /** + * 可编辑材料清单的任务状态:草稿 / 收集中 / 已暂停。 + * + *

与覆盖班级({@code TaskOrgService})的口径一致,但**刻意各写一份**: + * 两者现在是碰巧相同,将来未必(比如"审核已开始就不许加材料", + * 对应错误码 {@code 40030},属 M5)。共用一个常量会让改动一处影响两处, + * 而"材料能不能加"和"班级能不能改"本来就不是同一条业务规则。 + */ + private static final Set 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 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 list(Long taskId) { + requireTask(taskId); + List rows = materialDefMapper.selectList( + Wrappers.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.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.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})。 + * + *

幂等:已经存在的材料码会被跳过而不是报错 —— 建任务时带模板、 + * 发布前又手动加了一遍,是正常的操作顺序,不该撞出错误。 + * + * @return 实际新增的数量 + */ + @Transactional(rollbackFor = Exception.class) + public int applyTemplate(Long taskId, String templateCode) { + List items = MaterialCatalog.template(templateCode); + if (items == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "没有这个材料模板:" + templateCode + ";可用模板:" + + String.join(", ", MaterialCatalog.allTemplateCodes())); + } + requireEditableTask(taskId); + + Set existing = new LinkedHashSet<>(); + for (CollectMaterialDef row : materialDefMapper.selectList( + Wrappers.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; + } + + // ── 内部 ────────────────────────────────────── + + /** + * 把请求里的"可选"字段落到实体上,缺省值按材料码与压缩档位补齐。 + * + *

调用方负责先填好像名称、别名这些与码强相关的值。 + */ + 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 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 normalizeFileTypes(List fileTypes) { + if (fileTypes == null || fileTypes.isEmpty()) { + return DEFAULT_FILE_TYPES; + } + List 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 readJsonMap(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + Map 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 parseFixedSize(String raw) { + if (raw == null || raw.isBlank() || !FIXED_SIZE_PATTERN.matcher(raw.trim()).matches()) { + return null; + } + String[] parts = raw.trim().split("x"); + Map m = new LinkedHashMap<>(); + m.put("width", Integer.parseInt(parts[0])); + m.put("height", Integer.parseInt(parts[1])); + return m; + } + + private static String join(List values) { + if (values == null || values.isEmpty()) { + return null; + } + List 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 split(String raw) { + List 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 -> "未知状态"; + }; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskUpsertReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskUpsertReq.java index 1d347d3..042f8da 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskUpsertReq.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskUpsertReq.java @@ -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)。 + * + *

为什么不是契约里的 {@code materialTemplateId}(int64): + * 那三个内置模板在文档与代码里都是具名常量,库里也没有"材料模板表", + * 拿一个 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; } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java index a6dfbec..8b0a8e4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java @@ -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 = "{姓名}-{证件号}-{材料名}"; - /** - * 命名模板允许的占位符。 - * - *

来源:《PRD》§命名规则配置的"可用变量"一栏。 - * 多一个会生成出 {@code {未知变量}} 这种字面量文件名,少一个会拒掉合法模板, - * 所以这份清单必须与 PRD 逐字对齐。 - */ - private static final Set NAMING_PLACEHOLDERS = Set.of( - "姓名", "证件号", "证件类型", "材料名", "班级", "专业", "工种", - "等级", "报名点", "序号", "日期", "后缀"); - - /** {@code {自定义字段.xxx}} 的前缀 —— 扩展字段是动态的,不能枚举。 */ - private static final String CUSTOM_FIELD_PREFIX = "自定义字段."; - - /** - * 命名模板必须含这三个变量。 - * - *

来源:《PRD》§5.6「命名模板必须含姓名/证件号/材料名」。 - * 缺了任意一个,文件名就无法唯一归属到人 —— 而"文件归属"正是这个产品的根基 - * (实测 52.7% 的原始文件名是违规的,靠的正是这三个变量重建归属)。 - */ - private static final List 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 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) { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/NamingTemplate.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/NamingTemplate.java new file mode 100644 index 0000000..2860d1d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/NamingTemplate.java @@ -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; + +/** + * 命名模板的校验规则(任务级与材料级共用)。 + * + *

为什么必须共用:任务上有 {@code naming_template},每个材料还可以用 + * {@code name_pattern} 覆盖它。如果两处各写一套校验,迟早出现 + * "任务级模板加了新变量,材料级不认"——而这类不一致的表现是 + * 发布时通过、导出时文件名叫错,是最难在测试里撞上的一种。 + * + *

规则有两条,都来自文档: + *

    + *
  1. 必须含 {@code {姓名}} {@code {证件号}} {@code {材料名}} + * (《PRD》§5.6:缺了任一项,文件名就无法唯一归属到人)。
  2. + *
  3. 所有占位符都要在可用变量表里(《PRD》命名规则配置的"可用变量"一栏)。 + * 写错一个变量名(比如 {@code {身证号}})不会报错,只会让每个文件名 + * 都带上一个字面量 {@code {身证号}},而且要到导出的那一刻才会被发现。
  4. + *
+ */ +public final class NamingTemplate { + + private NamingTemplate() { + } + + /** 允许的占位符,与 PRD「可用变量」一栏逐字对齐。 */ + public static final Set PLACEHOLDERS = Set.of( + "姓名", "证件号", "证件类型", "材料名", "班级", "专业", "工种", + "等级", "报名点", "序号", "日期", "后缀"); + + /** {@code {自定义字段.xxx}} 的前缀 —— 扩展字段是动态的,不能枚举。 */ + public static final String CUSTOM_FIELD_PREFIX = "自定义字段."; + + /** 必须包含的三个变量。 */ + public static final List 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 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"; + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V5__material_profile_params_nullable.sql b/backend/xt-server/src/main/resources/db/migration/V5__material_profile_params_nullable.sql new file mode 100644 index 0000000..9dfcb1b --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V5__material_profile_params_nullable.sql @@ -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'; diff --git a/docs/db/schema-postgresql.sql b/docs/db/schema-postgresql.sql index 28dabed..4bcf2f6 100644 --- a/docs/db/schema-postgresql.sql +++ b/docs/db/schema-postgresql.sql @@ -211,11 +211,11 @@ CREATE TABLE collect_material_def ( file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png', max_size_kb integer NOT NULL DEFAULT 1024, compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', - min_long_side integer NOT NULL DEFAULT 1400, + min_long_side integer, min_quality smallint NOT NULL DEFAULT 70, target_dpi integer, fixed_size varchar(32), - output_format varchar(16) NOT NULL DEFAULT 'jpg', + output_format varchar(16), max_count smallint NOT NULL DEFAULT 1, merge_enabled smallint NOT NULL DEFAULT 0, ocr_type varchar(32), @@ -240,11 +240,11 @@ COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式'; COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单'; COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3'; -COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)'; +COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(长边px),仅 P2 证件翻拍档生效;其余档位为 NULL 表示不适用'; COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受'; COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)'; -COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png'; +COMMENT ON COLUMN collect_material_def.output_format IS '输出格式;NULL 表示保持原格式。P4 截图档固定 png'; COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2'; COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; diff --git a/docs/交接摘要.md b/docs/交接摘要.md index cda8804..f729be1 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -49,8 +49,9 @@ | M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** | | **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** | | **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** | +| **M1-04 材料项定义** | ✅ 材料项 CRUD(§7.10)+ 三个内置模板(`TPL_SCHOOL_ROLL`/`TPL_SOCIAL`/`TPL_ZHUANXIANG`)+ 材料目录接口;**`e2e-m1-materials.py` → 68/68** | | PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** | -| 待做 | M1 剩余(M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 | +| 待做 | M1 剩余(M1-03 表单 schema、M1-05 规则模板、M1-06 二维码、**M1-07~M1-10 前端**——材料规则的界面按计划属 **M1-09**);小程序(uni-app)未创建;CI/CD;H5 保底预研 | --- @@ -219,6 +220,52 @@ --- +## 一·七、M1-04 材料项定义(2026-09-29 上午,未提交) + +> 范围:`GET/POST /tasks/{id}/materials`、`PUT/DELETE /materials/{id}`(《03-接口设计》§7.10)、 +> 三个内置材料模板、材料目录接口 `GET /material-catalog`。 +> **按计划表,材料规则的界面属 M1-09(6 人日,前端),本轮只做后端。** + +**验收**:`python scripts/e2e-m1-materials.py` → **68/68**; +其余四条回归通过(`e2e-m0.py` 36 + `e2e-m0-user.py` 55 + `e2e-m1-task.py` 71 + `e2e-m1-orgs.py` 48), +合计 **278 项断言**。 + +### 落在代码里的三件事 + +| # | 事 | 说明 | +|:--:|---|---| +| 1 | **默认值按两个维度分别取** | 见 `MaterialCatalog`:**按材料码**定名字/别名/默认压缩档/**份数与是否合并**;**按压缩档**定固定尺寸(P1)/长边下限(P2)/目标 DPI(P3)/输出格式。把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",而"身份证要正反面"是**这件材料的事实**,不是档位的规则 | +| 2 | **`WORK_PROOF` 的名字是「工作证明」** | 机构模板里「职业技能等级个人申报表」的规范文件名就是「工作证明」。这是机构既有习惯,**不要"修正"** —— 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上 | +| 3 | **关联键是 `(task_id, material_code)`** | `collect_file` 表里**没有** `material_def_id`。所以:①「已有文件不可删」(`40027`)必须按码查,用 id 查会永远返回 0、这道校验形同虚设;②修改**不允许改码**(改了等于把已有文件迁到别的材料名下) | + +### 本轮修掉的 2 个真问题 + +| # | 问题 | 说明 | +|:--:|---|---| +| 1 | **`NOT NULL DEFAULT` 与"档位不适用就置空"打架(同一类 bug 第二次)** | `min_long_side`(`NOT NULL DEFAULT 1400`)与 `output_format`(`NOT NULL DEFAULT 'jpg'`)在切档位时被写成 null → PostgreSQL 报 `null value in column "min_long_side" violates not-null constraint`,接口 500。而更根本的是:**契约 §3.4 明确写着 `outputFormat` 的 `null` = 保持原格式** —— 这一列本就不该是 NOT NULL。已由 `V5__material_profile_params_nullable.sql` 把四个档位参数统一成"**不适用时为空**"(另外两个 `target_dpi`/`fixed_size` 本来就可空),并同步更正两份 DDL 文档。
⚠️ **这与 `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`(已有文件) | + +--- + **关键实现决策(不要在后继开发中改回去)** | 决策 | 理由 | diff --git a/docs/数据信息填报系统PRD.md b/docs/数据信息填报系统PRD.md index 7cab262..e685e44 100644 --- a/docs/数据信息填报系统PRD.md +++ b/docs/数据信息填报系统PRD.md @@ -1669,11 +1669,11 @@ CREATE TABLE collect_material_def ( file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png', max_size_kb integer NOT NULL DEFAULT 1024, compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', - min_long_side integer NOT NULL DEFAULT 1400, + min_long_side integer, min_quality smallint NOT NULL DEFAULT 70, target_dpi integer, fixed_size varchar(32), - output_format varchar(16) NOT NULL DEFAULT 'jpg', + output_format varchar(16), max_count smallint NOT NULL DEFAULT 1, merge_enabled smallint NOT NULL DEFAULT 0, ocr_type varchar(32), @@ -1698,11 +1698,11 @@ COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式'; COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单'; COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3'; -COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)'; +COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(长边px),仅 P2 证件翻拍档生效;其余档位为 NULL 表示不适用'; COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受'; COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)'; -COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png'; +COMMENT ON COLUMN collect_material_def.output_format IS '输出格式;NULL 表示保持原格式。P4 截图档固定 png'; COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2'; COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; diff --git a/scripts/e2e-m1-materials.py b/scripts/e2e-m1-materials.py new file mode 100644 index 0000000..0f7e52d --- /dev/null +++ b/scripts/e2e-m1-materials.py @@ -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)