feat(task): 规则模板 FORM 真正可套用 + 已归档任务可删(客户确认的两项)
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
· FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
逻辑只有一份
· RuleTemplateService 的 FORM 分支接上它(不再 skipped)
· 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
套用时按「用系统默认,不覆盖」跳过
二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
演示/测试任务只能改名挪开,库只增不减
· DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
拒绝的话说清「先归档」
· 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
· ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
的「删除后新建不重号」断言守住
· 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
行数/文件数)
验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
e2e-m1-task → 71/71(旧口径断言更新为新口径)
This commit is contained in:
+36
@@ -90,6 +90,42 @@ public class FormSchemaService {
|
||||
|
||||
// ── 保存 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 用一份**已经存在**的 schemaJson 作为新版本保存(规则模板套用 / 任务克隆场景)。
|
||||
*
|
||||
* <p>与 {@link #save(Long, FormSchemaSaveReq)} 的区别:内容<b>不再校验与归一化</b> ——
|
||||
* 它当初就是从某个任务按本服务的规则存出来的,再走一遍"解析 → 对象 → 序列化"
|
||||
* 只会丢掉没建模的字段(与 {@code TaskCloneService} 复制表单是同一个理由)。
|
||||
* <b>状态检查与版本管理保留</b>(与手工保存同一套,两处必须一致)。
|
||||
*
|
||||
* @return 新版本号
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int saveRawVersion(Long taskId, String schemaJson) {
|
||||
if (schemaJson == null || schemaJson.isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表单定义为空,无可保存内容");
|
||||
}
|
||||
CollectTask task = requireTask(taskId);
|
||||
int status = task.getStatus() == null ? -1 : task.getStatus();
|
||||
if (!EDITABLE_TASK_STATUS.contains(status)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"任务已" + statusText(status) + ",表单不可再改");
|
||||
}
|
||||
int nextVersion = latestVersion(taskId) + 1;
|
||||
formSchemaMapper.update(null, Wrappers.<CollectFormSchema>lambdaUpdate()
|
||||
.eq(CollectFormSchema::getTaskId, taskId)
|
||||
.set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
|
||||
CollectFormSchema entity = new CollectFormSchema();
|
||||
entity.setTaskId(taskId);
|
||||
entity.setVersion(nextVersion);
|
||||
entity.setSchemaJson(schemaJson);
|
||||
entity.setIsCurrent(CollectFormSchema.CURRENT);
|
||||
formSchemaMapper.insert(entity);
|
||||
log.info("任务 {}({})表单按已存定义保存为新版本 v{}(套用模板/克隆)",
|
||||
taskId, task.getTaskCode(), nextVersion);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
|
||||
CollectTask task = requireTask(taskId);
|
||||
|
||||
+2
@@ -79,6 +79,8 @@ public class OperationLogService {
|
||||
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
||||
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
|
||||
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
||||
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
||||
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
|
||||
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
|
||||
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
|
||||
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
|
||||
|
||||
+4
-1
@@ -107,7 +107,10 @@ public class TaskController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
|
||||
@Operation(summary = "删除任务",
|
||||
description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
|
||||
+ "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
|
||||
+ "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
|
||||
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||
taskService.delete(id);
|
||||
return ApiResult.ok();
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.tianbaotong.fill.module.task.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 删除**已归档任务**时的级联清理({@code TaskService.delete},2026-09-30 客户确认,
|
||||
* 见《03-接口设计》§17.2 D-9 与《08》F-14 —— 此前"已发布的任务删不掉",
|
||||
* 演示/测试任务只能改名挪开,库会一直堆)。
|
||||
*
|
||||
* <h3>为什么集中在这么一个文件里</h3>
|
||||
*
|
||||
* <p>级联删除是"一次动作动十四张表"的地方,散在各 Mapper 里会让人
|
||||
* 只看到其中一截而以为删干净了。集中放置 + 每个方法一行,
|
||||
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
|
||||
*
|
||||
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
|
||||
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
|
||||
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
|
||||
*
|
||||
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
|
||||
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
|
||||
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
|
||||
* ({@code maxSeqByCodePrefix} 靠"含软删行的最大序号"保证编码不复用 ——
|
||||
* 编码同时是文件目录名)。删除动作的<b>留痕在 {@code sys_operation_log}</b>
|
||||
* ({@code DELETE_TASK},记了谁删的、删了多少行多少文件)。
|
||||
*
|
||||
* <p>⚠️ 全部 SQL 走 MyBatis-Plus 租户拦截器(自动补 {@code tenant_id},这些表都有该列),
|
||||
* 删除动作在管理员的租户上下文里执行。
|
||||
*/
|
||||
public interface TaskCascadeMapper {
|
||||
|
||||
// ── 文件路径(先取再删行:物理删除要用,删完行就查不到了)──────
|
||||
|
||||
/** 材料文件路径(std 与 raw 用 | 分隔,可能为空串)。 */
|
||||
@Select("""
|
||||
SELECT coalesce(std_path, '') || '|' || coalesce(raw_path, '')
|
||||
FROM collect_file WHERE task_id = #{taskId}
|
||||
""")
|
||||
List<String> filePaths(@Param("taskId") Long taskId);
|
||||
|
||||
/** 导出产物路径。 */
|
||||
@Select("""
|
||||
SELECT file_path FROM collect_export_file
|
||||
WHERE task_id = #{taskId} AND file_path IS NOT NULL
|
||||
""")
|
||||
List<String> exportPaths(@Param("taskId") Long taskId);
|
||||
|
||||
// ── 物理删(这 5 张表没有 deleted 列)────────────────
|
||||
|
||||
@Delete("DELETE FROM collect_validation_issue WHERE task_id = #{taskId}")
|
||||
int deleteValidationIssues(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_audit_record WHERE task_id = #{taskId}")
|
||||
int deleteAuditRecords(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_notify_record WHERE task_id = #{taskId}")
|
||||
int deleteNotifyRecords(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_open_push_log WHERE task_id = #{taskId}")
|
||||
int deleteOpenPushLogs(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_file WHERE task_id = #{taskId}")
|
||||
int deleteFiles(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_export_file WHERE task_id = #{taskId}")
|
||||
int deleteExportFiles(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_export_task WHERE task_id = #{taskId}")
|
||||
int deleteExportTasks(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
|
||||
int deleteSubmissions(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
|
||||
int deleteJoinRequests(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
|
||||
int deleteRoster(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_form_schema WHERE task_id = #{taskId}")
|
||||
int deleteFormSchemas(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_material_def WHERE task_id = #{taskId}")
|
||||
int deleteMaterialDefs(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_notify_template WHERE task_id = #{taskId}")
|
||||
int deleteNotifyTemplates(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_task_org WHERE task_id = #{taskId}")
|
||||
int deleteTaskOrgs(@Param("taskId") Long taskId);
|
||||
}
|
||||
+6
-1
@@ -58,6 +58,7 @@ public class RuleTemplateService {
|
||||
private final TaskMapper taskMapper;
|
||||
private final MaterialDefMapper materialDefMapper;
|
||||
private final MaterialDefService materialDefService;
|
||||
private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
|
||||
|
||||
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
|
||||
public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
|
||||
@@ -126,8 +127,12 @@ public class RuleTemplateService {
|
||||
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
|
||||
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
|
||||
} else {
|
||||
// 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
|
||||
// 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
|
||||
// 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
|
||||
assertNotPublished(target, "表单定义");
|
||||
r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)");
|
||||
formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
|
||||
r.getApplied().add("FORM");
|
||||
}
|
||||
}
|
||||
if (want.contains("MATERIALS")) {
|
||||
|
||||
+10
@@ -258,6 +258,16 @@ public class TaskCloneService {
|
||||
row.setCertType(t.getCertType());
|
||||
row.setMaterials(arr.toString());
|
||||
row.setNamingTemplate(t.getNamingTemplate());
|
||||
// 表单定义:来源任务存过版本化表单就一并带上(2026-09-30 与「FORM 套用」一起补上 ——
|
||||
// 此前存模板不存表单、套模板又用不上表单,两头各缺一半,见 F-57/F-64)。
|
||||
// 没存过 = 用系统默认 → 存 null(套用时按「用默认,不覆盖」跳过)
|
||||
var currentForm = formSchemaMapper.selectOne(Wrappers
|
||||
.<cn.tianbaotong.fill.module.form.entity.CollectFormSchema>lambdaQuery()
|
||||
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, taskId)
|
||||
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getIsCurrent,
|
||||
cn.tianbaotong.fill.module.form.entity.CollectFormSchema.CURRENT)
|
||||
.last("limit 1"));
|
||||
row.setFormSchema(currentForm == null ? null : currentForm.getSchemaJson());
|
||||
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
|
||||
row.setUseCount(0);
|
||||
ruleTemplateMapper.insert(row);
|
||||
|
||||
+94
-5
@@ -73,6 +73,8 @@ public class TaskService {
|
||||
private final TaskReadinessMapper readinessMapper;
|
||||
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
|
||||
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
|
||||
private final cn.tianbaotong.fill.module.task.mapper.TaskCascadeMapper cascadeMapper;
|
||||
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
|
||||
@@ -369,17 +371,104 @@ public class TaskService {
|
||||
taskMapper.updateById(patch);
|
||||
}
|
||||
|
||||
// ── 删除(仅草稿)────────────────────────────
|
||||
// ── 删除(草稿直接删;已归档的级联删,2026-09-30 客户确认)──────
|
||||
|
||||
/**
|
||||
* 删除任务(《03-接口设计》§17.2 D-9;原口径"已发布即不可删"会让任务列表只增不减,
|
||||
* 演示/测试数据只能改名挪开 —— 见《08》F-14)。
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>草稿</b>:直接删(没有业务数据,与原来一样);</li>
|
||||
* <li><b>已归档(status=3)</b>:管理员显式删除 —— <b>子表级联物理删</b>
|
||||
* (名册/填报单/材料/审核记录等 14 张表,清掉的是大头:证件号密文、材料、审核记录),
|
||||
* 但<b>任务行本身软删</b> —— 它必须留下来占住 {@code task_code}:
|
||||
* {@code TaskMapper.maxSeqByCodePrefix} 靠"含软删行的最大序号"保证
|
||||
* <b>编码用过的永不复用</b>(编码同时是文件目录名,复用会把两批任务的文件
|
||||
* 混进同一个目录)。E2E 抓到过这个坑:物理删任务行后新任务拿到重号。
|
||||
* 物理文件尽力删(删不掉只记日志不阻断),<b>全程留痕</b>
|
||||
* (sys_operation_log 记 DELETE_TASK + 行数/文件数);</li>
|
||||
* <li><b>收集中/已截止</b>:仍然拒绝 —— 归档是"最后的后悔药",
|
||||
* 直接删"还活着的数据"这个口子不开。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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,
|
||||
"只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
|
||||
int st = exist.getStatus() == null ? -1 : exist.getStatus();
|
||||
|
||||
if (st == CollectTask.STATUS_DRAFT) {
|
||||
taskMapper.deleteById(id);
|
||||
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
|
||||
return;
|
||||
}
|
||||
if (st != CollectTask.STATUS_ARCHIVED) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"只有草稿或已归档的任务可以删除;收集中/已截止的任务请先「归档」再删"
|
||||
+ "(删除会连带清掉名册、填报单、材料与文件,不可恢复)");
|
||||
}
|
||||
|
||||
// ★ 先取文件路径再删行 —— 行删了就查不到了(顺序反了 = 文件永远留在磁盘上)
|
||||
List<String> filePaths = cascadeMapper.filePaths(id);
|
||||
List<String> exportPaths = cascadeMapper.exportPaths(id);
|
||||
|
||||
int rows = cascadeMapper.deleteValidationIssues(id)
|
||||
+ cascadeMapper.deleteAuditRecords(id)
|
||||
+ cascadeMapper.deleteNotifyRecords(id)
|
||||
+ cascadeMapper.deleteOpenPushLogs(id)
|
||||
+ cascadeMapper.deleteFiles(id)
|
||||
+ cascadeMapper.deleteExportFiles(id)
|
||||
+ cascadeMapper.deleteExportTasks(id)
|
||||
+ cascadeMapper.deleteSubmissions(id)
|
||||
+ cascadeMapper.deleteJoinRequests(id)
|
||||
+ cascadeMapper.deleteRoster(id)
|
||||
+ cascadeMapper.deleteFormSchemas(id)
|
||||
+ cascadeMapper.deleteMaterialDefs(id)
|
||||
+ cascadeMapper.deleteNotifyTemplates(id)
|
||||
+ cascadeMapper.deleteTaskOrgs(id);
|
||||
// ★ 任务行**软删**(不是物理删):唯一约束 uk_task_tenant_code 不看 deleted,
|
||||
// 软删行继续占住编码 → maxSeqByCodePrefix 的"编码不复用"不变量保持成立。
|
||||
// 物理删它的话,下一个同批次任务会拿到重号(e2e-m1-task 的断言抓到过)。
|
||||
taskMapper.deleteById(id);
|
||||
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
|
||||
|
||||
int removed = deleteFilesQuietly(filePaths, exportPaths);
|
||||
|
||||
operationLog.record(
|
||||
cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
|
||||
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_DELETE_TASK,
|
||||
"TASK", id,
|
||||
"任务「" + exist.getTaskName() + "」(" + exist.getTaskCode() + ")已归档后删除",
|
||||
"级联删除 " + rows + " 行;物理删除文件 " + removed + " 个");
|
||||
log.info("删除已归档任务 {}(id={}):级联 {} 行,文件 {} 个,经办人={}",
|
||||
exist.getTaskCode(), id, rows, removed, UserContext.uid());
|
||||
}
|
||||
|
||||
/** 尽力删除物理文件(本地磁盘或对象存储)。失败只记日志 —— 行已删,别让一个文件拖垮整个删除。 */
|
||||
private int deleteFilesQuietly(List<String> filePaths, List<String> exportPaths) {
|
||||
int ok = 0;
|
||||
for (String pair : filePaths) {
|
||||
// 形如 "std/1/xx.jpg|raw/1/xx.jpg"(两段都可能是空串)
|
||||
for (String p : pair.split("\\|")) {
|
||||
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
|
||||
ok++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String p : exportPaths) {
|
||||
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
|
||||
ok++;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private boolean deleteOneQuietly(String path) {
|
||||
try {
|
||||
storageService.delete(path);
|
||||
return true;
|
||||
} catch (Exception e) { // noqa: BLE001
|
||||
log.warn("删除任务时清理文件失败(不影响删除本身):{} → {}", path, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user