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:
2026-09-28 23:48:21 +08:00
parent 8480f431ba
commit daf42608d9
22 changed files with 3228 additions and 29 deletions
+19
View File
@@ -21,6 +21,25 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!--
Jackson 自动配置。
⚠️ Boot 4 把 Jackson 的自动配置从 spring-boot-autoconfigure 拆到了
**spring-boot-jackson**(starter 名 `spring-boot-starter-jackson`),
而老的 `spring-boot-starter-json` 在 Boot 4 里**已经没有 4.x 版本**(最高 3.5.6)。
不引它的后果是"静默降级":
· 容器里没有 ObjectMapper bean,任何 @Autowired ObjectMapper 直接启动失败
· application.yml 里的 `spring.jackson.*` **完全不生效**(看起来配了,其实没读)
其中 `time-zone: Asia/Shanghai` 不生效尤其危险 ——
本机时区恰好是 Asia/Shanghai 时看不出来,一旦部署到 UTC 的服务器,
所有 LocalDateTime 会整体偏移 8 小时,而且只表现为"时间看着不对"。
这正是本项目反复踩到的"依赖在、功能不在"。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<!-- @Valid / 参数校验 --> <!-- @Valid / 参数校验 -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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> {
}
@@ -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);
}
@@ -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) {
}
}
+63 -3
View File
@@ -46,9 +46,10 @@
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 | | M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) | | M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** | | M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 36/36**;**`python scripts/e2e-m0-user.py` → 53/53** | | M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / **账号管理** | | **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| 待做 | 小程序(uni-app)未创建;CI/CD;H5 保底预研;`hasActiveTask` 待 M1 补实现 | | 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` / 编译时出现,编辑器里看不出来。**
正确做法:在 `<script>` 里算好 `{ value, label }`,模板只做纯取值。
- **列宽要先量可用宽度,不能按像素凑总宽**(实测数据):
| 页面 | 可用宽度 | 原因 |
|---|---|---|
| 收集任务 | 约 **824px** | 只有 200 左侧栏 |
| 账号管理 | 约 **574px** | 200 左侧栏 + 约 250 左侧组织树卡 |
Arco 会按容器**重新分配**列宽:声明 120 的列被拉到 188,而 `fixed="right"` 的操作列被压到 105 —— 105px 装不下「编辑 / 重置密码 / 停用」,**竖排成了「编 辑 / 重 置 密 码 / 停 用」**。
更关键的是:列宽合计超过可用宽度时必然横向滚动,而**钉住的操作列会盖住紧邻它的那一列**(实测把「状态」整个遮住了,表头挤成「进度操作」)—— 这是固定列的固有行为,不是配置错误。
**踩过两次的教训:去掉 `fixed` 看似不压盖了,实际是把操作列滚出视野,用户根本看不到操作入口 —— 比压盖更糟。**
真正的解法只有一条:把列数减到能在可用宽度内放下(账号管理目前 5 列 800px,还需要再减一列)。
- ⚠️ **改中文内容一律用 Python 脚本文件,不要用 PowerShell 内联命令**(本项目已写进规则,我又犯了一次):
用 `python -c @"..."@` 内联改这两个 vue 文件时,PowerShell 把 `fixed="right"` 的 `f` 吃成了一个**不可见字符**(变成 `ixed="right"`),同时引入"不规则空白"。
肉眼完全看不出来,只有 eslint 的 `no-irregular-whitespace` 能发现。
---
**关键实现决策(不要在后继开发中改回去)** **关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 | | 决策 | 理由 |
+219
View File
@@ -0,0 +1,219 @@
/**
* 收集任务接口(《03-接口设计》§7.1 ~ §7.5)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 审核流配置(§3.2) */
export interface AuditConfig {
needClassAudit: boolean
needAdminAudit: boolean
classAuditTimeoutHours: number
adminAuditTimeoutHours: number
maxRejectCount: number
allowProxyUpload: boolean
/** 批量通过只带无问题项。**铁律,不可关闭** —— 传 false 也会被服务端扳回 true */
batchApproveOnlyGreen: boolean
}
/** 压缩参数(§3.11) */
export interface CompressConfig {
defaultMaxSizeKb: number
defaultTargetDpi: number
defaultQuality: number
minQuality: number
hardFloorDpi: number
maxPixels: number
keepPngForScreenshot: boolean
markWhenForced: boolean
}
/**
* 任务统计快照(§3.3)。
*
* ⚠️ **M1 阶段后端恒返回 `null`** —— 它依赖名册(M2)与提交单(M3)。
* 界面要显示「待导入名册」,而不是把 null 当成 0 显示成"完成率 0%"。
*/
export interface TaskStat {
rosterTotal: number
submittedCount: number
approvedCount: number
pendingCount: number
rejectedCount: number
unsubmittedCount: number
submitRate: string
passRate: string
abnormalCount: number
avgAuditHours: number
}
/** 收集任务(§3.1) */
export interface TaskVO {
id: number
taskCode: string
taskName: string
/** 1 学校收集 / 2 社会收集(二期) */
collectMode: number
batchNo: string
majorName: string
jobName: string | null
jobLevel: string | null
/** 1 自主认定 / 2 社会评价(二期)/ 3 专项职业能力(二期) */
certType: number
startTime: string | null
endTime: string | null
auditConfig: AuditConfig
namingTemplate: string
compressConfig: CompressConfig
/** 恒为 true —— 名单门禁是产品根基 */
rosterRequired: boolean
/** 0 草稿 / 1 收集中 / 2 已截止 / 3 已归档 / 4 已暂停 */
status: number
publishedTime: string | null
/** M1 阶段恒为 null */
stat: TaskStat | null
createTime: string
}
/** 建任务请求(§7.2) */
export interface TaskUpsertReq {
taskName: string
collectMode: number
batchNo: string
majorName: string
jobName?: string | null
jobLevel?: string | null
certType: number
startTime: string
endTime: string
auditConfig?: Partial<AuditConfig>
namingTemplate?: string
compressConfig?: Partial<CompressConfig>
}
export interface TaskCreateResult {
id: number
taskCode: string
status: number
}
export interface TaskPublishResult {
status: number
publishedTime: string
warnings: string[]
qrcodeUrl: string
}
export interface TaskListQuery {
keyword?: string
status?: number
collectMode?: number
batchNo?: string
orgId?: number
pageNum?: number
pageSize?: number
}
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
export function getTaskList(params: TaskListQuery) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
}
export function getTaskDetail(id: number) {
return http.get<TaskVO>(`/tasks/${id}`)
}
export function addTask(data: TaskUpsertReq) {
return http.post<TaskCreateResult>('/tasks', data)
}
export function updateTask(id: number, data: TaskUpsertReq) {
return http.put<void>(`/tasks/${id}`, data)
}
/** 删除:只有草稿可以删 */
export function deleteTask(id: number) {
return http.delete<void>(`/tasks/${id}`)
}
/** 发布。前置 5 项全是阻断级,force 无法绕过 */
export function publishTask(id: number, force = false) {
return http.post<TaskPublishResult>(`/tasks/${id}/publish`, { force })
}
/** 暂停:幂等。已提交的照常审核,且可以恢复 —— 与「截止」不是一回事 */
export function pauseTask(id: number, reason?: string) {
return http.post<void>(`/tasks/${id}/pause`, { reason })
}
/** 恢复:幂等 */
export function resumeTask(id: number) {
return http.post<void>(`/tasks/${id}/resume`)
}
/** 截止:不可逆 */
export function closeTask(id: number) {
return http.post<void>(`/tasks/${id}/close`, {})
}
/** 归档:不可逆,要求无待审单 */
export function archiveTask(id: number, archiveRemark?: string) {
return http.post<void>(`/tasks/${id}/archive`, { archiveRemark })
}
// ── 枚举镜像 ────────────────────────────────────────
/** 任务状态 → 文案与颜色(对应 collect_task.status) */
export const TASK_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '草稿', color: 'gray' },
1: { text: '收集中', color: 'green' },
2: { text: '已截止', color: 'orange' },
3: { text: '已归档', color: 'purple' },
4: { text: '已暂停', color: 'red' }
}
/** 收集模式(一期只启用 1) */
export const COLLECT_MODE_NAMES: Record<number, string> = {
1: '学校收集',
2: '社会收集(二期)'
}
/** 认定类型(一期只启用 1) */
export const CERT_TYPE_NAMES: Record<number, string> = {
1: '自主认定',
2: '社会评价(二期)',
3: '专项职业能力(二期)'
}
/** 任务状态常量,避免界面上到处写魔法数字 */
export const TASK_STATUS_DRAFT = 0
export const TASK_STATUS_COLLECTING = 1
export const TASK_STATUS_CLOSED = 2
export const TASK_STATUS_ARCHIVED = 3
export const TASK_STATUS_PAUSED = 4
/**
* 命名模板可用变量(与 PRD 的"可用变量"一栏逐字对齐)。
*
* 必须与服务端 `TaskService.NAMING_PLACEHOLDERS` 一致:
* 前端多给一个,用户点了会被服务端以 40020 拒掉。
*/
export const NAMING_VARIABLES = [
'姓名',
'证件号',
'证件类型',
'材料名',
'班级',
'专业',
'工种',
'等级',
'报名点',
'序号',
'日期',
'后缀'
] as const
/** 默认命名模板(与 DDL 的列默认值一致) */
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'
+1 -2
View File
@@ -51,8 +51,7 @@ export const XT_MENUS: XtMenu[] = [
component: 'task/index', component: 'task/index',
group: 'collect', group: 'collect',
sort: 11, sort: 11,
ready: false, ready: true
note: '建任务、配表单与材料规则、发布与暂停。对应里程碑 M1。'
}, },
{ {
key: 'roster', key: 'roster',
@@ -0,0 +1,338 @@
<template>
<a-modal v-model:visible="visible" :title="title" :width="640" :mask-closable="false"
:ok-loading="submitting" @ok="confirm" @cancel="close">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="14">
<a-form-item field="taskName" label="任务名称">
<a-input v-model="form.taskName" placeholder="如 2026年9月机电专业认定材料收集"
:max-length="200" allow-clear />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="batchNo" label="批次号">
<a-input v-model="form.batchNo" placeholder="如 202609" :max-length="8" allow-clear />
<template #extra>
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
</template>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="majorName" label="专业">
<a-input v-model="form.majorName" placeholder="如 数控加工" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobName" label="报考工种">
<a-input v-model="form.jobName" placeholder="可后补" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobLevel" label="报考等级">
<a-input v-model="form.jobLevel" placeholder="可后补" :max-length="32" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="startTime" label="填报开始">
<a-date-picker v-model="form.startTime" value-format="YYYY-MM-DD" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endTime" label="填报截止">
<a-date-picker v-model="form.endTime" value-format="YYYY-MM-DD" style="width: 100%" />
<template #extra>
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
</template>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">一期范围</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="收集模式">
<a-input model-value="学校收集" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="认定类型">
<a-input model-value="自主认定" disabled />
</a-form-item>
</a-col>
</a-row>
<a-alert type="info" :show-icon="false" class="task-form__scope">
<template #title>
一期只做 <strong>学校收集 + 自主认定</strong>。
社会收集、社会评价、专项职业能力已后置二期,数据结构保留、界面暂不开放。
</template>
</a-alert>
<a-divider orientation="left">命名规则</a-divider>
<a-form-item field="namingTemplate" label="文件命名模板">
<a-input v-model="form.namingTemplate" :max-length="256" allow-clear />
<template #extra>
<span class="task-form__hint">
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
缺了任一项,文件名就无法唯一归属到人。
</span>
</template>
</a-form-item>
<div class="task-form__vars">
<span class="task-form__vars-label">插入变量</span>
<a-space wrap size="mini">
<a-button v-for="opt in namingVariableOptions" :key="opt.value" size="mini" type="outline"
@click="appendVariable(opt.value)">
{{ opt.label }}
</a-button>
</a-space>
</div>
<a-form-item label="预览(以「张同学 / 361029200912100010 / 身份证」为例)">
<code class="task-form__preview">{{ namingPreview }}</code>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
import { Message } from '@arco-design/web-vue'
import * as taskApi from '@/apis/xt/task'
import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
/**
* 建 / 改任务弹窗。
*
* 三处刻意的设计:
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,
* 一期也不启用其它取值。可编辑只会让人以为能改。
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
*/
defineOptions({ name: 'TaskFormModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
const formRef = useTemplateRef('formRef')
const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
/**
* 表单自己的类型。
*
* 与 {@link TaskUpsertReq} 的差别只在 `jobName` / `jobLevel`:
* 接口那边允许 `null`(表示"清空"),而 Arco 的 `a-input` 的 v-model 只接受
* `string | undefined` —— 直接绑 `string | null | undefined` 会报 TS2322。
* 界面上用空串表示"没填",发给服务端前由 Service 的 `trimToNull` 转成 null。
*/
type TaskForm = Omit<TaskUpsertReq, 'jobName' | 'jobLevel'> & {
jobName: string
jobLevel: string
}
const getInitForm = (): TaskForm => ({
taskName: '',
collectMode: 1,
batchNo: '',
majorName: '',
jobName: '',
jobLevel: '',
certType: 1,
startTime: '',
endTime: '',
namingTemplate: DEFAULT_NAMING_TEMPLATE
})
const form = reactive(getInitForm())
const rules: FormInstance['rules'] = {
taskName: [{ required: true, message: '请填写任务名称' }],
batchNo: [
{ required: true, message: '请填写批次号' },
{ match: /^\d{4,8}$/, message: '批次号应为 4~8 位数字' }
],
majorName: [{ required: true, message: '请填写专业' }],
startTime: [{ required: true, message: '请选择填报开始时间' }],
endTime: [
{ required: true, message: '请选择填报截止时间' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value && form.startTime && value <= form.startTime) {
callback('截止时间必须晚于开始时间')
return
}
callback()
}
}
],
namingTemplate: [{ required: true, message: '请填写命名模板' }]
}
/**
* 预览时把 `{自定义字段.xxx}` 整体替换掉。
*
* 放在模块作用域而不是 computed 里:正则每次渲染都重新编译一遍是纯浪费,
* 而且这条规则(e18e/prefer-static-regex)在项目里是开着的。
*/
const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
/** 命名预览:把变量替换成样例值,让用户一眼看出最终文件名长什么样 */
const namingPreview = computed(() => {
const sample: Record<string, string> = {
姓名: '张同学',
证件号: '361029200912100010',
证件类型: '居民身份证',
材料名: '身份证',
班级: '24中技数控1班',
专业: '数控加工',
工种: '数控车工',
等级: '四级',
报名点: '示范职业学校',
序号: '007',
日期: '20260928',
后缀: '.jpg'
}
let out = form.namingTemplate || ''
for (const [k, v] of Object.entries(sample)) {
out = out.replaceAll(`{${k}}`, v)
}
// 自定义字段在预览里没有真实值,用占位提示而不是留一个空
out = out.replace(CUSTOM_FIELD_RE, '(自定义字段)')
return out || '—'
})
/**
* 变量按钮的文案。
*
* ⚠️ **不要在模板里拼花括号**(比如 `{{ '{' + v + '}' }}`)。
* Vue 的插值解析器是靠找 `}}` 来收尾的,而模板里一旦出现内层的 `}` 与结尾的 `}}`
* 相邻(eslint --fix 会把它"帮忙"改成模板字符串 `` `{${v}}` ``,正好制造这种情况),
* 就会报 `Error parsing JavaScript expression: Unexpected token, expected "}"` ——
* 而且这个错误**只在构建/编译时才出现**,编辑器里看不出问题。
* 所以在 script 里算好,模板里只做纯取值。
*/
const namingVariableOptions = NAMING_VARIABLES.map((v) => ({ value: v, label: `{${v}}` }))
function appendVariable(v: string) {
form.namingTemplate = `${form.namingTemplate ?? ''}{${v}}`
}
async function add() {
detailId.value = null
Object.assign(form, getInitForm())
visible.value = true
}
function edit(row: TaskVO) {
detailId.value = row.id
Object.assign(form, getInitForm())
form.taskName = row.taskName
form.batchNo = row.batchNo ?? ''
form.majorName = row.majorName ?? ''
form.jobName = row.jobName ?? ''
form.jobLevel = row.jobLevel ?? ''
form.startTime = (row.startTime ?? '').slice(0, 10)
form.endTime = (row.endTime ?? '').slice(0, 10)
form.namingTemplate = row.namingTemplate ?? DEFAULT_NAMING_TEMPLATE
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
const payload: TaskUpsertReq = {
taskName: form.taskName.trim(),
collectMode: 1,
batchNo: (form.batchNo ?? '').trim(),
majorName: form.majorName.trim(),
jobName: form.jobName?.trim() || null,
jobLevel: form.jobLevel?.trim() || null,
certType: 1,
startTime: form.startTime,
// 只给日期时补上当天最后一秒,避免"截止日期当天 00:00 就截止了"
endTime: form.endTime.length <= 10 ? `${form.endTime}T23:59:59` : form.endTime,
namingTemplate: (form.namingTemplate ?? '').trim()
}
if (isEdit.value) {
await taskApi.updateTask(detailId.value!, payload)
Message.success('已保存')
} else {
const res = await taskApi.addTask(payload)
Message.success(`任务已创建,编码 ${res.data?.taskCode ?? ''}(草稿)`)
}
visible.value = false
emit('save-success')
return true
} finally {
submitting.value = false
}
}
defineExpose({ add, edit })
</script>
<style lang="scss" scoped>
.task-form {
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
code {
padding: 0 3px;
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--color-fill-2);
border-radius: 2px;
}
}
&__scope {
margin-top: -6px;
line-height: 1.8;
}
&__vars {
display: flex;
gap: 10px;
align-items: baseline;
margin: -8px 0 18px;
}
&__vars-label {
flex: none;
font-size: 12px;
color: var(--color-text-3);
}
&__preview {
display: block;
width: 100%;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
color: var(--color-text-1);
word-break: break-all;
background: var(--color-fill-2);
border-radius: 4px;
}
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>收集任务</template>
<template #extra>
<span class="task__hint">共 {{ total }} 个任务</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
<template #icon><IconPlus /></template>
新建任务
</a-button>
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
class="task__search" @search="search" @clear="search" />
</a-space>
</div>
<!--
列宽是算过的:1366 宽的屏幕上,减去 200 侧栏后内容区约 830px。
下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。
之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
-->
<!--
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
实测(1366 宽的屏幕):
· 收集任务页:左侧栏 200,表格可用约 824px
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
所以账号管理页必须保留 `fixed="right"`:
列宽合计 800px 装不进 574px,必然横向滚动;
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 840 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="任务" :width="230" ellipsis tooltip>
<template #cell="{ record }">
<div class="task__name">{{ record.taskName }}</div>
<div class="task__code">{{ record.taskCode }}</div>
</template>
</a-table-column>
<a-table-column title="批次 / 专业" :width="120">
<template #cell="{ record }">
<div>{{ record.batchNo }}</div>
<div class="task__code">{{ record.majorName }}</div>
</template>
</a-table-column>
<a-table-column title="填报时间" :width="160">
<template #cell="{ record }">
<div class="task__muted">{{ fmt(record.startTime) }} 起</div>
<div class="task__muted">{{ fmt(record.endTime) }} 止</div>
</template>
</a-table-column>
<a-table-column title="进度" :width="90">
<template #cell="{ record }">
<!--
stat 在 M1 阶段后端恒为 null(它依赖名册 M2 与提交单 M3)。
这里明确显示「待导入名册」,而不是把 null 当成 0 显示成"0%" ——
后者会让人以为"没人填",而真相是"还没有分母"。
-->
<span v-if="!record.stat" class="task__muted">待导入名册</span>
<span v-else class="task__muted">{{ record.stat.submitRate }}</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="80" align="center">
<template #cell="{ record }">
<a-tag :color="TASK_STATUS[record.status]?.color ?? 'gray'">
{{ TASK_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="160" align="center" fixed="right">
<template #cell="{ record }">
<a-space v-if="canManage" size="mini" wrap>
<template v-if="record.status === TASK_STATUS_DRAFT">
<a-link @click="onEdit(record)">编辑</a-link>
<a-link @click="onPublish(record)">发布</a-link>
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
@ok="onDelete(record)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
<a-link status="warning" @click="onPause(record)">暂停</a-link>
<a-link @click="onClose(record)">截止</a-link>
</template>
<template v-else-if="record.status === TASK_STATUS_PAUSED">
<a-link status="success" @click="onResume(record)">恢复</a-link>
<a-link @click="onClose(record)">截止</a-link>
</template>
<template v-else-if="record.status === TASK_STATUS_CLOSED">
<a-link @click="onArchive(record)">归档</a-link>
</template>
<span v-else class="task__muted">只读</span>
</a-space>
<span v-else class="task__muted">只读</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<TaskFormModal ref="formModalRef" @save-success="load" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TaskVO } from '@/apis/xt/task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import * as taskApi from '@/apis/xt/task'
import {
TASK_STATUS,
TASK_STATUS_CLOSED,
TASK_STATUS_COLLECTING,
TASK_STATUS_DRAFT,
TASK_STATUS_PAUSED
} from '@/apis/xt/task'
import { useUserStore } from '@/stores'
import TaskFormModal from './TaskFormModal.vue'
/**
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
*
* 这一屏的核心不是"列表 + 增删改",而是**状态机**:
* 草稿 →(发布)→ 收集中 →(暂停)⇄(恢复)→ 已暂停 →(截止)→ 已截止 →(归档)→ 已归档
*
* 两个容易做错的地方,界面上刻意做了区分:
* 1. **「暂停」和「截止」是两件事**:暂停可以恢复、已提交的照常审核;
* 截止不可逆。所以暂停用普通确认,截止用"不可逆"措辞的二次确认。
* 2. **发布可能被拦**:5 项前置校验都是阻断级。发起前不讲清"还差什么",
* 用户会以为是系统坏了。所以发布失败时把服务端列出的缺项原样展示,
* 并把"仅 P1 提醒、可通过 force 跳过"的那一步单独做成二次确认。
*/
defineOptions({ name: 'Task' })
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage'))
const loading = ref(false)
const list = ref<TaskVO[]>([])
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
const formModalRef = useTemplateRef('formModalRef')
const statusChips = [
{ label: '全部', value: undefined },
{ label: '草稿', value: TASK_STATUS_DRAFT },
{ label: '收集中', value: TASK_STATUS_COLLECTING },
{ label: '已暂停', value: TASK_STATUS_PAUSED },
{ label: '已截止', value: TASK_STATUS_CLOSED }
]
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v: string | null) {
return v ? v.replace('T', ' ').slice(0, 10) : '—'
}
async function load() {
loading.value = true
try {
const res = await taskApi.getTaskList({
keyword: query.keyword.trim() || undefined,
batchNo: query.batchNo.trim() || undefined,
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number | undefined) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onAdd() {
formModalRef.value?.add()
}
function onEdit(row: TaskVO) {
formModalRef.value?.edit(row)
}
async function onDelete(row: TaskVO) {
await taskApi.deleteTask(row.id)
Message.success('已删除')
await load()
}
/**
* 发布。
*
* 先不带 force 试一次:如果服务端说"存在待确认的提醒",说明 5 项阻断级校验都过了,
* 只剩 P1 级提醒(比如某班没指定初审人)。这时才用二次确认 + force 重发。
* 如果返回的是阻断级错误码(40016/40017/40018/40019/40020),
* 说明根本还没配齐 —— 直接展示缺项,**不给 force 的入口**(给了也绕不过去,只会让人白试)。
*/
async function onPublish(row: TaskVO) {
try {
const res = await taskApi.publishTask(row.id, false)
Message.success('已发布,学生可以开始填报了')
const warns = res.data?.warnings ?? []
if (warns.length) {
Modal.info({ title: '发布时的提醒', content: warns.join(';') })
}
await load()
} catch (e) {
const err = e as Error & { bizCode?: number }
const msg = err?.message ?? ''
// 阻断级错误码:把缺项讲清楚,不给 force
const blocking = [40016, 40017, 40018, 40019, 40020]
if (err?.bizCode && blocking.includes(err.bizCode)) {
Modal.warning({
title: '还不能发布',
content: `${msg}
这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。`,
hideCancel: true
})
return
}
// P1 级提醒:二次确认后可 force
Modal.confirm({
title: '发现待确认的提醒',
content: `${msg}
确认无误后可以继续发布。`,
okText: '确认并发布',
onOk: async () => {
await taskApi.publishTask(row.id, true)
Message.success('已发布')
await load()
}
})
}
}
function onPause(row: TaskVO) {
const reason = ''
Modal.confirm({
title: `暂停「${row.taskName}」?`,
content: '暂停后学生<b>不能提交</b>,但<b>已提交的照常审核</b>,而且随时可以恢复。'
+ '适合"名单要改、规则要调"的场景 —— 如果是不再收集了,请用「截止」。',
okText: '暂停',
onOk: async () => {
await taskApi.pauseTask(row.id, reason || undefined)
Message.success('已暂停')
await load()
}
})
}
async function onResume(row: TaskVO) {
await taskApi.resumeTask(row.id)
Message.success('已恢复')
await load()
}
function onClose(row: TaskVO) {
Modal.warning({
title: `截止「${row.taskName}」?`,
content: '截止后学生不能提交,且<b>不可逆</b>(不能再恢复为收集中)。已提交的仍可审核。',
okText: '确认截止',
hideCancel: false,
onBeforeOk: async () => {
await taskApi.closeTask(row.id)
Message.success('已截止')
await load()
return true
}
})
}
function onArchive(row: TaskVO) {
Modal.warning({
title: `归档「${row.taskName}」?`,
content: '归档后<b>连审核也不能做</b>,整体转为只读。有未审完的单子会被拦下。',
okText: '确认归档',
hideCancel: false,
onBeforeOk: async () => {
await taskApi.archiveTask(row.id)
Message.success('已归档')
await load()
return true
}
})
}
onMounted(load)
</script>
<style lang="scss" scoped>
.task {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__batch {
width: 120px;
}
&__search {
width: 240px;
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--color-text-3);
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
}
</style>
+46 -24
View File
@@ -59,22 +59,40 @@
<!-- <!--
⚠️ `:scroll` 不是可选的:Arco 的 `fixed: 'right'` 列**必须**配合 scroll 配置 ⚠️ `:scroll` 不是可选的:Arco 的 `fixed: 'right'` 列**必须**配合 scroll 配置
才会预留位置;不加的话那一列会直接浮在其它列上面, 才会预留位置;不加的话那一列会直接浮在其它列上面。
看起来像"操作列插到手机号前面了"。
总列宽 1140,这里给 1160 让它在常见分辨率下不出现横向滚动条、 ⚠️ 列宽也不是随便给的:1366 宽的屏幕减去 200 侧栏,内容区只有约 830px。
窄屏时又能正常滚动并把操作列钉在右边。 下面 6 列合计 860 —— 基本贴边。**下次加列前先按这个口径算一遍**,
否则会横向滚动,而钉住的「操作」列会盖住紧邻它的那一列
(实测把「状态」整个遮住了,表头挤成「进度操作」)。
-->
<!--
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
实测(1366 宽的屏幕):
· 收集任务页:左侧栏 200,表格可用约 824px
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
所以账号管理页必须保留 `fixed="right"`:
列宽合计 800px 装不进 574px,必然横向滚动;
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
--> -->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1160 }" @page-change="onPageChange"> :scroll="{ x: 800 }" @page-change="onPageChange">
<template #columns> <template #columns>
<a-table-column title="姓名 / 账号" :width="190"> <a-table-column title="姓名 / 账号" :width="175">
<template #cell="{ record }"> <template #cell="{ record }">
<div class="user__name">{{ record.realName }}</div> <div class="user__name">{{ record.realName }}</div>
<div class="user__account">{{ record.username ?? '(未设账号)' }}</div> <div class="user__account">{{ record.username ?? '(未设账号)' }}</div>
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="角色" :width="200"> <a-table-column title="角色" :width="170">
<template #cell="{ record }"> <template #cell="{ record }">
<a-space v-if="record.roleNames.length" wrap size="mini"> <a-space v-if="record.roleNames.length" wrap size="mini">
<a-tag v-for="n in record.roleNames" :key="n" size="small">{{ n }}</a-tag> <a-tag v-for="n in record.roleNames" :key="n" size="small">{{ n }}</a-tag>
@@ -83,13 +101,7 @@
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="所属组织" data-index="orgName" :width="160"> <a-table-column title="手机号" :width="125">
<template #cell="{ record }">
{{ record.orgName ?? '—' }}
</template>
</a-table-column>
<a-table-column title="手机号" :width="140">
<template #cell="{ record }"> <template #cell="{ record }">
<span class="user__mono">{{ record.phoneMasked ?? '—' }}</span> <span class="user__mono">{{ record.phoneMasked ?? '—' }}</span>
</template> </template>
@@ -102,20 +114,23 @@
看着像 bug。类型仍由接口返回,需要时在详情里看即可 —— 列表以「角色」为准。 看着像 bug。类型仍由接口返回,需要时在详情里看即可 —— 列表以「角色」为准。
--> -->
<a-table-column title="状态" :width="100" align="center"> <a-table-column title="状态 / 最后登录" :width="130" align="center">
<template #cell="{ record }"> <template #cell="{ record }">
<a-tag :color="USER_STATUS[record.status]?.color ?? 'gray'"> <a-tag :color="USER_STATUS[record.status]?.color ?? 'gray'">
{{ USER_STATUS[record.status]?.text ?? '未知' }} {{ USER_STATUS[record.status]?.text ?? '未知' }}
</a-tag> </a-tag>
</template> <!--
</a-table-column> 最后登录并进这一列,是为了给窄屏省宽度。
1366 的屏幕减去 200 侧栏,内容区只有约 830px,
<a-table-column title="最后登录" :width="150"> 7 列各占一份根本放不下 —— 放不下的后果是横向滚动,
<template #cell="{ record }"> 而钉住的「操作」列会直接盖住紧邻它的那一列(实测「状态」被整个遮住)。
<span v-if="record.lastLoginTime" class="user__muted"> 这里用"标签 + 一行小字"把两列并成一列,信息一点没少。
{{ record.lastLoginTime.replace('T', ' ').slice(0, 16) }} -->
</span> <div class="user__muted user__lastlogin">
<span v-else class="user__muted">从未登录</span> {{ record.lastLoginTime
? record.lastLoginTime.replace('T', ' ').slice(0, 16)
: '从未登录' }}
</div>
</template> </template>
</a-table-column> </a-table-column>
@@ -404,6 +419,13 @@ onMounted(() => {
color: var(--color-text-3); color: var(--color-text-3);
} }
/** 与状态标签同列的"最后登录"小字 */
&__lastlogin {
margin-top: 4px;
font-size: 11px;
line-height: 1.5;
}
&__pwd-box { &__pwd-box {
margin: 16px 0 0; margin: 16px 0 0;
} }
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-01 收集任务(CRUD + 状态机)验收测试。
覆盖《03-接口设计》§7.1~§7.5:
[x] 建任务:默认值、编码生成、一期范围限制(40044)
[x] 改任务:草稿可改模式,已发布不可改
[x] 状态机:草稿→发布→暂停⇄恢复→截止→归档,幂等与错误码
[x] 发布前置校验:5 项全部阻断,force 无法绕过
[x] 可见范围:班主任只看自己带的班级的任务
[x] 删除:仅草稿
⚠️ 关于「发布」的可达性
发布要求名册非空 + ≥1 项必传材料 + 已设置覆盖班级,而这三样分别属于 M2 / M1-04 / M1-02。
所以本脚本用**直接写库**的方式造前置数据(这是测试夹具,不是产品捷径):
只有这样才能真正走到 已发布/暂停/恢复/截止/归档 那几个状态并验证它们。
用法
python scripts/e2e-m1-task.py
python scripts/e2e-m1-task.py --base http://localhost:8080
依赖:cryptography;且需要能 `docker exec xt-pg`(写测试夹具)
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
# ── 断言 ──────────────────────────────────────────────
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def show(body: dict, indent: int = 9) -> None:
pad = " " * indent
text = json.dumps(body, ensure_ascii=False)
print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}")
# ── SQL(测试夹具)────────────────────────────────────
def sql(stmt: str) -> tuple[bool, str]:
"""直接执行 SQL —— 只用于造/清测试夹具。"""
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
# ── HTTP ────────────────────────────────────────────
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def main() -> int:
ap = argparse.ArgumentParser(description="M1-01 收集任务 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--admin", default="admin")
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"M1-01 收集任务 E2E base={base} 租户={args.tenant}")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, args.admin, args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
tenant_id = (body["data"].get("tenant") or {}).get("id")
ok(f"管理员登录(租户 id={tenant_id})")
st, bd = call("GET", f"{base}/api/v1/orgs/tree", token)
def first_class(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
return n
hit = first_class(n.get("children"))
if hit:
return hit
return None
cls = first_class(bd.get("data"))
if not cls:
fail("取一个班级组织", "demo 租户里没有 orgType=3 的组织,E2E 无法继续")
return 1
ok(f"取到班级组织:{cls['orgName']}(id={cls['id']})")
# ── 1. 建任务:校验与默认值 ─────────────────────────
print("\n[1] 建任务")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": "202609",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
check("collectMode=2(社会收集)被拒 40044", body.get("code") == 40044,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
"majorName": "数控加工", "certType": 3,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
check("certType=3(专项职业能力)被拒 40044", body.get("code") == 40044,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "2026-09",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
check("批次号格式非法被拒", body.get("code") != 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-10-15", "endTime": "2026-09-28",
})
check("截止早于开始被拒", body.get("code") != 0,
f"code={body.get('code')} msg={body.get('msg')}")
# 命名模板:缺必备变量
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
"namingTemplate": "{姓名}-{材料名}",
})
check("命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
f"code={body.get('code')} msg={body.get('msg')}")
# 命名模板:未知变量
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
"namingTemplate": "{姓名}-{身证号}-{材料名}",
})
check("命名模板含未知变量被拒 40020", body.get("code") == 40020,
f"code={body.get('code')} msg={body.get('msg')}")
batch = "20" + suffix[:4] if suffix[:4].isdigit() else "2099"
# 顺带验证 application.yml 的 spring.jackson.* 真的生效。
# Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,
# 没引它时这些配置是**静默失效**的:看着配了,其实一行都没读。
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E未知字段 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
"thisFieldDoesNotExist": "fail-on-unknown-properties=false 生效时才不会报错",
})
check("请求带未知字段也能通过(spring.jackson 配置真的生效)",
body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"
" —— 若为 40001/50000,说明 spring-boot-starter-jackson 没生效")
if body.get("code") == 0:
CREATED_TASK_IDS.append(body["data"]["id"])
# 合法创建
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
if not check("建任务成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"):
show(body)
return 1
task_id = body["data"]["id"]
task_code = body["data"]["taskCode"]
CREATED_TASK_IDS.append(task_id)
print(f" → id={task_id} taskCode={task_code} status={body['data']['status']}")
check("返回的状态是草稿(0)", body["data"]["status"] == 0, f"status={body['data']['status']}")
check("任务编码 = 批次号 + 4 位序号",
task_code.startswith(batch) and len(task_code) == len(batch) + 4 and task_code[len(batch):].isdigit(),
f"batch={batch} taskCode={task_code}")
# ── 2. 详情与默认值 ─────────────────────────────────
print("\n[2] 详情与默认值")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
d = body.get("data") or {}
ac = d.get("auditConfig") or {}
cc = d.get("compressConfig") or {}
check("rosterRequired 恒为 true", d.get("rosterRequired") is True,
f"rosterRequired={d.get('rosterRequired')}")
check("stat 为 null(名册未接,不能假装是 0)",
d.get("stat") is None, f"stat={d.get('stat')}")
check("auditConfig 默认:需班主任初审 + 需终审",
ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True,
f"auditConfig={ac}")
check("auditConfig 默认:48 / 72 / 3",
ac.get("classAuditTimeoutHours") == 48 and ac.get("adminAuditTimeoutHours") == 72
and ac.get("maxRejectCount") == 3,
f"auditConfig={ac}")
check("auditConfig:批量通过只带无问题项恒为 true",
ac.get("batchApproveOnlyGreen") is True, f"batchApproveOnlyGreen={ac.get('batchApproveOnlyGreen')}")
check("compressConfig 默认:1024KB / 300dpi / q85",
cc.get("defaultMaxSizeKb") == 1024 and cc.get("defaultTargetDpi") == 300
and cc.get("defaultQuality") == 85,
f"compressConfig={cc}")
check("compressConfig 默认:minQuality=70 / hardFloorDpi=200",
cc.get("minQuality") == 70 and cc.get("hardFloorDpi") == 200,
f"compressConfig={cc}")
check("compressConfig:截图保 PNG 恒为 true",
cc.get("keepPngForScreenshot") is True, f"keepPngForScreenshot={cc.get('keepPngForScreenshot')}")
# 铁律:批量通过只带无问题项不可关闭
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工改", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-20",
"auditConfig": {"batchApproveOnlyGreen": False, "needAdminAudit": False},
})
check("修改草稿任务成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
d = body.get("data") or {}
check("客户端传 batchApproveOnlyGreen=false 被服务端扳回 true(铁律不可关)",
(d.get("auditConfig") or {}).get("batchApproveOnlyGreen") is True,
f"batchApproveOnlyGreen={(d.get('auditConfig') or {}).get('batchApproveOnlyGreen')}")
check("needsAdminAudit=false 被采纳(它是可配的)",
(d.get("auditConfig") or {}).get("needAdminAudit") is False,
f"needAdminAudit={(d.get('auditConfig') or {}).get('needAdminAudit')}")
check("专业已改为「数控加工改」", d.get("majorName") == "数控加工改", f"majorName={d.get('majorName')}")
# ── 3. 列表与过滤 ───────────────────────────────────
print("\n[3] 列表与过滤")
status, body = call("GET", f"{base}/api/v1/tasks?pageNum=1&pageSize=5", token)
dd = body.get("data") or {}
check("列表返回 {list,total,pageNum,pageSize}",
isinstance(dd.get("list"), list) and dd.get("pageNum") == 1 and dd.get("pageSize") == 5,
f"keys={list(dd.keys())}")
status, body = call("GET", f"{base}/api/v1/tasks?"
+ urllib.parse.urlencode({"keyword": f"E2E 任务 {suffix}"}), token)
hits = (body.get("data") or {}).get("list") or []
check("按关键字能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
status, body = call("GET", f"{base}/api/v1/tasks?"
+ urllib.parse.urlencode({"status": 0, "batchNo": batch}), token)
hits = (body.get("data") or {}).get("list") or []
check("按状态+批次过滤能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
status, body = call("GET", f"{base}/api/v1/tasks?status=3", token)
hits = (body.get("data") or {}).get("list") or []
check("按状态过滤排除了草稿任务", not any(h["id"] == task_id for h in hits),
f"状态=3 却命中了草稿任务")
# ── 4. 发布前置校验(全部阻断)──────────────────────
print("\n[4] 发布前置校验(阻断级,force 绕不过)")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
check("空任务发布被拒 40016(名册为空)", body.get("code") == 40016,
f"code={body.get('code')} msg={body.get('msg')}")
msg = body.get("msg") or ""
check("一次把所有缺项都列出来了(不是撞到第一个就返回)",
("材料" in msg) and ("班级" in msg),
f"msg={msg}")
check("force=true 也无法绕过阻断级校验", body.get("code") == 40016,
f"code={body.get('code')}")
# ── 5. 造前置数据后发布 ─────────────────────────────
print("\n[5] 造前置数据(SQL 夹具)后发布")
okk, out = sql(
f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 'E2E学生A', 'seed', 'seed')")
check("夹具:写入 1 行名册", okk, out)
okk, out = sql(
f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
check("夹具:写入 1 项必传材料", okk, out)
okk, out = sql(
f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 1)")
check("夹具:写入 1 条覆盖班级(不指定初审人 → 会回退到班级负责人)", okk, out)
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {})
check("班级未指定初审人 → 只给 P1 提醒,不阻断", body.get("code") != 0 and "确认" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
if not check("带 force 发布成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"):
show(body)
else:
check("发布后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
f"status={(body.get('data') or {}).get('status')}")
check("发布了时间", bool((body.get("data") or {}).get("publishedTime")),
"publishedTime 为空")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
check("已发布再发布被拒", body.get("code") != 0, f"code={body.get('code')}")
# 已发布不可改模式
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": batch,
"majorName": "数控加工改", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-20",
})
check("已发布不可改 collectMode", body.get("code") != 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 6. 暂停 / 恢复(幂等)──────────────────────────
print("\n[6] 暂停 / 恢复")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token,
{"reason": "名册需补充 3 人"})
check("暂停成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
check("暂停后状态=已暂停(4)", (body.get("data") or {}).get("status") == 4,
f"status={(body.get('data') or {}).get('status')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
check("重复暂停幂等(返回成功,不报错)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
check("恢复成功", body.get("code") == 0, f"code={body.get('code')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
check("恢复后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
f"status={(body.get('data') or {}).get('status')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
check("重复恢复幂等", body.get("code") == 0, f"code={body.get('code')}")
# ── 7. 截止 / 归档 ─────────────────────────────────
print("\n[7] 截止 / 归档")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
check("截止成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
check("截止后状态=已截止(2)", (body.get("data") or {}).get("status") == 2,
f"status={(body.get('data') or {}).get('status')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
check("已截止不可暂停(40021)", body.get("code") == 40021,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token,
{"archiveRemark": "E2E 归档"})
check("归档成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
check("归档后状态=已归档(3)", (body.get("data") or {}).get("status") == 3,
f"status={(body.get('data') or {}).get('status')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
check("已归档不可恢复(40022)", body.get("code") == 40022,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
"taskName": "改名试试", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工改", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-20",
})
check("已归档仍可改名称(只锁模式与认定类型)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}")
# ── 8. 草稿路径与删除 ───────────────────────────────
print("\n[8] 草稿路径与删除")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E 草稿 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
draft_id = (body.get("data") or {}).get("id")
if check("再建一个草稿任务", body.get("code") == 0, f"code={body.get('code')}"):
CREATED_TASK_IDS.append(draft_id)
check("任务编码递增(同一批次不重号)",
(body["data"]["taskCode"] != task_code),
f"两次都是 {task_code}")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E草稿2 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
if check("删掉草稿后再建(同批次)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"):
third_id = body["data"]["id"]
third_code = body["data"]["taskCode"]
CREATED_TASK_IDS.append(third_id)
# 回归护栏:软删掉的任务仍然占着唯一约束,编码不能重用,
# 否则会报 duplicate key(且编码同时是文件目录名,复用会串目录)
check("软删后不改用旧编码(唯一约束不看软删标记)",
third_code not in (task_code,),
f"复用了编码 {third_code}")
check("新编码序号继续递增", third_code > task_code,
f"{task_code} → {third_code}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{third_id}", token)
check("再删掉它", body.get("code") == 0, f"code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/pause", token, {})
check("草稿不可暂停(40021)", body.get("code") == 40021,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/resume", token)
check("草稿不可恢复(40022)", body.get("code") == 40022,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/archive", token, {})
check("草稿不可归档", body.get("code") != 0, f"code={body.get('code')}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{draft_id}", token)
check("草稿可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{draft_id}", token)
check("删除后详情查不到", body.get("code") != 0, f"code={body.get('code')}")
# ── 9. 可见范围(班主任只看自己带的班)───────────────
print("\n[9] 可见范围:班主任只看自己带的班级")
ht_user = f"htt{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E班主任{suffix}",
"phone": "13900139901", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造一个班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
ht_id = body["data"]["id"]
ht_pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(ht_id)
# 建两个任务:一个把他设为初审人,一个不设
mine, others = [], []
for tag in ("mine", "other"):
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E可见性-{tag}-{suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
if body.get("code") != 0:
fail(f"造可见性任务 {tag}", f"code={body.get('code')} msg={body.get('msg')}")
continue
tid = body["data"]["id"]
CREATED_TASK_IDS.append(tid)
(mine if tag == "mine" else others).append(tid)
if tag == "mine":
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, head_user_id, status) "
f"VALUES ({tenant_id}, {tid}, {cls['id']}, {ht_id}, 1)")
else:
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
f"VALUES ({tenant_id}, {tid}, {cls['id']}, 1)")
status, body = login(base, ht_user, ht_pwd, args.tenant)
if check("班主任能登录", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks?pageSize=200", token=ht_token)
seen = {t["id"] for t in (body.get("data") or {}).get("list") or []}
check("班主任能看到「自己带的班」的任务",
all(t in seen for t in mine), f"应看到 {mine},实际 {sorted(seen)}")
check("班主任看不到「不是自己带的班」的任务",
all(t not in seen for t in others), f"不该看到 {others},实际 {sorted(seen)}")
status, body = call("POST", f"{base}/api/v1/tasks", ht_token, {
"taskName": "班主任不该能建任务", "collectMode": 1, "batchNo": batch,
"majorName": "x", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-10-15",
})
check("班主任建任务被拒(无 task:manage)", body.get("code") == 40301,
f"code={body.get('code')}")
# ── 10. 清理 ───────────────────────────────────────
print("\n[10] 清理测试数据")
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
okk, out = sql(stmt)
if not okk:
print(f" ⚠ {stmt[:60]}… → {out}")
for uid in CREATED_USER_IDS:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号及其夹具数据")
# ── 汇总 ───────────────────────────────────────────
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())