diff --git a/backend/xt-common/pom.xml b/backend/xt-common/pom.xml index 7d0a994..0b2fb8c 100644 --- a/backend/xt-common/pom.xml +++ b/backend/xt-common/pom.xml @@ -21,6 +21,25 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jackson + + org.springframework.boot diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java new file mode 100644 index 0000000..807c63e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java @@ -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)。 + * + *

列表与详情用 {@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> 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 create(@Valid @RequestBody TaskUpsertReq req) { + return ApiResult.ok(taskService.create(req)); + } + + @GetMapping("/{id}") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "任务详情") + public ApiResult detail(@PathVariable Long id) { + return ApiResult.ok(taskService.detail(id)); + } + + @PutMapping("/{id}") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "修改任务", description = "已发布的任务不可改收集模式与认定类型") + public ApiResult 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 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 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 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 resume(@PathVariable Long id) { + taskService.resume(id); + return ApiResult.ok(); + } + + @PostMapping("/{id}/close") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "截止任务", description = "不可逆。收集中或已暂停都可以截止") + public ApiResult 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 archive(@PathVariable Long id, + @RequestBody(required = false) TaskActionReq req) { + taskService.archive(id, req); + return ApiResult.ok(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/AuditConfigVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/AuditConfigVO.java new file mode 100644 index 0000000..bffaadf --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/AuditConfigVO.java @@ -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)。 + * + *

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 confirmWarnings; + + /** {@code pause}:暂停原因,会随日志留痕(可选)。 */ + @Schema(description = "暂停原因", example = "名册需补充 3 人") + private String reason; + + /** {@code archive}:归档备注(可选)。 */ + @Schema(description = "归档备注") + private String archiveRemark; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskCreateVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskCreateVO.java new file mode 100644 index 0000000..3c67f47 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskCreateVO.java @@ -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)。 + * + *

只回这三个字段:任务建出来必定是草稿,前端拿 {@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}): + *

+ *   0 草稿 ──publish──→ 1 收集中 ──pause──→ 4 已暂停 ──resume──→ 1 收集中
+ *                          │                    │
+ *                          └────close───────────┴──→ 2 已截止 ──archive──→ 3 已归档
+ * 
+ * + *

⚠️ 「暂停」与「截止」不是同一件事(《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 { + + /** + * 某人在本租户下"能看到的"任务 id。 + * + *

谁需要这个:班主任与副班主任。《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 selectVisibleTaskIds(@Param("tenantId") Long tenantId, + @Param("userId") Long userId); + + /** + * 取某批次当前的最大序号,用于生成下一个任务编码。 + * + *

⚠️ 这里故意不加 {@code deleted = 0},有三个原因,每一个都是踩过的坑: + *

    + *
  1. 唯一约束不看软删标记:{@code uk_task_tenant_code} 是 + * {@code (tenant_id, task_code)},软删掉的任务仍然占着那个编码。 + * 查重时过滤掉软删行,就会出现"查重说没有、插进去说重复" + * (PostgreSQL: duplicate key value violates unique constraint)。
  2. + *
  3. 不能用 {@code COUNT(*) + 1}:删掉中间某个任务后, + * 计数会退回去,算出已经被占用的号。
  4. + *
  5. 编码一旦用过就不该复用:它同时是文件目录名, + * 复用一个旧编码会把两批任务的文件混进同一个目录。
  6. + *
+ * 所以正确做法是"看所有历史行的最大序号"(含软删),在其上递增。 + * + *

{@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 { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java new file mode 100644 index 0000000..8eb0647 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java @@ -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; + +/** + * 「任务是否具备发布条件」的体检查询。 + * + *

为什么单独一个 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)。 + * + *

本类的三处硬约束(改动时当成规则,不要当成"可以商量的校验"): + *

    + *
  1. 「暂停」与「截止」语义不同(§7.4):暂停可恢复、已提交照常审; + * 截止不可逆。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点。
  2. + *
  3. 发布前置 5 项全部是阻断级,{@code force} 绕不过去。 + * 让它们可绕过会出现"学生填了却没人审"或"文件名根本生成不出来"。
  4. + *
  5. {@code collectMode} 与 {@code certType} 发布后不可改: + * 它们决定审核流与材料清单,改了会让已填过的学生面对另一份表单。
  6. + *
+ */ +@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 = "{姓名}-{证件号}-{材料名}"; + + /** + * 命名模板允许的占位符。 + * + *

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

来源:《PRD》§5.6「命名模板必须含姓名/证件号/材料名」。 + * 缺了任意一个,文件名就无法唯一归属到人 —— 而"文件归属"正是这个产品的根基 + * (实测 52.7% 的原始文件名是违规的,靠的正是这三个变量重建归属)。 + */ + private static final List NAMING_REQUIRED = List.of("姓名", "证件号", "材料名"); + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]*)\\}"); + + private final TaskMapper taskMapper; + private final TaskReadinessMapper readinessMapper; + + // ── 列表 ────────────────────────────────────── + + /** + * 任务列表。 + * + *

