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