列表与详情用 {@code task:view}、其余用 {@code task:manage} ——
+ * 班主任只有 {@code task:view}(他能看到自己班的任务,但不能建、不能发布)。
+ */
+@Tag(name = "收集任务")
+@RestController
+@RequestMapping("/api/v1/tasks")
+@RequiredArgsConstructor
+public class TaskController {
+
+ private final TaskService taskService;
+
+ @GetMapping
+ @RequirePerm(PermissionDef.TASK_VIEW)
+ @Operation(summary = "任务列表",
+ description = "管理员看全租户;班主任与副班主任只看自己带的班级的任务")
+ public ApiResult 7 个字段的默认值都写在这里,服务端在创建任务时会用它们补齐客户端没传的部分。
+ * 默认值集中在一处,是为了让"界面上的初始值"和"接口的初始值"不可能不一致 ——
+ * 分散成"前端填默认、后端再兜底"两套,迟早会出现同一件事两个默认值。
+ */
+@Getter
+@Setter
+@Schema(description = "审核流配置")
+public class AuditConfigVO {
+
+ /** 是否需班主任初审。社会收集恒为 false(二期),一期恒为 true。 */
+ private Boolean needClassAudit = Boolean.TRUE;
+
+ /** 是否需管理员终审。 */
+ private Boolean needAdminAudit = Boolean.TRUE;
+
+ /** 初审超时提醒(小时)。 */
+ private Integer classAuditTimeoutHours = 48;
+
+ /** 终审超时提醒(小时)。 */
+ private Integer adminAuditTimeoutHours = 72;
+
+ /** 驳回次数上限,超限升级管理员。 */
+ private Integer maxRejectCount = 3;
+
+ /** 允许班主任代传。 */
+ private Boolean allowProxyUpload = Boolean.TRUE;
+
+ /**
+ * 批量通过只带无问题项。
+ *
+ * ⚠️ 这是铁律,不允许关闭(交接摘要铁律 9:批量通过只带无问题项,红黄一律排除)。
+ * 字段保留只是为了契约形状一致 —— 客户端传 {@code false} 会被服务端强制改回 {@code true}。
+ * 做成"可配置"就等于给"手快全选"留了一扇门,而误审的代价由学生承担。
+ */
+ private Boolean batchApproveOnlyGreen = Boolean.TRUE;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/CompressConfigVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/CompressConfigVO.java
new file mode 100644
index 0000000..9dd32db
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/CompressConfigVO.java
@@ -0,0 +1,44 @@
+package cn.tianbaotong.fill.module.task.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 压缩配置(《03-接口设计》§3.11)。
+ *
+ * 默认值来自真实基准数据集的实测结论(300dpi / q85 → 28/28 达标、
+ * 平均 806KB、最大 831KB、清晰度零损失),不是拍脑袋定的。
+ *
+ * ⚠️ {@code defaultMaxSizeKb} 是无例外的硬要求(D1 决策):认定机构只看体积,
+ * 扫描件超过 1M 会被直接退回来。所以这个值不提供"任务级放宽"的入口。
+ */
+@Getter
+@Setter
+@Schema(description = "压缩参数")
+public class CompressConfigVO {
+
+ /** 全局体积上限(KB)。D1 决策:无例外。 */
+ private Integer defaultMaxSizeKb = 1024;
+
+ /** A4 扫描件目标 DPI。 */
+ private Integer defaultTargetDpi = 300;
+
+ /** 默认质量。 */
+ private Integer defaultQuality = 85;
+
+ /** 质量下限(低于此值标记"强制压缩")。 */
+ private Integer minQuality = 70;
+
+ /** DPI 硬下限。 */
+ private Integer hardFloorDpi = 200;
+
+ /** 超大图熔断阈值(像素)。5000 万像素以上转后台处理,不走同步链路。 */
+ private Integer maxPixels = 50_000_000;
+
+ /** 截图类保持 PNG 不转 JPG(实测学籍表是 16:9 截图,转 JPG 会糊掉表格线)。 */
+ private Boolean keepPngForScreenshot = Boolean.TRUE;
+
+ /** 强制压缩时打标记进抽检清单。 */
+ private Boolean markWhenForced = Boolean.TRUE;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskActionReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskActionReq.java
new file mode 100644
index 0000000..51e0e65
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskActionReq.java
@@ -0,0 +1,44 @@
+package cn.tianbaotong.fill.module.task.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 任务动作请求(发布 / 暂停 / 恢复 / 截止 / 归档 共用)。
+ *
+ * 五种动作的请求体都很小,且字段互不冲突,所以合成一个对象而不是五个类 ——
+ * 五个几乎空着的类只会让 Controller 的签名更长、更难读。
+ * 各字段的适用范围在下面逐条写明。
+ */
+@Getter
+@Setter
+@Schema(description = "任务动作(发布/暂停/恢复/截止/归档)")
+public class TaskActionReq {
+
+ /**
+ * 是否强制({@code publish} / {@code close} 使用)。
+ *
+ * ⚠️ 只能跳过 P1 级警告,跳过不了阻断级校验。
+ * 发布前的 5 项检查(名册非空、名册含关键列、至少 1 项必传材料、
+ * 已设置覆盖班级与班主任、命名规则试跑通过)全部是阻断级,
+ * {@code force = true} 也不会放行 —— 这五项不通过就发布,
+ * 学生端会出现"填了却没人审"或"文件名根本生成不出来"。
+ */
+ @Schema(description = "是否强制跳过 P1 级警告;阻断级校验不受影响")
+ private Boolean force;
+
+ /** {@code publish}:已确认的 P1 警告码。 */
+ @Schema(description = "已确认的警告码")
+ private List 只回这三个字段:任务建出来必定是草稿,前端拿 {@code id} 继续走向导
+ * (配表单 → 配材料 → 导名册 → 发布),所以这里不需要回整个任务对象。
+ */
+@Getter
+@Setter
+@Schema(description = "建任务结果")
+public class TaskCreateVO {
+
+ private Long id;
+
+ /** 任务编码,形如 {@code 2026090001};同时用于服务端生成文件目录。 */
+ private String taskCode;
+
+ /** 必定是 {@code 0}(草稿)。 */
+ private Integer status;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskStatVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskStatVO.java
new file mode 100644
index 0000000..507c563
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskStatVO.java
@@ -0,0 +1,54 @@
+package cn.tianbaotong.fill.module.task.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 任务统计快照(《03-接口设计》§3.3)。
+ *
+ * ⚠️ M1-01 阶段该对象恒为 {@code null},不是"忘了填":
+ * 这里的每一个数字都要求名册({@code collect_roster},M2)与
+ * 提交单({@code collect_submission},M3)已经存在。
+ * 现在返回 0 或 "0%" 会是一个更难发现的谎 —— 界面会显示"完成率 0%",
+ * 而真实情况是"还没导名册,根本没有分母"。所以宁可返回 {@code null},
+ * 由界面显示「待导入名册」,把真实状态说清楚。
+ *
+ * 字段先按契约定好,M2 接上名册后开始有值。
+ */
+@Getter
+@Setter
+@Schema(description = "任务统计快照(M2 起才有值)")
+public class TaskStatVO {
+
+ /** 应填人数(名册行数)。 */
+ private Integer rosterTotal;
+
+ private Integer submittedCount;
+
+ private Integer approvedCount;
+
+ private Integer pendingCount;
+
+ private Integer rejectedCount;
+
+ private Integer unsubmittedCount;
+
+ /**
+ * 已提交率({@code submittedCount / rosterTotal})。
+ *
+ * ⚠️ 与 {@link #passRate} 必须分开报:原型定稿的口径是
+ * 「完成率 82% 是已提交率 23/28」。已提交 ≠ 已通过,
+ * 合并成一个数字会让"看起来快完成了"和"快通过了"混为一谈。
+ */
+ private String submitRate;
+
+ /** 通过率({@code approvedCount / rosterTotal})。 */
+ private String passRate;
+
+ /** 有红/黄问题的单数。 */
+ private Integer abnormalCount;
+
+ /** 平均处理时长(小时)。 */
+ private Double avgAuditHours;
+}
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
new file mode 100644
index 0000000..1d347d3
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskUpsertReq.java
@@ -0,0 +1,86 @@
+package cn.tianbaotong.fill.module.task.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 任务新增 / 修改请求(《03-接口设计》§7.2 / §7.5)。
+ *
+ * {@code collectMode} 与 {@code certType} 在发布后不可改:
+ * 它们决定审核流(有无班主任初审)与材料清单,改了会让已经填过的学生
+ * 面对一份不同的表单。服务端在 {@code update} 时会拒绝改动这两个字段。
+ */
+@Getter
+@Setter
+@Schema(description = "任务新增或修改")
+public class TaskUpsertReq {
+
+ @NotBlank(message = "请填写任务名称")
+ @Size(max = 200, message = "任务名称过长")
+ @Schema(description = "任务名称", example = "2026年9月机电专业认定材料收集",
+ requiredMode = Schema.RequiredMode.REQUIRED)
+ private String taskName;
+
+ @NotNull(message = "请选择收集模式")
+ @Schema(description = "1 学校收集 / 2 社会收集(二期,传 2 返回 40044)", example = "1",
+ requiredMode = Schema.RequiredMode.REQUIRED)
+ private Integer collectMode;
+
+ @NotBlank(message = "请填写批次号")
+ @Size(max = 64)
+ @Pattern(regexp = "^\\d{4,8}$", message = "批次号应为 4~8 位数字,如 202609")
+ @Schema(description = "批次号,同时作为任务编码前缀", example = "202609",
+ requiredMode = Schema.RequiredMode.REQUIRED)
+ private String batchNo;
+
+ @NotBlank(message = "请填写专业名称")
+ @Size(max = 64)
+ @Schema(description = "专业", example = "数控加工", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String majorName;
+
+ /** §7.2 的必填表里没有这两项,但 §3.1 的 TaskVO 有 —— 属于"建任务时可留空、后续补"。 */
+ @Size(max = 64, message = "工种名称过长")
+ @Schema(description = "报考工种(可后补)", example = "数控车工")
+ private String jobName;
+
+ @Size(max = 32, message = "等级过长")
+ @Schema(description = "报考等级(可后补)", example = "四级")
+ private String jobLevel;
+
+ @NotNull(message = "请选择认定类型")
+ @Schema(description = "1 自主认定 / 2 社会评价(二期)/ 3 专项职业能力(二期);一期只支持 1",
+ example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Integer certType;
+
+ @NotBlank(message = "请选择填报开始时间")
+ @Schema(description = "填报开始,如 2026-09-28 或 2026-09-28T09:00:00", example = "2026-09-28",
+ requiredMode = Schema.RequiredMode.REQUIRED)
+ private String startTime;
+
+ @NotBlank(message = "请选择填报截止时间")
+ @Schema(description = "填报截止,只给日期时按当天 23:59:59 处理",
+ example = "2026-10-15", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String endTime;
+
+ @Schema(description = "审核流配置。不传按收集模式取默认值(见 §3.2)")
+ private AuditConfigVO auditConfig;
+
+ @Size(max = 256, message = "命名模板过长")
+ @Schema(description = "命名模板,不传取默认 {姓名}-{证件号}-{材料名}",
+ example = "{姓名}-{证件号}-{材料名}")
+ private String namingTemplate;
+
+ @Schema(description = "压缩配置。不传取默认(见 §3.11)")
+ private CompressConfigVO compressConfig;
+
+ @Schema(description = "从表单模板库初始化(模板库属 M1-03,当前传了会被忽略)")
+ private Long formTemplateId;
+
+ @Schema(description = "从材料模板初始化(模板库属 M1-04,当前传了会被忽略)")
+ private Long materialTemplateId;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskVO.java
new file mode 100644
index 0000000..42dd187
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskVO.java
@@ -0,0 +1,67 @@
+package cn.tianbaotong.fill.module.task.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 任务视图(《03-接口设计》§3.1)。
+ */
+@Getter
+@Setter
+@Schema(description = "收集任务")
+public class TaskVO {
+
+ private Long id;
+
+ /** 任务编码,形如 {@code 2026090001}。 */
+ private String taskCode;
+
+ private String taskName;
+
+ /** {@code 1} 学校收集 / {@code 2} 社会收集(二期)。 */
+ private Integer collectMode;
+
+ private String batchNo;
+
+ private String majorName;
+
+ private String jobName;
+
+ private String jobLevel;
+
+ /** {@code 1} 自主认定 / {@code 2} 社会评价(二期)/ {@code 3} 专项职业能力(二期)。 */
+ private Integer certType;
+
+ private LocalDateTime startTime;
+
+ private LocalDateTime endTime;
+
+ private AuditConfigVO auditConfig;
+
+ private String namingTemplate;
+
+ private CompressConfigVO compressConfig;
+
+ /** 是否强制门禁。当前恒为 true —— 名单门禁是产品根基,不提供关闭入口。 */
+ private Boolean rosterRequired;
+
+ /** {@code 0} 草稿 / {@code 1} 收集中 / {@code 2} 已截止 / {@code 3} 已归档 / {@code 4} 已暂停。 */
+ private Integer status;
+
+ private LocalDateTime publishedTime;
+
+ /**
+ * 统计快照。
+ *
+ * ⚠️ M1 阶段恒为 {@code null} —— 它依赖名册(M2)与提交单(M3)。
+ * 这里不返回"全是 0 的对象",因为界面会把它显示成「完成率 0%」,
+ * 而真实情况是"还没导名册、没有分母"。{@code null} 会显示成「待导入名册」,
+ * 把真实状态说清楚。详见 {@link TaskStatVO}。
+ */
+ private TaskStatVO stat;
+
+ private LocalDateTime createTime;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java
new file mode 100644
index 0000000..49a760e
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java
@@ -0,0 +1,91 @@
+package cn.tianbaotong.fill.module.task.entity;
+
+import cn.tianbaotong.fill.support.TenantEntity;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 收集任务(对应 {@code collect_task})。
+ *
+ * 状态机({@code status}):
+ * ⚠️ 「暂停」与「截止」不是同一件事(《03-接口设计》§7.4):
+ * JSON 字段:{@code audit_config} / {@code compress_config} 在库里是 {@code jsonb}。
+ * 实体里存原始 JSON 字符串(JDBC URL 带 {@code stringtype=unspecified},
+ * 写 jsonb 列时 PG 会自行推断),**读写都在 Service 层用 Jackson 转换**,
+ * 不要在实体上挂 TypeHandler —— 那样 Swagger 里就只剩一坨字符串了。
+ */
+@Getter
+@Setter
+@TableName("collect_task")
+public class CollectTask extends TenantEntity {
+
+ /** 任务编码,形如 {@code 2026090001},用于文件路径。租户内唯一。 */
+ private String taskCode;
+
+ private String taskName;
+
+ /** {@code 1} 学校收集 / {@code 2} 社会收集(二期)。发布后不可改。 */
+ private Integer collectMode;
+
+ /** 批次号,如 {@code 202609}。 */
+ private String batchNo;
+
+ private String majorName;
+
+ /** 报考工种。 */
+ private String jobName;
+
+ /** 报考等级。 */
+ private String jobLevel;
+
+ /** {@code 1} 自主认定 / {@code 2} 社会评价(二期)/ {@code 3} 专项职业能力(二期)。 */
+ private Integer certType;
+
+ private LocalDateTime startTime;
+
+ private LocalDateTime endTime;
+
+ /** 审核流配置,jsonb 原文。 */
+ private String auditConfig;
+
+ /** 命名模板,如 {@code {姓名}-{证件号}-{材料名}}。 */
+ private String namingTemplate;
+
+ /** 任务级压缩默认参数,jsonb 原文。 */
+ private String compressConfig;
+
+ /** 是否需要名册:固定为 1(两种收集模式都必填,名单门禁是产品根基)。 */
+ private Integer rosterRequired;
+
+ /** {@code 0} 草稿 / {@code 1} 收集中 / {@code 2} 已截止 / {@code 3} 已归档 / {@code 4} 已暂停。 */
+ private Integer status;
+
+ private LocalDateTime publishedTime;
+
+ // ── 状态常量 ──────────────────────────────────
+ // 用常量而不是散落的魔法数字:状态机改一处漏一处是最难查的一类 bug。
+
+ public static final int STATUS_DRAFT = 0;
+ public static final int STATUS_COLLECTING = 1;
+ public static final int STATUS_CLOSED = 2;
+ public static final int STATUS_ARCHIVED = 3;
+ public static final int STATUS_PAUSED = 4;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTaskOrg.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTaskOrg.java
new file mode 100644
index 0000000..9af0655
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTaskOrg.java
@@ -0,0 +1,66 @@
+package cn.tianbaotong.fill.module.task.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 任务覆盖组织与审核人绑定(对应 {@code collect_task_org})。
+ *
+ * 「任务覆盖了哪些班级、每班的初审人是谁」都在这张表里。
+ * 发布任务前必须至少有一条生效记录(否则班主任不知道该审谁、学生也不知道该不该填)。
+ *
+ * ⚠️ 本实体刻意不继承 {@code TenantEntity} / {@code BaseEntity}:
+ * {@code collect_task_org} 没有 {@code deleted} 列(DDL 如此,见
+ * {@code V1__business_tables.sql}),而基类上的 {@code @TableLogic} 会让
+ * MyBatis-Plus 给每条查询自动加上 {@code deleted = 0} —— 那会直接报
+ * "column cto.deleted does not exist",且只在运行时才暴露。
+ *
+ * 因此这张表的"移除"语义走 {@code status = 0}(DDL 注释:0已移除 / 1生效),
+ * 不要对它调 {@code deleteById},那会变成物理删除,历史就查不到了。
+ */
+@Getter
+@Setter
+@TableName("collect_task_org")
+public class CollectTaskOrg implements Serializable {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField(value = "tenant_id", fill = FieldFill.INSERT)
+ private Long tenantId;
+
+ private Long taskId;
+
+ private Long orgId;
+
+ /** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
+ private Long headUserId;
+
+ /**
+ * 副班主任 / 协办人,逗号分隔。
+ *
+ * ⚠️ 存的是首尾都带逗号的形式,如 {@code ,3,7,},不是 {@code 3,7}。
+ * 原因:用 {@code LIKE '%,3,%'} 精确匹配某个 id 时,
+ * 裸逗号分隔的 {@code 3,17} 会被子串误命中。
+ * 首尾补逗号后每个 id 都被逗号夹住,不可能误匹配 ——
+ * 这是个很小的选择,但错了会表现为"莫名其妙的协办人能看到别人的班"。
+ */
+ private String deputyUserIds;
+
+ /** 应填人数(冗余,由名册汇总)。 */
+ private Integer expectedCount;
+
+ /** {@code 0} 已移除 / {@code 1} 生效。 */
+ private Integer status;
+
+ @TableField(value = "create_time", fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskMapper.java
new file mode 100644
index 0000000..ab85e5f
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskMapper.java
@@ -0,0 +1,85 @@
+package cn.tianbaotong.fill.module.task.mapper;
+
+import cn.tianbaotong.fill.module.task.entity.CollectTask;
+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;
+
+import java.util.List;
+
+public interface TaskMapper extends BaseMapper 谁需要这个:班主任与副班主任。《03-接口设计》§7.1 明确
+ * 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
+ * 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
+ *
+ * 初审人的判定顺序与 DDL 注释一致:{@code cto.head_user_id} 优先,
+ * 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
+ * 不该要求每条都显式指定一遍。
+ *
+ * 副班主任走 {@code deputy_user_ids} 的逗号匹配。该列存的是
+ * 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
+ *
+ * 为什么显式传 tenantId 并关掉拦截器:这是一条权限过滤用的查询。
+ * 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
+ * 结果就是"A 校班主任的列表里出现 B 校的任务"。这类查询把租户条件写成显式参数,
+ * 让"哪个租户"只有一个来源,不依赖"拦截器应该会加上"。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT DISTINCT cto.task_id
+ FROM collect_task_org cto
+ LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
+ WHERE cto.status = 1
+ AND cto.tenant_id = #{tenantId}
+ AND (
+ COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
+ OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
+ )
+ """)
+ List ⚠️ 这里故意不加 {@code deleted = 0},有三个原因,每一个都是踩过的坑:
+ * {@code LIKE prefix || '____'}(4 个下划线 = 恰好 4 个字符)
+ * 是为了让 {@code 20260} 与 {@code 2026} 这类前缀不互相误伤。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COALESCE(MAX(SUBSTRING(task_code FROM #{prefixLen} + 1 FOR 4)::int), 0)
+ FROM collect_task
+ WHERE tenant_id = #{tenantId}
+ AND task_code LIKE #{prefix} || '____'
+ """)
+ Integer maxSeqByCodePrefix(@Param("tenantId") Long tenantId,
+ @Param("prefix") String prefix,
+ @Param("prefixLen") int prefixLen);
+
+ /** 某编码是否已被占用(含软删行,与唯一约束的口径一致)。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_task
+ WHERE tenant_id = #{tenantId} AND task_code = #{taskCode}
+ """)
+ Integer countByCodeIncludingDeleted(@Param("tenantId") Long tenantId,
+ @Param("taskCode") String taskCode);
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskOrgMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskOrgMapper.java
new file mode 100644
index 0000000..6182236
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskOrgMapper.java
@@ -0,0 +1,13 @@
+package cn.tianbaotong.fill.module.task.mapper;
+
+import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+/**
+ * 任务覆盖组织({@code collect_task_org})。
+ *
+ * M1-01 只用到基础 CRUD(发布前置校验要判断"是否已设置覆盖班级");
+ * 批量设置、应填人数汇总等留给 M1-02。
+ */
+public interface TaskOrgMapper extends BaseMapper 为什么单独一个 Mapper,而不是塞进 {@code TaskMapper} 或各建各的:
+ * 发布前置校验(《03-接口设计》§7.3 的 5 项)本质上就是跨表体检 ——
+ * 它要知道名册、材料、覆盖班级三张表的状态。
+ * 把这些查询摊进 {@code TaskMapper} 会让"任务本身的 CRUD"和
+ * "任务能不能发布"混在一起;而让 {@code TaskService} 直接注入名册/材料各自的
+ * Mapper,又会让任务模块反向依赖那两个模块(它们的完整实现还在 M2 / M1-04)。
+ *
+ * 所以:这里只放计数,不放任何业务逻辑。等 M2 的 {@code RosterMapper}、
+ * M1-04 的 {@code MaterialDefMapper} 齐了,这些查询可以按归属搬走,
+ * 也可以留着 —— 它们表达的是"发布条件",与"名册怎么增删"本就不同一件事。
+ *
+ * ⚠️ 表 {@code collect_task_org} 没有 {@code deleted} 列,
+ * 所以那两条查询只能用 {@code status} 过滤,不要照抄别的表的 {@code deleted = 0}。
+ */
+public interface TaskReadinessMapper {
+
+ /** 名册行数。为 0 时发布必须被拦({@code 40016})。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_roster
+ WHERE task_id = #{taskId} AND deleted = 0
+ """)
+ Long countRoster(@Param("taskId") Long taskId);
+
+ /**
+ * 名册里同时有姓名和证件号的行数。
+ *
+ * 用于 {@code 40017}「名册缺少「姓名」或「身份证号」列」。
+ * 这两列在名册表里都是 NOT NULL,所以正常情况下两者相等;
+ * 但只要出现 {@code count < countRoster},就说明有一批行是残缺的
+ * (比如导入时列错位、或某列整列为空),继续发布会让这批人校验不过也说不清原因。
+ * 因此这个检查不能省成"名册非空就行"。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_roster
+ WHERE task_id = #{taskId} AND deleted = 0
+ AND real_name IS NOT NULL AND btrim(real_name) <> ''
+ AND id_card_hash IS NOT NULL AND btrim(id_card_hash) <> ''
+ """)
+ Long countRosterWithKeyColumns(@Param("taskId") Long taskId);
+
+ /** 必传材料项数量。为 0 时发布必须被拦({@code 40018})。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_material_def
+ WHERE task_id = #{taskId} AND deleted = 0 AND required = 1
+ """)
+ Long countRequiredMaterials(@Param("taskId") Long taskId);
+
+ /** 已覆盖的班级数(有效记录)。为 0 时发布必须被拦({@code 40019})。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_task_org
+ WHERE task_id = #{taskId} AND status = 1
+ """)
+ Long countActiveOrgs(@Param("taskId") Long taskId);
+
+ /**
+ * 没有初审人的班级数 —— 这是 P1 级警告,不是阻断项。
+ *
+ * 判定与 DDL 注释一致:{@code collect_task_org.head_user_id} 优先,
+ * 为空则回退到 {@code sys_org.leader_user_id}。两者都没有才算"没人审"。
+ * 为什么允许带警告发布:真要卡死"每班必须显式指定初审人",
+ * 几百个班的学校会被逼着机械地点几百次,而其中绝大多数本来就等于班级负责人。
+ * 提示出来让人确认即可({@code force} 可跳过)。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_task_org cto
+ LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
+ WHERE cto.task_id = #{taskId}
+ AND cto.status = 1
+ AND COALESCE(cto.head_user_id, o.leader_user_id) IS NULL
+ """)
+ Long countOrgsWithoutHead(@Param("taskId") Long taskId);
+
+ /**
+ * 待审的提交单数,用于归档前置校验({@code 40023})。
+ *
+ * {@code collect_submission.status} 的口径是
+ * {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 /
+ * {@code 4} 已驳回 / {@code 5} 已归档,所以"待审"是 {@code IN (1, 2)}。
+ *
+ * M1 阶段这个数必然是 0(还没有填报功能,表是空的),
+ * 但这条查询是真的,不是占位 —— M3 接上填报后它自动开始有意义。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_submission
+ WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
+ """)
+ Long countPendingAudit(@Param("taskId") Long taskId);
+}
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
new file mode 100644
index 0000000..a6dfbec
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java
@@ -0,0 +1,743 @@
+package cn.tianbaotong.fill.module.task.service;
+
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.api.PageResult;
+import cn.tianbaotong.fill.common.context.LoginUser;
+import cn.tianbaotong.fill.common.context.UserContext;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.module.task.dto.AuditConfigVO;
+import cn.tianbaotong.fill.module.task.dto.CompressConfigVO;
+import cn.tianbaotong.fill.module.task.dto.TaskActionReq;
+import cn.tianbaotong.fill.module.task.dto.TaskCreateVO;
+import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
+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.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 收集任务服务(《03-接口设计》§7.1 ~ §7.5)。
+ *
+ * 本类的三处硬约束(改动时当成规则,不要当成"可以商量的校验"):
+ * 来源:《PRD》§命名规则配置的"可用变量"一栏。
+ * 多一个会生成出 {@code {未知变量}} 这种字面量文件名,少一个会拒掉合法模板,
+ * 所以这份清单必须与 PRD 逐字对齐。
+ */
+ private static final Set 来源:《PRD》§5.6「命名模板必须含姓名/证件号/材料名」。
+ * 缺了任意一个,文件名就无法唯一归属到人 —— 而"文件归属"正是这个产品的根基
+ * (实测 52.7% 的原始文件名是违规的,靠的正是这三个变量重建归属)。
+ */
+ private static final List 可见范围(§7.1):管理员(学校管理员 / 审核员)看全租户;
+ * 班主任、副班主任只看自己带的班级的任务。
+ * 后者不是"少显示几行"的问题 —— 让一个班主任看到全校的任务名与批次,
+ * 本身就是一次越权。
+ */
+ public PageResult 一期只做「学校收集 + 自主认定」。
+ * 数据结构保留、只是不启用 —— 所以这里拦的是"启用",传了二期取值返回 {@code 40044},
+ * 而不是把字段删掉或改成只能填 1。二期直接放开发就行,不需要改库。
+ */
+ private void assertScopeSupported(TaskUpsertReq req) {
+ if (req.getCollectMode() == null || req.getCollectMode() != 1) {
+ throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
+ "一期仅支持学校收集(collectMode=1)");
+ }
+ if (req.getCertType() == null || req.getCertType() != 1) {
+ throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
+ "一期仅支持自主认定(certType=1)");
+ }
+ }
+
+ // ── 修改 ──────────────────────────────────────
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, TaskUpsertReq req) {
+ CollectTask exist = requireTask(id);
+
+ // 模式与认定类型决定审核流和材料清单,改了会让已填过的学生面对另一份表单
+ if (exist.getStatus() != null && exist.getStatus() != CollectTask.STATUS_DRAFT) {
+ if (req.getCollectMode() != null && !req.getCollectMode().equals(exist.getCollectMode())) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "任务已发布,收集模式不可修改");
+ }
+ if (req.getCertType() != null && !req.getCertType().equals(exist.getCertType())) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "任务已发布,认定类型不可修改");
+ }
+ } else {
+ assertScopeSupported(req);
+ }
+
+ LocalDateTime start = parseDateTime(req.getStartTime(), false);
+ LocalDateTime end = parseDateTime(req.getEndTime(), true);
+ assertTimeRange(start, end);
+
+ String namingTemplate = req.getNamingTemplate() == null || req.getNamingTemplate().isBlank()
+ ? exist.getNamingTemplate()
+ : req.getNamingTemplate().trim();
+ assertNamingTemplate(namingTemplate);
+
+ CollectTask patch = new CollectTask();
+ patch.setId(id);
+ patch.setTaskName(req.getTaskName().trim());
+ patch.setBatchNo(req.getBatchNo().trim());
+ patch.setMajorName(req.getMajorName().trim());
+ patch.setJobName(trimToNull(req.getJobName()));
+ patch.setJobLevel(trimToNull(req.getJobLevel()));
+ patch.setStartTime(start);
+ patch.setEndTime(end);
+ patch.setNamingTemplate(namingTemplate);
+ if (req.getAuditConfig() != null) {
+ patch.setAuditConfig(writeJson(withAuditDefaults(req.getAuditConfig())));
+ }
+ if (req.getCompressConfig() != null) {
+ patch.setCompressConfig(writeJson(withCompressDefaults(req.getCompressConfig())));
+ }
+ if (exist.getStatus() != null && exist.getStatus() == CollectTask.STATUS_DRAFT) {
+ patch.setCollectMode(req.getCollectMode());
+ patch.setCertType(req.getCertType());
+ }
+ taskMapper.updateById(patch);
+ }
+
+ // ── 删除(仅草稿)────────────────────────────
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id) {
+ CollectTask exist = requireTask(id);
+ if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
+ }
+ taskMapper.deleteById(id);
+ log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
+ }
+
+ // ── 发布 ──────────────────────────────────────
+
+ /**
+ * 发布(§7.3)。
+ *
+ * 五项前置校验全是阻断级,{@code force} 无法绕过。
+ * 这里一次性把所有不通过的项都收集起来再抛,而不是撞到第一个就返回 ——
+ * 配一个几百人的任务要反复试五次才知道还差什么,是很难受的。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public PublishResult publish(Long id, TaskActionReq req) {
+ CollectTask task = requireTask(id);
+ assertStatus(task, CollectTask.STATUS_DRAFT, "只有草稿状态的任务可以发布");
+
+ List 与「截止」的区别:暂停后学生不能提交,但已提交的照常审核,
+ * 而且可以恢复。典型场景是"名单要改、规则要调,先冻住入口"。
+ * 幂等:已经是暂停状态时直接返回成功,不报错 —— 重复点按钮不该看到红色提示。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void pause(Long id, TaskActionReq req) {
+ CollectTask task = requireTask(id);
+ int status = task.getStatus() == null ? -1 : task.getStatus();
+ if (status == CollectTask.STATUS_PAUSED) {
+ return; // 幂等
+ }
+ if (status != CollectTask.STATUS_COLLECTING) {
+ throw new BizException(ErrorCode.ONLY_PUBLISHED_CAN_PAUSE);
+ }
+ patchStatus(id, CollectTask.STATUS_PAUSED);
+ log.info("暂停任务 {}(id={})原因:{}", task.getTaskCode(), id,
+ req == null || req.getReason() == null ? "(未填写)" : req.getReason());
+ }
+
+ /** 恢复。幂等:已经是收集中时直接返回成功。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void resume(Long id) {
+ CollectTask task = requireTask(id);
+ int status = task.getStatus() == null ? -1 : task.getStatus();
+ if (status == CollectTask.STATUS_COLLECTING) {
+ return; // 幂等
+ }
+ if (status != CollectTask.STATUS_PAUSED) {
+ throw new BizException(ErrorCode.ONLY_PAUSED_CAN_RESUME);
+ }
+ patchStatus(id, CollectTask.STATUS_COLLECTING);
+ log.info("恢复任务 {}(id={})", task.getTaskCode(), id);
+ }
+
+ // ── 截止 / 归档(§7.5)───────────────────────
+
+ /** 截止:不可逆。收集中或已暂停都可以截止 —— 两者都还没到"结束"状态。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void close(Long id, TaskActionReq req) {
+ CollectTask task = requireTask(id);
+ int status = task.getStatus() == null ? -1 : task.getStatus();
+ if (status == CollectTask.STATUS_CLOSED) {
+ return; // 幂等
+ }
+ if (status != CollectTask.STATUS_COLLECTING && status != CollectTask.STATUS_PAUSED) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "只有收集中或已暂停的任务可以截止");
+ }
+ patchStatus(id, CollectTask.STATUS_CLOSED);
+ log.info("截止任务 {}(id={})", task.getTaskCode(), id);
+ }
+
+ /**
+ * 归档:不可逆,且要求没有待审单({@code 40023})。
+ *
+ * 归档后连审核都不行(只读)。若还有人在等审核就归档,那些单子会永远悬着 ——
+ * 学生看到"已提交",系统里却再也没人能处理。所以这条必须拦。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void archive(Long id, TaskActionReq req) {
+ CollectTask task = requireTask(id);
+ int status = task.getStatus() == null ? -1 : task.getStatus();
+ if (status == CollectTask.STATUS_ARCHIVED) {
+ return; // 幂等
+ }
+ if (status != CollectTask.STATUS_CLOSED) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "只有已截止的任务可以归档");
+ }
+ long pending = nz(readinessMapper.countPendingAudit(id));
+ if (pending > 0) {
+ throw new BizException(ErrorCode.PENDING_AUDIT_EXISTS,
+ "还有 " + pending + " 单在等待审核,归档后将无法处理,请先审完");
+ }
+ patchStatus(id, CollectTask.STATUS_ARCHIVED);
+ log.info("归档任务 {}(id={})备注:{}", task.getTaskCode(), id,
+ req == null ? null : req.getArchiveRemark());
+ }
+
+ // ── 内部 ──────────────────────────────────────
+
+ private CollectTask requireTask(Long id) {
+ CollectTask t = taskMapper.selectById(id);
+ if (t == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
+ }
+ return t;
+ }
+
+ private void assertStatus(CollectTask task, int expected, String message) {
+ if (task.getStatus() == null || task.getStatus() != expected) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, message);
+ }
+ }
+
+ private void patchStatus(Long id, int status) {
+ CollectTask patch = new CollectTask();
+ patch.setId(id);
+ patch.setStatus(status);
+ taskMapper.updateById(patch);
+ }
+
+ private void assertTimeRange(LocalDateTime start, LocalDateTime end) {
+ if (end.isBefore(start) || end.isEqual(start)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "截止时间必须晚于开始时间");
+ }
+ }
+
+ /**
+ * 生成任务编码:批次号 + 4 位序号。
+ *
+ * 序号按同批次已有任务数 + 1 取。并发下两次请求可能算出同一个号,
+ * 所以靠 {@code uk_task_tenant_code} 唯一约束兜底,撞了就重试。
+ * 不引独立的序列表:这个量级(一个学校一学期几十个任务)不值得多一张表,
+ * 而唯一约束 + 重试已经把正确性保证了。
+ */
+ /**
+ * 生成任务编码:批次号 + 4 位序号,如 {@code 2026090001}。
+ *
+ * 为什么是"最大序号 + 1"而不是"数量 + 1",以及
+ * 为什么查重必须绕过逻辑删除:见 {@code TaskMapper.maxSeqByCodePrefix} 的注释。
+ * 简单说:任务编码同时是文件目录名,用过就不该复用;
+ * 而软删掉的任务仍然占着唯一约束,只查"未删除的"会算出已被占用的号。
+ *
+ * 残留风险:两个请求同时到达时可能算出同一个号,由唯一约束兜底
+ * ({@code create} 里捕获了 {@code DuplicateKeyException} 并返回可读提示,
+ * 而不是 500)。一个学校一学期建几十个任务,撞号概率极低,
+ * 为它引入独立的序列表或分布式锁不划算 —— 但这一点要写下来,别当成"已经解决了"。
+ */
+ private String nextTaskCode(String batchNo) {
+ Long tenantId = UserContext.tenantId();
+ int start = nzInt(taskMapper.maxSeqByCodePrefix(tenantId, batchNo, batchNo.length())) + 1;
+ for (int i = 0; i < 200; i++) {
+ String code = batchNo + String.format("%04d", start + i);
+ if (nzInt(taskMapper.countByCodeIncludingDeleted(tenantId, code)) == 0) {
+ return code;
+ }
+ }
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "批次 " + batchNo + " 的可用编码已用尽,请更换批次号");
+ }
+
+ private static int nzInt(Integer v) {
+ return v == null ? 0 : v;
+ }
+
+ /** 兼容 {@code 2026-09-28} 与 {@code 2026-09-28T09:00:00} 两种写法。 */
+ private static LocalDateTime parseDateTime(String raw, boolean endOfDay) {
+ if (raw == null || raw.isBlank()) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报起止时间不能为空");
+ }
+ String s = raw.trim();
+ try {
+ if (s.length() <= 10) {
+ LocalDate d = LocalDate.parse(s);
+ return endOfDay ? d.atTime(LocalTime.of(23, 59, 59)) : d.atStartOfDay();
+ }
+ return LocalDateTime.parse(s);
+ } catch (Exception e) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "时间格式不正确,应为 2026-09-28 或 2026-09-28T09:00:00:" + s);
+ }
+ }
+
+ /**
+ * 命名模板校验。
+ *
+ * 两件事:① 必须含姓名/证件号/材料名(PRD §5.6);
+ * ② 所有占位符都必须在允许集合里 —— 写错一个变量名(比如 {@code {身证号}})
+ * 不会报错,只会让每个文件名都带上一个字面量 {@code {身证号}},
+ * 而且要到导出的那一刻才会被发现。
+ *
+ * ⚠️ 这里是静态校验。§7.3 里"命名规则试跑通过"的完整形态是
+ * 拿真实名册的前若干行跑一遍,检查生成的名字非空且两两不重复 ——
+ * 那需要名册数据,属 M1-09 / M3-01。当前这条已经能拦住绝大多数错误
+ * (变量名写错、漏掉必备变量),且名册为空时发布本来就会被 {@code 40016} 先拦住。
+ */
+ private void assertNamingTemplate(String template) {
+ String problem = checkNamingTemplate(template);
+ if (problem != null) {
+ throw new BizException(ErrorCode.NAMING_DRYRUN_FAILED, problem);
+ }
+ }
+
+ /** @return 不合规的原因;合规返回 {@code null} */
+ private String checkNamingTemplate(String template) {
+ if (template == null || template.isBlank()) {
+ return "命名模板不能为空";
+ }
+ Set 原因:Boot 4 的自动配置用的是 Jackson 3({@code tools.jackson}),
+ * 而 {@code jjwt-jackson} 又把 Jackson 2({@code com.fasterxml.jackson})
+ * 带上了 classpath —— 打包后 jar 里同时躺着
+ * {@code jackson-databind-2.21.5.jar} 与 {@code jackson-databind-3.1.5.jar}。
+ * 这种情况下无论 {@code import} 哪一个,都是在赌"哪个版本先被解析到",
+ * 而且失败方式很隐蔽({@code NoClassDefFoundError} 只在跑到时才出现)。
+ *
+ * {@code hutool-all} 是本项目在 {@code xt-common} 里直接声明的依赖
+ * (验证码就在用它),不存在版本竞争;而任务配置里只有布尔、整数与字符串,
+ * 不涉及日期格式与时区,任何 JSON 库都够用。
+ */
+ private static String writeJson(Object o) {
+ return JSONUtil.toJsonStr(o);
+ }
+
+ private static AuditConfigVO readAuditConfig(String json) {
+ if (json == null || json.isBlank()) {
+ return withAuditDefaults(null);
+ }
+ try {
+ return withAuditDefaults(JSONUtil.toBean(json, AuditConfigVO.class));
+ } catch (Exception e) {
+ // 库里的 JSON 坏了不该让整个列表 500;退回默认值并在日志里留痕
+ log.warn("任务审核配置解析失败,已退回默认值:{}", e.getMessage());
+ return withAuditDefaults(null);
+ }
+ }
+
+ private static CompressConfigVO readCompressConfig(String json) {
+ if (json == null || json.isBlank()) {
+ return withCompressDefaults(null);
+ }
+ try {
+ return withCompressDefaults(JSONUtil.toBean(json, CompressConfigVO.class));
+ } catch (Exception e) {
+ log.warn("任务压缩配置解析失败,已退回默认值:{}", e.getMessage());
+ return withCompressDefaults(null);
+ }
+ }
+
+ // ── VO 装配 ───────────────────────────────────
+
+ private static TaskVO toVo(CollectTask t) {
+ TaskVO vo = new TaskVO();
+ vo.setId(t.getId());
+ vo.setTaskCode(t.getTaskCode());
+ vo.setTaskName(t.getTaskName());
+ vo.setCollectMode(t.getCollectMode());
+ vo.setBatchNo(t.getBatchNo());
+ vo.setMajorName(t.getMajorName());
+ vo.setJobName(t.getJobName());
+ vo.setJobLevel(t.getJobLevel());
+ vo.setCertType(t.getCertType());
+ vo.setStartTime(t.getStartTime());
+ vo.setEndTime(t.getEndTime());
+ vo.setNamingTemplate(t.getNamingTemplate());
+ vo.setRosterRequired(t.getRosterRequired() != null && t.getRosterRequired() == 1);
+ vo.setStatus(t.getStatus());
+ vo.setPublishedTime(t.getPublishedTime());
+ vo.setCreateTime(t.getCreateTime());
+ // auditConfig / compressConfig 由 toFullVo 装配;stat 留 null(见字段注释)
+ return vo;
+ }
+
+ /** 装配 JSON 配置与统计快照。列表与详情共用,避免两处不一致。 */
+ private TaskVO toFullVo(CollectTask t) {
+ TaskVO vo = toVo(t);
+ vo.setAuditConfig(readAuditConfig(t.getAuditConfig()));
+ vo.setCompressConfig(readCompressConfig(t.getCompressConfig()));
+ // stat 保持 null —— 它依赖名册(M2)与提交单(M3),
+ // 现在返回 0 会让界面显示"完成率 0%",而真相是"还没有分母"。
+ return vo;
+ }
+
+ // ── 返回值 ────────────────────────────────────
+
+ /** 发布结果。 */
+ @Getter
+ @Setter
+ public static class PublishResult {
+
+ private Integer status;
+
+ private LocalDateTime publishedTime;
+
+ /** 被 {@code force} 跳过的 P1 级提醒(回给界面存档,不是错误)。 */
+ private List
+ * 0 草稿 ──publish──→ 1 收集中 ──pause──→ 4 已暂停 ──resume──→ 1 收集中
+ * │ │
+ * └────close───────────┴──→ 2 已截止 ──archive──→ 3 已归档
+ *
+ *
+ *
+ *
+ * 把暂停做成"软截止"是最容易犯的错 —— 那会让"临时冻一下"变成不可逆操作,
+ * 现场没人敢点。所以这两个状态必须分开。
+ *
+ *
+ *
+ * 所以正确做法是"看所有历史行的最大序号"(含软删),在其上递增。
+ *
+ *
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TaskService {
+
+ private static final int MAX_PAGE_SIZE = 200;
+
+ /** 与 DDL 默认值一致:{@code naming_template} 的列默认值是它去掉 {@code {后缀}} 的形式。 */
+ private static final String DEFAULT_NAMING_TEMPLATE = "{姓名}-{证件号}-{材料名}";
+
+ /**
+ * 命名模板允许的占位符。
+ *
+ *