可见范围(§7.1):管理员(学校管理员 / 审核员)看全租户; + * 班主任、副班主任只看自己带的班级的任务。 + * 后者不是"少显示几行"的问题 —— 让一个班主任看到全校的任务名与批次, + * 本身就是一次越权。 + */ + public PageResult 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 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 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 page = taskMapper.selectPage(new Page<>(num, size), q); + // 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null + List 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「一期范围声明」)。 + * + *

一期只做「学校收集 + 自主认定」。 + * 数据结构保留、只是不启用 —— 所以这里拦的是"启用",传了二期取值返回 {@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 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 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,幂等)───────────────── + + /** + * 暂停。 + * + *

与「截止」的区别:暂停后学生不能提交,但已提交的照常审核, + * 而且可以恢复。典型场景是"名单要改、规则要调,先冻住入口"。 + * 幂等:已经是暂停状态时直接返回成功,不报错 —— 重复点按钮不该看到红色提示。 + */ + @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 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,刻意不用 Jackson。 + * + *

原因: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 warnings = new ArrayList<>(); + + /** 二维码地址(M1-06 前只是占位,返回的是接口路径)。 */ + private String qrcodeUrl; + } + + /** 一条阻断项:错误码 + 给用户看的话。 */ + private record Blocker(ErrorCode code, String message) { + } +} diff --git a/docs/交接摘要.md b/docs/交接摘要.md index e469a78..c0bd2d3 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -46,9 +46,10 @@ | M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 | | M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) | | M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** | -| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 36/36**;**`python scripts/e2e-m0-user.py` → 53/53** | -| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / **账号管理** | -| 待做 | 小程序(uni-app)未创建;CI/CD;H5 保底预研;`hasActiveTask` 待 M1 补实现 | +| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** | +| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** | +| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务** | +| 待做 | M1 剩余(M1-02 覆盖班级、M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 | --- @@ -93,6 +94,65 @@ --- +## 一·五、M1-01 任务 CRUD 与状态机(2026-09-28 夜,未提交) + +> 范围:`GET/POST /tasks`、`GET/PUT/DELETE /tasks/{id}`、 +> `POST /tasks/{id}/{publish|pause|resume|close|archive}`;前端「收集管理 → 收集任务」一屏。 + +**验收**:`python scripts/e2e-m1-task.py` → **71/71**; +`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过; +前端 `pnpm typecheck` 0 错、`pnpm build` 通过。 + +### 三条硬约束(改动时当成规则,不是"可以商量的校验") + +| # | 约束 | 为什么 | +|:--:|---|---| +| 1 | **「暂停」与「截止」是两件事** | 暂停:学生不能交,**已提交的照常审**,且**可恢复**(名单要改、规则要调时用)。截止:不可交、仍可审,**不可逆**。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点 | +| 2 | **发布前置 5 项全是阻断级**,`force` 绕不过 | 名册非空(40016)、名册含姓名与证件号(40017)、≥1 项必传材料(40018)、已设置覆盖班级(40019)、命名模板可用(40020)。可绕过就会出现"学生填了却没人审"或"文件名根本生成不出来"。只有 P1 级提醒(某班未指定初审人)可用 `force` 跳过 | +| 3 | **`collectMode` / `certType` 发布后不可改** | 它们决定审核流与材料清单;改了会让已经填过的学生面对另一份表单。归档后仍可改名称,只锁这两个 | + +### 本轮修掉的 3 个真问题 + +| # | 问题 | 后果 | 修法 | +|:--:|---|---|---| +| 1 | **`collect_task_org` 没有 `deleted` 列** | 我起初让它继承 `TenantEntity`(基类带 `@TableLogic deleted`),MyBatis-Plus 会给**每条**查询自动加 `deleted = 0` → 运行时报「列不存在」。而这**编译期完全看不出来** | 该实体改成不继承基类、显式声明字段;"移除"语义走 `status = 0`(DDL 注释即如此),**不要对它调 `deleteById`**(会变成物理删除) | +| 2 | **任务编码查重被逻辑删除遮蔽** | 用 MP 的 `selectCount` 查重会自动过滤 `deleted = 0`,但**数据库的唯一约束不看软删标记** —— 软删掉的任务仍占着那个编码。表现为 `duplicate key value violates unique constraint "uk_task_tenant_code"`(500)。另外原用 `COUNT+1` 也错:删掉中间一个任务后计数会退回去,算出已占用的号 | 改为 `MAX(序号) + 1`,且**查重语句刻意不加 `deleted = 0`**(与唯一约束同口径);编码一次用过就不复用(它同时是**文件目录名**,复用会把两批任务的文件混进同一目录)。并发撞号由唯一约束兜底并返回可读提示,不再 500 | +| 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),照老习惯找它是找不到的 | 在 `xt-common` 补 `spring-boot-starter-jackson`。但**任务配置的 JSON 读写仍不用 Jackson** —— Boot 4 用 Jackson 3(`tools.jackson`)而 `jjwt-jackson` 带来 Jackson 2,打包后 jar 里两个 `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。当前这条已能拦住绝大多数错误(变量名写错、漏必备变量),且名册为空时发布本就会被 `40016` 先拦 | +| `stat` 恒为 `null` | 依赖名册(M2)与提交单(M3)。**故意不返回全 0 的对象** —— 那会让界面显示"完成率 0%",而真相是"还没有分母"。界面显式显示「待导入名册」 | +| 任务编码并发撞号 | 两个请求同时到达可能算出同一个号。唯一约束兜底 + 可读提示(不再 500)。一学期几十个任务,撞号概率极低,为此引独立序列表或分布式锁不划算 —— 但**别当成已解决** | +| `taskOrgMapper` 尚未用到 | M1-01 只用到 `TaskReadinessMapper` 里的计数;覆盖班级的增删改属 M1-02 | + +### 前端踩到的坑 + +- **不要在校验/修复工具会碰的模板里拼花括号**。我写 `{{ '{' + v + '}' }}` 渲染命名变量按钮,`eslint --fix` 把它"帮忙"改成模板字符串 `` `{${v}}` `` —— 而里面的 `}}` 会截断 Vue 的插值解析器,报 + `Error parsing JavaScript expression: Unexpected token, expected "}"`。 + **这个错只在 `pnpm build` / 编译时出现,编辑器里看不出来。** + 正确做法:在 ` + + diff --git a/frontend-pc/src/views/task/index.vue b/frontend-pc/src/views/task/index.vue new file mode 100644 index 0000000..dd4b754 --- /dev/null +++ b/frontend-pc/src/views/task/index.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/frontend-pc/src/views/user/index.vue b/frontend-pc/src/views/user/index.vue index 6c74ac7..2f4a335 100644 --- a/frontend-pc/src/views/user/index.vue +++ b/frontend-pc/src/views/user/index.vue @@ -59,22 +59,40 @@ + + :scroll="{ x: 800 }" @page-change="onPageChange">