feat(task): M1-01 收集任务 CRUD 与状态机
后端:/tasks 建改删查 + publish/pause/resume/close/archive 六个动作, 按角色过滤可见范围(班主任只看自己带的班级的任务)。 前端:收集任务列表页 + 建/改任务弹窗(命名模板带实时预览与变量插入)。 验收:e2e-m1-task.py 71/71;e2e-m0.py 36/36、e2e-m0-user.py 55/55 回归通过; 前端 typecheck 0 错 / build 通过 / 浏览器肉眼确认。 三条硬约束(改动时当成规则): 1) 「暂停」与「截止」不是一回事。暂停:学生不能交、已提交的照常审、 且可恢复;截止不可逆。做成一个会让"临时冻一下"变成不可逆操作。 2) 发布前置 5 项全是阻断级,force 绕不过:名册非空(40016)、名册含姓名与 证件号(40017)、至少 1 项必传材料(40018)、已设置覆盖班级(40019)、 命名模板可用(40020)。只有 P1 级提醒(某班未指定初审人)可 force 跳过。 3) collectMode / certType 发布后不可改 —— 它们决定审核流与材料清单, 改了会让已填过的学生面对另一份表单。 本轮修掉的 3 个真问题: 1) collect_task_org 没有 deleted 列,而我让它继承了带 @TableLogic 的基类 —— MyBatis-Plus 会给每条查询自动加 deleted = 0,运行时报"列不存在", 编译期完全看不出来。改为不继承基类、显式声明字段; "移除"语义走 status = 0,不要对它调 deleteById(会变成物理删除)。 2) 任务编码查重被逻辑删除遮蔽:用 MP 的 selectCount 会自动过滤 deleted = 0, 但数据库的唯一约束不看软删标记 —— 软删掉的任务仍占着那个编码, 于是"查重说没有、插进去说重复",报 duplicate key(500)。 另外原用 COUNT+1 也错:删掉中间一个后计数会退回去,算出已占用的号。 改为 MAX(序号)+1,且查重语句刻意不加 deleted = 0(与唯一约束同口径); 编码一次用过就不复用(它同时是文件目录名,复用会把两批文件混进同一目录)。 3) Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,项目没引, 导致容器里没有 ObjectMapper bean(@Autowired 直接启动失败), 且 application.yml 的 spring.jackson.* 静默失效 —— 其中 time-zone: Asia/Shanghai 不生效最危险:本机时区恰好是上海时看不出来, 部署到 UTC 服务器后所有时间会整体偏 8 小时。 注意 spring-boot-starter-json 在 Boot 4 已无 4.x 版本(仓库里最高 3.5.6)。 补依赖,但任务配置的 JSON 读写仍不用 Jackson —— Boot 4 用 Jackson 3 而 jjwt-jackson 带来 Jackson 2,打包后两个 jackson-databind 并存 (2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 hutool-all。 已知缺口(都写进了代码注释与交接摘要): - 发布在前置数据齐备前不可达(名册属 M2、材料属 M1-04、覆盖班级属 M1-02), E2E 用 SQL 夹具造数据才走到发布之后的状态 —— M1-02/M1-04/M2 落地后 要回来把夹具换成真实接口。 - 命名规则试跑目前只做静态校验(变量名合法 + 必含姓名/证件号/材料名), 完整形态(拿真实名册跑一遍查非空与不重名)属 M1-09 / M3-01。 - stat 恒为 null(依赖名册与提交单)。故意不返回全 0 的对象 —— 那会让界面显示"完成率 0%",而真相是"还没有分母";界面显示「待导入名册」。 - 任务编码并发撞号由唯一约束兜底 + 可读提示(不再 500),未做分布式锁。 顺带: - 两个列表页的列宽按实测可用宽度重算(1366 屏:收集任务 824px、 账号管理因左侧组织树只有 574px),并把"操作列必须 fixed + 列宽口径" 写进注释 —— 之前把「状态」列整个遮住、把操作链接挤成竖排。 - 状态与最后登录并成一列,给窄屏省出 150px。
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
||||
package cn.tianbaotong.fill.module.task.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.common.api.PageResult;
|
||||
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.service.TaskService;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
*
|
||||
* <p>列表与详情用 {@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<PageResult<TaskVO>> list(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) Integer collectMode,
|
||||
@RequestParam(required = false) String batchNo,
|
||||
@RequestParam(required = false) Long orgId,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return ApiResult.ok(taskService.list(keyword, status, collectMode, batchNo, orgId, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "创建任务",
|
||||
description = "建成必定是草稿;一期只支持 collectMode=1(学校收集)与 certType=1(自主认定),传其它值返回 40044")
|
||||
public ApiResult<TaskCreateVO> create(@Valid @RequestBody TaskUpsertReq req) {
|
||||
return ApiResult.ok(taskService.create(req));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@Operation(summary = "任务详情")
|
||||
public ApiResult<TaskVO> detail(@PathVariable Long id) {
|
||||
return ApiResult.ok(taskService.detail(id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "修改任务", description = "已发布的任务不可改收集模式与认定类型")
|
||||
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody TaskUpsertReq req) {
|
||||
taskService.update(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
|
||||
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||
taskService.delete(id);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/publish")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "发布任务",
|
||||
description = "前置校验全部为阻断级,force 无法绕过:名册非空(40016)、名册含姓名与证件号(40017)、"
|
||||
+ "至少 1 项必传材料(40018)、已设置覆盖班级与班主任(40019)、命名模板可用(40020)。"
|
||||
+ "全部通过后仅 P1 级提醒可用 force 跳过")
|
||||
public ApiResult<TaskService.PublishResult> publish(@PathVariable Long id,
|
||||
@RequestBody(required = false) TaskActionReq req) {
|
||||
return ApiResult.ok(taskService.publish(id, req));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/pause")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "暂停任务",
|
||||
description = "幂等。暂停后学生不能提交,但已提交的照常审核,且可以恢复 —— 与「截止」不是一回事")
|
||||
public ApiResult<Void> pause(@PathVariable Long id,
|
||||
@RequestBody(required = false) TaskActionReq req) {
|
||||
taskService.pause(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/resume")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "恢复任务", description = "幂等。仅已暂停的任务可恢复")
|
||||
public ApiResult<Void> resume(@PathVariable Long id) {
|
||||
taskService.resume(id);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/close")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "截止任务", description = "不可逆。收集中或已暂停都可以截止")
|
||||
public ApiResult<Void> close(@PathVariable Long id,
|
||||
@RequestBody(required = false) TaskActionReq req) {
|
||||
taskService.close(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/archive")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "归档任务", description = "不可逆。要求无待审单,否则 40023")
|
||||
public ApiResult<Void> archive(@PathVariable Long id,
|
||||
@RequestBody(required = false) TaskActionReq req) {
|
||||
taskService.archive(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package cn.tianbaotong.fill.module.task.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 审核配置(《03-接口设计》§3.2)。
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
/**
|
||||
* 批量通过只带无问题项。
|
||||
*
|
||||
* <p>⚠️ <b>这是铁律,不允许关闭</b>(交接摘要铁律 9:批量通过只带无问题项,红黄一律排除)。
|
||||
* 字段保留只是为了契约形状一致 —— 客户端传 {@code false} 会被服务端强制改回 {@code true}。
|
||||
* 做成"可配置"就等于给"手快全选"留了一扇门,而误审的代价由学生承担。
|
||||
*/
|
||||
private Boolean batchApproveOnlyGreen = Boolean.TRUE;
|
||||
}
|
||||
+44
@@ -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)。
|
||||
*
|
||||
* <p>默认值来自真实基准数据集的实测结论(300dpi / q85 → 28/28 达标、
|
||||
* 平均 806KB、最大 831KB、清晰度零损失),不是拍脑袋定的。
|
||||
*
|
||||
* <p>⚠️ {@code defaultMaxSizeKb} 是<b>无例外</b>的硬要求(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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
|
||||
/**
|
||||
* 任务动作请求(发布 / 暂停 / 恢复 / 截止 / 归档 共用)。
|
||||
*
|
||||
* <p>五种动作的请求体都很小,且字段互不冲突,所以合成一个对象而不是五个类 ——
|
||||
* 五个几乎空着的类只会让 Controller 的签名更长、更难读。
|
||||
* 各字段的适用范围在下面逐条写明。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "任务动作(发布/暂停/恢复/截止/归档)")
|
||||
public class TaskActionReq {
|
||||
|
||||
/**
|
||||
* 是否强制({@code publish} / {@code close} 使用)。
|
||||
*
|
||||
* <p>⚠️ <b>只能跳过 P1 级警告,跳过不了阻断级校验</b>。
|
||||
* 发布前的 5 项检查(名册非空、名册含关键列、至少 1 项必传材料、
|
||||
* 已设置覆盖班级与班主任、命名规则试跑通过)<b>全部是阻断级</b>,
|
||||
* {@code force = true} 也不会放行 —— 这五项不通过就发布,
|
||||
* 学生端会出现"填了却没人审"或"文件名根本生成不出来"。
|
||||
*/
|
||||
@Schema(description = "是否强制跳过 P1 级警告;阻断级校验不受影响")
|
||||
private Boolean force;
|
||||
|
||||
/** {@code publish}:已确认的 P1 警告码。 */
|
||||
@Schema(description = "已确认的警告码")
|
||||
private List<String> confirmWarnings;
|
||||
|
||||
/** {@code pause}:暂停原因,会随日志留痕(可选)。 */
|
||||
@Schema(description = "暂停原因", example = "名册需补充 3 人")
|
||||
private String reason;
|
||||
|
||||
/** {@code archive}:归档备注(可选)。 */
|
||||
@Schema(description = "归档备注")
|
||||
private String archiveRemark;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.tianbaotong.fill.module.task.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 建任务结果(《03-接口设计》§7.2)。
|
||||
*
|
||||
* <p>只回这三个字段:任务建出来必定是草稿,前端拿 {@code id} 继续走向导
|
||||
* (配表单 → 配材料 → 导名册 → 发布),所以这里不需要回整个任务对象。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "建任务结果")
|
||||
public class TaskCreateVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 任务编码,形如 {@code 2026090001};同时用于服务端生成文件目录。 */
|
||||
private String taskCode;
|
||||
|
||||
/** 必定是 {@code 0}(草稿)。 */
|
||||
private Integer status;
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>⚠️ <b>M1-01 阶段该对象恒为 {@code null}</b>,不是"忘了填":
|
||||
* 这里的每一个数字都要求<b>名册</b>({@code collect_roster},M2)与
|
||||
* <b>提交单</b>({@code collect_submission},M3)已经存在。
|
||||
* 现在返回 0 或 "0%" 会是一个更难发现的谎 —— 界面会显示"完成率 0%",
|
||||
* 而真实情况是"还没导名册,根本没有分母"。所以宁可返回 {@code null},
|
||||
* 由界面显示「待导入名册」,把真实状态说清楚。
|
||||
*
|
||||
* <p>字段先按契约定好,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})。
|
||||
*
|
||||
* <p>⚠️ 与 {@link #passRate} <b>必须分开报</b>:原型定稿的口径是
|
||||
* 「完成率 82% 是<b>已提交率</b> 23/28」。已提交 ≠ 已通过,
|
||||
* 合并成一个数字会让"看起来快完成了"和"快通过了"混为一谈。
|
||||
*/
|
||||
private String submitRate;
|
||||
|
||||
/** 通过率({@code approvedCount / rosterTotal})。 */
|
||||
private String passRate;
|
||||
|
||||
/** 有红/黄问题的单数。 */
|
||||
private Integer abnormalCount;
|
||||
|
||||
/** 平均处理时长(小时)。 */
|
||||
private Double avgAuditHours;
|
||||
}
|
||||
+86
@@ -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)。
|
||||
*
|
||||
* <p><b>{@code collectMode} 与 {@code certType} 在发布后不可改</b>:
|
||||
* 它们决定审核流(有无班主任初审)与材料清单,改了会让已经填过的学生
|
||||
* 面对一份不同的表单。服务端在 {@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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/** 是否强制门禁。<b>当前恒为 true</b> —— 名单门禁是产品根基,不提供关闭入口。 */
|
||||
private Boolean rosterRequired;
|
||||
|
||||
/** {@code 0} 草稿 / {@code 1} 收集中 / {@code 2} 已截止 / {@code 3} 已归档 / {@code 4} 已暂停。 */
|
||||
private Integer status;
|
||||
|
||||
private LocalDateTime publishedTime;
|
||||
|
||||
/**
|
||||
* 统计快照。
|
||||
*
|
||||
* <p>⚠️ <b>M1 阶段恒为 {@code null}</b> —— 它依赖名册(M2)与提交单(M3)。
|
||||
* 这里不返回"全是 0 的对象",因为界面会把它显示成「完成率 0%」,
|
||||
* 而真实情况是"还没导名册、没有分母"。{@code null} 会显示成「待导入名册」,
|
||||
* 把真实状态说清楚。详见 {@link TaskStatVO}。
|
||||
*/
|
||||
private TaskStatVO stat;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
+91
@@ -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})。
|
||||
*
|
||||
* <p><b>状态机</b>({@code status}):
|
||||
* <pre>
|
||||
* 0 草稿 ──publish──→ 1 收集中 ──pause──→ 4 已暂停 ──resume──→ 1 收集中
|
||||
* │ │
|
||||
* └────close───────────┴──→ 2 已截止 ──archive──→ 3 已归档
|
||||
* </pre>
|
||||
*
|
||||
* <p>⚠️ <b>「暂停」与「截止」不是同一件事</b>(《03-接口设计》§7.4):
|
||||
* <ul>
|
||||
* <li><b>暂停</b>:学生不能提交,但<b>已提交的照常审核</b>,而且<b>可以恢复</b>。
|
||||
* 典型场景是"名单要改、规则要调,先冻住入口"。</li>
|
||||
* <li><b>截止</b>:学生不能提交,仍可审,但<b>不可逆</b>。是"时间到了"。</li>
|
||||
* <li><b>归档</b>:连审核都不行,只读。</li>
|
||||
* </ul>
|
||||
* 把暂停做成"软截止"是最容易犯的错 —— 那会让"临时冻一下"变成不可逆操作,
|
||||
* 现场没人敢点。所以这两个状态必须分开。
|
||||
*
|
||||
* <p><b>JSON 字段</b>:{@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} 社会收集(二期)。<b>发布后不可改</b>。 */
|
||||
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;
|
||||
}
|
||||
+66
@@ -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})。
|
||||
*
|
||||
* <p>「任务覆盖了哪些班级、每班的初审人是谁」都在这张表里。
|
||||
* 发布任务前必须至少有一条生效记录(否则班主任不知道该审谁、学生也不知道该不该填)。
|
||||
*
|
||||
* <p>⚠️ <b>本实体刻意不继承 {@code TenantEntity} / {@code BaseEntity}</b>:
|
||||
* {@code collect_task_org} <b>没有 {@code deleted} 列</b>(DDL 如此,见
|
||||
* {@code V1__business_tables.sql}),而基类上的 {@code @TableLogic} 会让
|
||||
* MyBatis-Plus 给每条查询自动加上 {@code deleted = 0} —— 那会直接报
|
||||
* "column cto.deleted does not exist",且只在运行时才暴露。
|
||||
*
|
||||
* <p>因此这张表的"移除"语义走 {@code status = 0}(DDL 注释:0已移除 / 1生效),
|
||||
* <b>不要对它调 {@code deleteById}</b>,那会变成物理删除,历史就查不到了。
|
||||
*/
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 副班主任 / 协办人,逗号分隔。
|
||||
*
|
||||
* <p>⚠️ 存的是<b>首尾都带逗号</b>的形式,如 {@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;
|
||||
}
|
||||
+85
@@ -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<CollectTask> {
|
||||
|
||||
/**
|
||||
* 某人在本租户下"能看到的"任务 id。
|
||||
*
|
||||
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确
|
||||
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
|
||||
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
|
||||
*
|
||||
* <p><b>初审人的判定顺序</b>与 DDL 注释一致:{@code cto.head_user_id} 优先,
|
||||
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
|
||||
* 不该要求每条都显式指定一遍。
|
||||
*
|
||||
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是
|
||||
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
|
||||
*
|
||||
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
|
||||
* 漏掉租户条件会返回别校的任务 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<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 取某批次当前的<b>最大序号</b>,用于生成下一个任务编码。
|
||||
*
|
||||
* <p>⚠️ 这里<b>故意不加 {@code deleted = 0}</b>,有三个原因,每一个都是踩过的坑:
|
||||
* <ol>
|
||||
* <li><b>唯一约束不看软删标记</b>:{@code uk_task_tenant_code} 是
|
||||
* {@code (tenant_id, task_code)},软删掉的任务<b>仍然占着那个编码</b>。
|
||||
* 查重时过滤掉软删行,就会出现"查重说没有、插进去说重复"
|
||||
* (PostgreSQL: duplicate key value violates unique constraint)。</li>
|
||||
* <li><b>不能用 {@code COUNT(*) + 1}</b>:删掉中间某个任务后,
|
||||
* 计数会退回去,算出已经被占用的号。</li>
|
||||
* <li>编码一旦用过就不该复用:它同时是<b>文件目录名</b>,
|
||||
* 复用一个旧编码会把两批任务的文件混进同一个目录。</li>
|
||||
* </ol>
|
||||
* 所以正确做法是"看所有历史行的最大序号"(含软删),在其上递增。
|
||||
*
|
||||
* <p>{@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);
|
||||
|
||||
/** 某编码是否已被占用(<b>含软删行</b>,与唯一约束的口径一致)。 */
|
||||
@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);
|
||||
}
|
||||
+13
@@ -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})。
|
||||
*
|
||||
* <p>M1-01 只用到基础 CRUD(发布前置校验要判断"是否已设置覆盖班级");
|
||||
* 批量设置、应填人数汇总等留给 M1-02。
|
||||
*/
|
||||
public interface TaskOrgMapper extends BaseMapper<CollectTaskOrg> {
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package cn.tianbaotong.fill.module.task.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 「任务是否具备发布条件」的体检查询。
|
||||
*
|
||||
* <p>为什么单独一个 Mapper,而不是塞进 {@code TaskMapper} 或各建各的:
|
||||
* 发布前置校验(《03-接口设计》§7.3 的 5 项)<b>本质上就是跨表体检</b> ——
|
||||
* 它要知道名册、材料、覆盖班级三张表的状态。
|
||||
* 把这些查询摊进 {@code TaskMapper} 会让"任务本身的 CRUD"和
|
||||
* "任务能不能发布"混在一起;而让 {@code TaskService} 直接注入名册/材料各自的
|
||||
* Mapper,又会让任务模块反向依赖那两个模块(它们的完整实现还在 M2 / M1-04)。
|
||||
*
|
||||
* <p>所以:<b>这里只放计数,不放任何业务逻辑</b>。等 M2 的 {@code RosterMapper}、
|
||||
* M1-04 的 {@code MaterialDefMapper} 齐了,这些查询可以按归属搬走,
|
||||
* 也可以留着 —— 它们表达的是"发布条件",与"名册怎么增删"本就不同一件事。
|
||||
*
|
||||
* <p>⚠️ 表 {@code collect_task_org} <b>没有 {@code deleted} 列</b>,
|
||||
* 所以那两条查询只能用 {@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);
|
||||
|
||||
/**
|
||||
* 名册里<b>同时有姓名和证件号</b>的行数。
|
||||
*
|
||||
* <p>用于 {@code 40017}「名册缺少「姓名」或「身份证号」列」。
|
||||
* 这两列在名册表里都是 NOT NULL,所以正常情况下两者相等;
|
||||
* 但只要出现 {@code count < countRoster},就说明有一批行是残缺的
|
||||
* (比如导入时列错位、或某列整列为空),继续发布会让这批人<b>校验不过也说不清原因</b>。
|
||||
* 因此这个检查不能省成"名册非空就行"。
|
||||
*/
|
||||
@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);
|
||||
|
||||
/**
|
||||
* 没有初审人的班级数 —— 这是 <b>P1 级警告</b>,不是阻断项。
|
||||
*
|
||||
* <p>判定与 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})。
|
||||
*
|
||||
* <p>{@code collect_submission.status} 的口径是
|
||||
* {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 /
|
||||
* {@code 4} 已驳回 / {@code 5} 已归档,所以"待审"是 {@code IN (1, 2)}。
|
||||
*
|
||||
* <p>M1 阶段这个数必然是 0(还没有填报功能,表是空的),
|
||||
* 但<b>这条查询是真的</b>,不是占位 —— 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);
|
||||
}
|
||||
+743
@@ -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)。
|
||||
*
|
||||
* <p><b>本类的三处硬约束</b>(改动时当成规则,不要当成"可以商量的校验"):
|
||||
* <ol>
|
||||
* <li><b>「暂停」与「截止」语义不同</b>(§7.4):暂停可恢复、已提交照常审;
|
||||
* 截止不可逆。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点。</li>
|
||||
* <li><b>发布前置 5 项全部是阻断级</b>,{@code force} 绕不过去。
|
||||
* 让它们可绕过会出现"学生填了却没人审"或"文件名根本生成不出来"。</li>
|
||||
* <li><b>{@code collectMode} 与 {@code certType} 发布后不可改</b>:
|
||||
* 它们决定审核流与材料清单,改了会让已填过的学生面对另一份表单。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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 = "{姓名}-{证件号}-{材料名}";
|
||||
|
||||
/**
|
||||
* 命名模板允许的占位符。
|
||||
*
|
||||
* <p>来源:《PRD》§命名规则配置的"可用变量"一栏。
|
||||
* 多一个会生成出 {@code {未知变量}} 这种字面量文件名,少一个会拒掉合法模板,
|
||||
* 所以这份清单必须与 PRD 逐字对齐。
|
||||
*/
|
||||
private static final Set<String> NAMING_PLACEHOLDERS = Set.of(
|
||||
"姓名", "证件号", "证件类型", "材料名", "班级", "专业", "工种",
|
||||
"等级", "报名点", "序号", "日期", "后缀");
|
||||
|
||||
/** {@code {自定义字段.xxx}} 的前缀 —— 扩展字段是动态的,不能枚举。 */
|
||||
private static final String CUSTOM_FIELD_PREFIX = "自定义字段.";
|
||||
|
||||
/**
|
||||
* 命名模板<b>必须</b>含这三个变量。
|
||||
*
|
||||
* <p>来源:《PRD》§5.6「命名模板必须含姓名/证件号/材料名」。
|
||||
* 缺了任意一个,文件名就无法唯一归属到人 —— 而"文件归属"正是这个产品的根基
|
||||
* (实测 52.7% 的原始文件名是违规的,靠的正是这三个变量重建归属)。
|
||||
*/
|
||||
private static final List<String> NAMING_REQUIRED = List.of("姓名", "证件号", "材料名");
|
||||
|
||||
private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]*)\\}");
|
||||
|
||||
private final TaskMapper taskMapper;
|
||||
private final TaskReadinessMapper readinessMapper;
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 任务列表。
|
||||
*
|
||||
* <p><b>可见范围</b>(§7.1):管理员(学校管理员 / 审核员)看全租户;
|
||||
* 班主任、副班主任<b>只看自己带的班级的任务</b>。
|
||||
* 后者不是"少显示几行"的问题 —— 让一个班主任看到全校的任务名与批次,
|
||||
* 本身就是一次越权。
|
||||
*/
|
||||
public PageResult<TaskVO> list(String keyword, Integer status, Integer collectMode,
|
||||
String batchNo, Long orgId, int pageNum, int pageSize) {
|
||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
int num = Math.max(pageNum, 1);
|
||||
|
||||
String kw = keyword == null ? null : keyword.trim();
|
||||
final String keywordLike = (kw == null || kw.isEmpty()) ? null : kw;
|
||||
|
||||
LambdaQueryWrapper<CollectTask> q = Wrappers.lambdaQuery();
|
||||
q.eq(status != null, CollectTask::getStatus, status)
|
||||
.eq(collectMode != null, CollectTask::getCollectMode, collectMode)
|
||||
.eq(batchNo != null && !batchNo.isBlank(), CollectTask::getBatchNo, batchNo);
|
||||
|
||||
if (keywordLike != null) {
|
||||
q.and(w -> w.like(CollectTask::getTaskName, keywordLike)
|
||||
.or().like(CollectTask::getTaskCode, keywordLike)
|
||||
.or().like(CollectTask::getMajorName, keywordLike));
|
||||
}
|
||||
if (orgId != null) {
|
||||
// orgId 是 Long,拼进 inSql 无注入风险
|
||||
q.inSql(CollectTask::getId,
|
||||
"SELECT task_id FROM collect_task_org WHERE status = 1 AND org_id = " + orgId);
|
||||
}
|
||||
|
||||
if (!seesWholeTenant()) {
|
||||
List<Long> visible = taskMapper.selectVisibleTaskIds(UserContext.tenantId(), UserContext.uid());
|
||||
if (visible.isEmpty()) {
|
||||
return PageResult.empty(num, size);
|
||||
}
|
||||
q.in(CollectTask::getId, visible);
|
||||
}
|
||||
|
||||
q.orderByDesc(CollectTask::getId);
|
||||
IPage<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q);
|
||||
// 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null
|
||||
List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList();
|
||||
return PageResult.of(list, page.getTotal(), num, size);
|
||||
}
|
||||
|
||||
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
|
||||
private boolean seesWholeTenant() {
|
||||
LoginUser me = UserContext.require();
|
||||
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
|
||||
}
|
||||
|
||||
// ── 详情 ──────────────────────────────────────
|
||||
|
||||
public TaskVO detail(Long id) {
|
||||
return toFullVo(requireTask(id));
|
||||
}
|
||||
|
||||
// ── 新建 ──────────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TaskCreateVO create(TaskUpsertReq req) {
|
||||
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()
|
||||
? DEFAULT_NAMING_TEMPLATE
|
||||
: req.getNamingTemplate().trim();
|
||||
assertNamingTemplate(namingTemplate);
|
||||
|
||||
CollectTask t = new CollectTask();
|
||||
t.setTaskName(req.getTaskName().trim());
|
||||
t.setCollectMode(req.getCollectMode());
|
||||
t.setBatchNo(req.getBatchNo().trim());
|
||||
t.setMajorName(req.getMajorName().trim());
|
||||
t.setJobName(trimToNull(req.getJobName()));
|
||||
t.setJobLevel(trimToNull(req.getJobLevel()));
|
||||
t.setCertType(req.getCertType());
|
||||
t.setStartTime(start);
|
||||
t.setEndTime(end);
|
||||
t.setAuditConfig(writeJson(withAuditDefaults(req.getAuditConfig())));
|
||||
t.setNamingTemplate(namingTemplate);
|
||||
t.setCompressConfig(writeJson(withCompressDefaults(req.getCompressConfig())));
|
||||
t.setRosterRequired(1);
|
||||
t.setStatus(CollectTask.STATUS_DRAFT);
|
||||
t.setTaskCode(nextTaskCode(req.getBatchNo().trim()));
|
||||
try {
|
||||
taskMapper.insert(t);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 并发撞号(见 nextTaskCode 的"残留风险")。唯一约束是最后一道防线,
|
||||
// 但要让用户看到一句能理解的话,而不是 GlobalExceptionHandler 的 50000。
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"任务编码 " + t.getTaskCode() + " 刚刚被占用,请重试一次");
|
||||
}
|
||||
|
||||
log.info("新建任务 {}(id={}, 批次={}, 专业={})", t.getTaskCode(), t.getId(),
|
||||
t.getBatchNo(), t.getMajorName());
|
||||
|
||||
TaskCreateVO vo = new TaskCreateVO();
|
||||
vo.setId(t.getId());
|
||||
vo.setTaskCode(t.getTaskCode());
|
||||
vo.setStatus(t.getStatus());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一期范围校验(《03-接口设计》§7.2 注 + PRD「一期范围声明」)。
|
||||
*
|
||||
* <p>一期只做「学校收集 + 自主认定」。
|
||||
* <b>数据结构保留、只是不启用</b> —— 所以这里拦的是"启用",传了二期取值返回 {@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)。
|
||||
*
|
||||
* <p>五项前置校验<b>全是阻断级</b>,{@code force} 无法绕过。
|
||||
* 这里一次性把所有不通过的项都收集起来再抛,而不是撞到第一个就返回 ——
|
||||
* 配一个几百人的任务要反复试五次才知道还差什么,是很难受的。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PublishResult publish(Long id, TaskActionReq req) {
|
||||
CollectTask task = requireTask(id);
|
||||
assertStatus(task, CollectTask.STATUS_DRAFT, "只有草稿状态的任务可以发布");
|
||||
|
||||
List<Blocker> blockers = new ArrayList<>();
|
||||
|
||||
long rosterTotal = nz(readinessMapper.countRoster(id));
|
||||
if (rosterTotal == 0) {
|
||||
blockers.add(new Blocker(ErrorCode.ROSTER_EMPTY,
|
||||
ErrorCode.ROSTER_EMPTY.getMsg() + "(请先导入名册)"));
|
||||
} else if (nz(readinessMapper.countRosterWithKeyColumns(id)) < rosterTotal) {
|
||||
blockers.add(new Blocker(ErrorCode.ROSTER_MISSING_KEY_COLUMN,
|
||||
ErrorCode.ROSTER_MISSING_KEY_COLUMN.getMsg()));
|
||||
}
|
||||
|
||||
if (nz(readinessMapper.countRequiredMaterials(id)) == 0) {
|
||||
blockers.add(new Blocker(ErrorCode.NO_REQUIRED_MATERIAL,
|
||||
ErrorCode.NO_REQUIRED_MATERIAL.getMsg()));
|
||||
}
|
||||
if (nz(readinessMapper.countActiveOrgs(id)) == 0) {
|
||||
blockers.add(new Blocker(ErrorCode.NO_TASK_ORG,
|
||||
ErrorCode.NO_TASK_ORG.getMsg()));
|
||||
}
|
||||
String namingProblem = checkNamingTemplate(task.getNamingTemplate());
|
||||
if (namingProblem != null) {
|
||||
blockers.add(new Blocker(ErrorCode.NAMING_DRYRUN_FAILED,
|
||||
ErrorCode.NAMING_DRYRUN_FAILED.getMsg() + ":" + namingProblem));
|
||||
}
|
||||
|
||||
if (!blockers.isEmpty()) {
|
||||
// 全部列出来,但用第一个的 code —— 前端按 code 定位到是哪个环节,
|
||||
// 同时能从 msg 里一次看到其余还差什么(配几百人的任务不该试五次才知道差多少)
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < blockers.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(";");
|
||||
}
|
||||
sb.append(blockers.get(i).message());
|
||||
}
|
||||
throw new BizException(blockers.get(0).code(), sb.toString());
|
||||
}
|
||||
|
||||
// P1 级警告:班主任未指定的班级。force 可跳过。
|
||||
long noHead = nz(readinessMapper.countOrgsWithoutHead(id));
|
||||
boolean forced = Boolean.TRUE.equals(req == null ? null : req.getForce());
|
||||
List<String> warnings = new ArrayList<>();
|
||||
if (noHead > 0 && !forced) {
|
||||
warnings.add("有 " + noHead + " 个班级没有指定的初审人(将取班级负责人)");
|
||||
}
|
||||
if (!warnings.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"存在待确认的提醒:" + String.join(";", warnings) + "。确认后请带 force 重新发布");
|
||||
}
|
||||
|
||||
CollectTask patch = new CollectTask();
|
||||
patch.setId(id);
|
||||
patch.setStatus(CollectTask.STATUS_COLLECTING);
|
||||
patch.setPublishedTime(LocalDateTime.now());
|
||||
taskMapper.updateById(patch);
|
||||
|
||||
log.info("发布任务 {}(id={}, 名册 {} 人)", task.getTaskCode(), id, rosterTotal);
|
||||
|
||||
PublishResult r = new PublishResult();
|
||||
r.setStatus(CollectTask.STATUS_COLLECTING);
|
||||
r.setPublishedTime(patch.getPublishedTime());
|
||||
r.setWarnings(warnings);
|
||||
// 二维码属 M1-06,这里先只把"二维码参数"回出去,够前端拼小程序码
|
||||
r.setQrcodeUrl("/api/v1/tasks/" + id + "/qrcode");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 暂停 / 恢复(§7.4,幂等)─────────────────
|
||||
|
||||
/**
|
||||
* 暂停。
|
||||
*
|
||||
* <p><b>与「截止」的区别</b>:暂停后学生不能提交,但<b>已提交的照常审核</b>,
|
||||
* 而且<b>可以恢复</b>。典型场景是"名单要改、规则要调,先冻住入口"。
|
||||
* 幂等:已经是暂停状态时直接返回成功,不报错 —— 重复点按钮不该看到红色提示。
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档:不可逆,且要求<b>没有待审单</b>({@code 40023})。
|
||||
*
|
||||
* <p>归档后连审核都不行(只读)。若还有人在等审核就归档,那些单子会永远悬着 ——
|
||||
* 学生看到"已提交",系统里却再也没人能处理。所以这条必须拦。
|
||||
*/
|
||||
@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 位序号。
|
||||
*
|
||||
* <p>序号按<b>同批次已有任务数 + 1</b> 取。并发下两次请求可能算出同一个号,
|
||||
* 所以靠 {@code uk_task_tenant_code} 唯一约束兜底,撞了就重试。
|
||||
* 不引独立的序列表:这个量级(一个学校一学期几十个任务)不值得多一张表,
|
||||
* 而唯一约束 + 重试已经把正确性保证了。
|
||||
*/
|
||||
/**
|
||||
* 生成任务编码:批次号 + 4 位序号,如 {@code 2026090001}。
|
||||
*
|
||||
* <p><b>为什么是"最大序号 + 1"而不是"数量 + 1"</b>,以及
|
||||
* <b>为什么查重必须绕过逻辑删除</b>:见 {@code TaskMapper.maxSeqByCodePrefix} 的注释。
|
||||
* 简单说:任务编码同时是文件目录名,用过就不该复用;
|
||||
* 而软删掉的任务仍然占着唯一约束,只查"未删除的"会算出已被占用的号。
|
||||
*
|
||||
* <p>残留风险:两个请求<b>同时</b>到达时可能算出同一个号,由唯一约束兜底
|
||||
* ({@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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 命名模板校验。
|
||||
*
|
||||
* <p>两件事:① 必须含姓名/证件号/材料名(PRD §5.6);
|
||||
* ② 所有占位符都必须在允许集合里 —— 写错一个变量名(比如 {@code {身证号}})
|
||||
* 不会报错,只会让每个文件名都带上一个字面量 {@code {身证号}},
|
||||
* 而且要到导出的那一刻才会被发现。
|
||||
*
|
||||
* <p>⚠️ 这里是<b>静态校验</b>。§7.3 里"命名规则试跑通过"的完整形态是
|
||||
* <b>拿真实名册的前若干行跑一遍,检查生成的名字非空且两两不重复</b> ——
|
||||
* 那需要名册数据,属 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<String> used = new LinkedHashSet<>();
|
||||
Matcher m = PLACEHOLDER.matcher(template);
|
||||
while (m.find()) {
|
||||
String name = m.group(1).trim();
|
||||
used.add(name);
|
||||
if (NAMING_PLACEHOLDERS.contains(name) || name.startsWith(CUSTOM_FIELD_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
return "命名模板里有不认识的变量 {" + name + "},可用变量:" + String.join(" ", NAMING_PLACEHOLDERS)
|
||||
+ "," + CUSTOM_FIELD_PREFIX + "xxx";
|
||||
}
|
||||
for (String need : NAMING_REQUIRED) {
|
||||
if (!used.contains(need)) {
|
||||
return "命名模板必须包含 {" + need + "}(缺了它,文件名无法唯一归属到人)";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long nz(Long v) {
|
||||
return v == null ? 0L : v;
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
// ── JSON 配置 ─────────────────────────────────
|
||||
|
||||
private static AuditConfigVO withAuditDefaults(AuditConfigVO in) {
|
||||
AuditConfigVO cfg = in == null ? new AuditConfigVO() : in;
|
||||
// 铁律:批量通过只带无问题项,不允许关闭。客户端传 false 也会被扳回来。
|
||||
cfg.setBatchApproveOnlyGreen(Boolean.TRUE);
|
||||
// 一期没有社会收集,班主任初审恒为开启
|
||||
cfg.setNeedClassAudit(Boolean.TRUE);
|
||||
if (cfg.getNeedAdminAudit() == null) {
|
||||
cfg.setNeedAdminAudit(Boolean.TRUE);
|
||||
}
|
||||
if (cfg.getClassAuditTimeoutHours() == null) {
|
||||
cfg.setClassAuditTimeoutHours(48);
|
||||
}
|
||||
if (cfg.getAdminAuditTimeoutHours() == null) {
|
||||
cfg.setAdminAuditTimeoutHours(72);
|
||||
}
|
||||
if (cfg.getMaxRejectCount() == null) {
|
||||
cfg.setMaxRejectCount(3);
|
||||
}
|
||||
if (cfg.getAllowProxyUpload() == null) {
|
||||
cfg.setAllowProxyUpload(Boolean.TRUE);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private static CompressConfigVO withCompressDefaults(CompressConfigVO in) {
|
||||
CompressConfigVO cfg = in == null ? new CompressConfigVO() : in;
|
||||
if (cfg.getDefaultMaxSizeKb() == null) {
|
||||
cfg.setDefaultMaxSizeKb(1024);
|
||||
}
|
||||
if (cfg.getDefaultTargetDpi() == null) {
|
||||
cfg.setDefaultTargetDpi(300);
|
||||
}
|
||||
if (cfg.getDefaultQuality() == null) {
|
||||
cfg.setDefaultQuality(85);
|
||||
}
|
||||
if (cfg.getMinQuality() == null) {
|
||||
cfg.setMinQuality(70);
|
||||
}
|
||||
if (cfg.getHardFloorDpi() == null) {
|
||||
cfg.setHardFloorDpi(200);
|
||||
}
|
||||
if (cfg.getMaxPixels() == null) {
|
||||
cfg.setMaxPixels(50_000_000);
|
||||
}
|
||||
if (cfg.getKeepPngForScreenshot() == null) {
|
||||
cfg.setKeepPngForScreenshot(Boolean.TRUE);
|
||||
}
|
||||
if (cfg.getMarkWhenForced() == null) {
|
||||
cfg.setMarkWhenForced(Boolean.TRUE);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 JSON 的读写用 Hutool,<b>刻意不用 Jackson</b>。
|
||||
*
|
||||
* <p>原因:Boot 4 的自动配置用的是 <b>Jackson 3</b>({@code tools.jackson}),
|
||||
* 而 {@code jjwt-jackson} 又把 <b>Jackson 2</b>({@code com.fasterxml.jackson})
|
||||
* 带上了 classpath —— 打包后 jar 里同时躺着
|
||||
* {@code jackson-databind-2.21.5.jar} 与 {@code jackson-databind-3.1.5.jar}。
|
||||
* 这种情况下无论 {@code import} 哪一个,都是在赌"哪个版本先被解析到",
|
||||
* 而且失败方式很隐蔽({@code NoClassDefFoundError} 只在跑到时才出现)。
|
||||
*
|
||||
* <p>{@code hutool-all} 是本项目在 {@code xt-common} 里<b>直接声明</b>的依赖
|
||||
* (验证码就在用它),不存在版本竞争;而任务配置里只有布尔、整数与字符串,
|
||||
* 不涉及日期格式与时区,任何 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<String> warnings = new ArrayList<>();
|
||||
|
||||
/** 二维码地址(M1-06 前只是占位,返回的是接口路径)。 */
|
||||
private String qrcodeUrl;
|
||||
}
|
||||
|
||||
/** 一条阻断项:错误码 + 给用户看的话。 */
|
||||
private record Blocker(ErrorCode code, String message) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user