diff --git a/sql/20261110_backfill_college_id.sql b/sql/20261110_backfill_college_id.sql
index 8ba5c48..5f50677 100644
--- a/sql/20261110_backfill_college_id.sql
+++ b/sql/20261110_backfill_college_id.sql
@@ -14,6 +14,26 @@
-- 3) 校验与清单(只读) —— 回填率、空值清单、未匹配文本一览,抄进上线记录
-- 3) ⚠ 只执行一次,不接定时任务。
--
+-- ============================================================================
+-- 表名 → 学院文本列 对照表(本脚本唯一的一处机械清单,B 段兜底按它逐段复制)
+--
+-- ┌──────────────────────┬──────────────────────────────────┬──────────┐
+-- │ 表名 │ 学院文本列(B 段兜底用) │ 列注释 │
+-- ├──────────────────────┼──────────────────────────────────┼──────────┤
+-- │ gxmu_sjqn │ unit_name │ 所在单位 │
+-- │ gxmu_tzbcy_form │ school_name │ 学校名称 │
+-- │ gxmu_qmgc_form │ school_info │ 学校、院系、年级、专业 │
+-- │ gxmu_wxxzx_project │ college_grade_class │ 学院、年级、班别 │
+-- └──────────────────────┴──────────────────────────────────┴──────────┘
+--
+-- 新增第 5 张带 college_id 的表时,按上表同步 4 处:
+-- ① sql/20261110_spec58_approval_chain.sql 的加列段 + 注册表种子(has_college = 1)
+-- ② 本脚本 A 段(学生档案主路径:user_id → gxmu_student.college_id)
+-- ③ 本脚本 B 段(表单文本兜底:按上表的文本列 LIKE 组织名)
+-- ④ 本脚本 3a / 3b / 3c / 3d(回填率、空值清单、未匹配文本、逐表计数)
+-- 漏改的后果不是报错,而是新表回填率恒为 0、审批人按学院解析静默失败。
+-- ============================================================================
+--
-- 可重复执行:所有 UPDATE 都带「只填空、不覆盖」条件(college_id IS NULL),且不带会话状态
-- (不用临时表、不用视图),任何一句都可以单独重跑。重跑不会改掉人工修对的值。
--
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
index 5ef157b..5b7cf22 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
@@ -1,33 +1,26 @@
package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.web.BaseController;
-import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
-import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam;
-import com.gxwebsoft.common.core.annotation.OperationLog;
-import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.LinkedHashSet;
+import java.util.Collections;
import java.util.List;
-import java.util.Objects;
-import java.util.Set;
/**
* 审核流控制器
*
+ *
层级连续性(从 1 连续升序)是审批链的业务规则,校验落在
+ * {@link ReviewFlowService#validateLevelsOnSave} 等方法的实现里;controller 只负责在写操作前调用一次。
+ *
* @author LX
* @since 2026-03-18 10:37:18
*/
@@ -69,12 +62,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("添加审核流")
@PostMapping()
public ApiResult> save(@RequestBody ReviewFlow reviewFlow) {
- validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow));
- // 记录当前登录用户id
-// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
-// if (checkByName != null) {
-// return fail("该名称已存在");
-// }
+ reviewFlowService.validateLevelsOnSave(reviewFlow == null ? null : Collections.singletonList(reviewFlow));
if (reviewFlowService.save(reviewFlow)) {
return success("添加成功");
}
@@ -84,11 +72,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("修改审核流")
@PutMapping()
public ApiResult> update(@RequestBody ReviewFlow reviewFlow) {
- validateUpdate(reviewFlow);
-// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
-// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
-// return fail("该名称已存在");
-// }
+ reviewFlowService.validateLevelsOnUpdate(reviewFlow);
if (reviewFlowService.updateById(reviewFlow)) {
return success("修改成功");
}
@@ -98,6 +82,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("删除审核流")
@DeleteMapping("/{id}")
public ApiResult> remove(@PathVariable("id") Integer id) {
+ reviewFlowService.validateLevelsOnRemove(Collections.singletonList(id));
if (reviewFlowService.removeById(id)) {
return success("删除成功");
}
@@ -107,7 +92,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量添加审核流")
@PostMapping("/batch")
public ApiResult> saveBatch(@RequestBody List list) {
- validateLevels(list);
+ reviewFlowService.validateLevelsOnSave(list);
if (reviewFlowService.saveBatch(list)) {
return success("添加成功");
}
@@ -117,145 +102,17 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量修改审核流")
@PutMapping("/batch")
public ApiResult> updateBatch(@RequestBody BatchParam batchParam) {
- validateBatchUpdate(batchParam);
+ reviewFlowService.validateLevelsOnBatchUpdate(batchParam);
if (batchParam.update(reviewFlowService, "id")) {
return success("修改成功");
}
return fail("修改失败");
}
- /**
- * 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。
- *
- * 管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
- * 否则逐级保存会被误判为缺级。
- */
- private void validateLevels(List incoming) {
- if (incoming == null || incoming.isEmpty()) {
- return;
- }
- List proposed = new ArrayList<>();
- Set titles = new LinkedHashSet<>();
- Set replacedIds = new LinkedHashSet<>();
- for (ReviewFlow flow : incoming) {
- if (flow == null) {
- continue;
- }
- proposed.add(flow);
- if (flow.getId() != null) {
- replacedIds.add(flow.getId());
- }
- if (flow.getTitle() != null) {
- titles.add(flow.getTitle().trim());
- }
- }
- for (String title : titles) {
- List existing = reviewFlowService.listByTitle(title);
- if (existing == null) {
- continue;
- }
- for (ReviewFlow flow : existing) {
- if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) {
- proposed.add(flow);
- }
- }
- }
- ReviewFlowLevelValidator.validate(proposed);
- }
-
- /**
- * 修改单条节点:未传的字段沿用库里原值(MyBatis-Plus 同样忽略 null),再校验受影响链的连续性。
- */
- private void validateUpdate(ReviewFlow reviewFlow) {
- if (reviewFlow == null) {
- return;
- }
- ReviewFlow existing = reviewFlow.getId() == null ? null : reviewFlowService.getById(reviewFlow.getId());
- ReviewFlow merged = merge(existing, reviewFlow);
- validateLevels(java.util.Collections.singletonList(merged));
- if (existing != null && !sameTitle(existing.getTitle(), merged.getTitle())) {
- validateRemaining(existing.getTitle(), java.util.Collections.singletonList(reviewFlow.getId()));
- }
- }
-
- /**
- * 批量修改:BatchParam 只带一个 data,id 集合里每行都按同样字段更新。
- */
- private void validateBatchUpdate(BatchParam batchParam) {
- if (batchParam == null || batchParam.getData() == null
- || batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
- return;
- }
- List ids = new ArrayList<>();
- for (Serializable id : batchParam.getIds()) {
- if (id instanceof Number) {
- ids.add(((Number) id).intValue());
- }
- }
- if (ids.isEmpty()) {
- return;
- }
- List existingRows = reviewFlowService.listByIds(ids);
- if (existingRows == null || existingRows.isEmpty()) {
- return;
- }
- List mergedRows = new ArrayList<>();
- List oldTitles = new ArrayList<>();
- for (ReviewFlow existing : existingRows) {
- if (existing == null) {
- continue;
- }
- mergedRows.add(merge(existing, batchParam.getData()));
- oldTitles.add(existing.getTitle());
- }
- validateLevels(mergedRows);
- Set newTitles = new LinkedHashSet<>();
- for (ReviewFlow row : mergedRows) {
- newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim());
- }
- for (String oldTitle : oldTitles) {
- if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) {
- validateRemaining(oldTitle, ids);
- }
- }
- }
-
- /**
- * 校验改名/移走后原审批链剩下的节点仍连续。
- */
- private void validateRemaining(String title, Collection excludedIds) {
- List remaining = new ArrayList<>();
- List existing = title == null ? null : reviewFlowService.listByTitle(title);
- if (existing != null) {
- for (ReviewFlow flow : existing) {
- if (flow != null && !excludedIds.contains(flow.getId())) {
- remaining.add(flow);
- }
- }
- }
- ReviewFlowLevelValidator.validate(remaining);
- }
-
- private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) {
- if (existing == null) {
- return incoming;
- }
- if (incoming.getTitle() != null) {
- existing.setTitle(incoming.getTitle());
- }
- if (incoming.getLevel() != null) {
- existing.setLevel(incoming.getLevel());
- }
- return existing;
- }
-
- private boolean sameTitle(String left, String right) {
- return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim());
- }
-
@ApiOperation("批量删除审核流")
@DeleteMapping("/batch")
public ApiResult> removeBatch(@RequestBody List ids) {
+ reviewFlowService.validateLevelsOnRemove(ids);
if (reviewFlowService.removeByIds(ids)) {
return success("删除成功");
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
index ad4b30c..8cc5d94 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
@@ -8,6 +8,7 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
/**
* 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。
@@ -69,13 +70,6 @@ public final class ReviewFlowLevelValidator {
}
private static String join(List levels) {
- StringBuilder builder = new StringBuilder();
- for (int index = 0; index < levels.size(); index++) {
- if (index > 0) {
- builder.append(", ");
- }
- builder.append(levels.get(index));
- }
- return builder.toString();
+ return levels.stream().map(String::valueOf).collect(Collectors.joining(", "));
}
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
index 1d7172b..7f3e2bc 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
@@ -1,10 +1,12 @@
package com.gxwebsoft.gxmu.service;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
+import java.util.Collection;
import java.util.List;
/**
@@ -42,4 +44,40 @@ public interface ReviewFlowService extends IService {
ReviewFlow getByTitle(String title);
List listByTitle(String title);
+
+ /**
+ * 保存前校验:保存后这条审批链的层级必须从 1 连续升序(spec #58 行为约定)。
+ *
+ * 管理端是"先删旧节点、再逐级新增",所以单条保存时必须把库里同 title 的已有节点一起看,
+ * 否则逐级保存会被误判为缺级。不合法时抛
+ * {@link com.gxwebsoft.common.core.exception.BusinessException}。
+ *
+ * @param incoming 本次提交的节点(可含多条审批链)
+ */
+ void validateLevelsOnSave(List incoming);
+
+ /**
+ * 修改单条节点前校验:未传的字段沿用库里原值,再校验受影响链的连续性;
+ * 若改了 title,还要校验原链剩下的节点仍连续。
+ *
+ * @param incoming 提交的节点
+ */
+ void validateLevelsOnUpdate(ReviewFlow incoming);
+
+ /**
+ * 批量修改前校验:id 集合里每行都按同样字段更新,合并后校验连续性,
+ * 并校验改名后原链剩余部分仍连续。
+ *
+ * @param batchParam 批量参数
+ */
+ void validateLevelsOnBatchUpdate(BatchParam batchParam);
+
+ /**
+ * 删除前校验:删除后受影响审批链剩下的节点必须仍从 1 连续升序。
+ *
+ * 删除前就已经不连续的链不做校验(否则存量脏数据会变成"永远删不掉")。
+ *
+ * @param removedIds 待删除的节点 id
+ */
+ void validateLevelsOnRemove(Collection removedIds);
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
index 4d6c733..9cee673 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
@@ -99,17 +99,9 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewFlowConfig item : list) {
item.setFlowName(flowNameMap.get(item.getFlowId()));
- item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
+ item.setModuleName(ModuleRegistryService.titleOf(moduleTitleMap, item.getModule()));
item.setOrganizationName(organizationNameMap.get(item.getOrganizationId()));
}
}
- private String moduleTitleOf(Map moduleTitleMap, String module) {
- if (module == null) {
- return null;
- }
- String title = moduleTitleMap.get(module);
- return title == null || title.isEmpty() ? module : title;
- }
-
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
index 8c1fe90..939c4c9 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
@@ -2,10 +2,12 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper;
+import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
@@ -14,7 +16,13 @@ import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Objects;
+import java.util.Set;
/**
* 审核流Service实现
@@ -91,4 +99,180 @@ public class ReviewFlowServiceImpl extends ServiceImpl incoming) {
+ if (incoming == null || incoming.isEmpty()) {
+ return;
+ }
+ List proposed = new ArrayList<>();
+ Set titles = new LinkedHashSet<>();
+ Set replacedIds = new LinkedHashSet<>();
+ for (ReviewFlow flow : incoming) {
+ if (flow == null) {
+ continue;
+ }
+ proposed.add(flow);
+ if (flow.getId() != null) {
+ replacedIds.add(flow.getId());
+ }
+ if (flow.getTitle() != null) {
+ titles.add(flow.getTitle().trim());
+ }
+ }
+ for (String title : titles) {
+ List existing = listByTitle(title);
+ if (existing == null) {
+ continue;
+ }
+ for (ReviewFlow flow : existing) {
+ if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) {
+ proposed.add(flow);
+ }
+ }
+ }
+ ReviewFlowLevelValidator.validate(proposed);
+ }
+
+ @Override
+ public void validateLevelsOnUpdate(ReviewFlow incoming) {
+ if (incoming == null) {
+ return;
+ }
+ ReviewFlow existing = incoming.getId() == null ? null : getById(incoming.getId());
+ // merge 会就地改 existing,旧标题必须先存下来,否则「改名后原链剩余校验」永远不触发
+ String oldTitle = existing == null ? null : existing.getTitle();
+ ReviewFlow merged = merge(existing, incoming);
+ validateLevelsOnSave(java.util.Collections.singletonList(merged));
+ if (existing != null && !sameTitle(oldTitle, merged.getTitle())) {
+ validateRemaining(oldTitle, java.util.Collections.singletonList(incoming.getId()));
+ }
+ }
+
+ @Override
+ public void validateLevelsOnBatchUpdate(BatchParam batchParam) {
+ if (batchParam == null || batchParam.getData() == null
+ || batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
+ return;
+ }
+ List ids = new ArrayList<>();
+ for (Serializable id : batchParam.getIds()) {
+ if (id instanceof Number) {
+ ids.add(((Number) id).intValue());
+ }
+ }
+ if (ids.isEmpty()) {
+ return;
+ }
+ List existingRows = listByIds(ids);
+ if (existingRows == null || existingRows.isEmpty()) {
+ return;
+ }
+ List mergedRows = new ArrayList<>();
+ List oldTitles = new ArrayList<>();
+ for (ReviewFlow existing : existingRows) {
+ if (existing == null) {
+ continue;
+ }
+ // 同上:merge 就地改 existing,旧标题要先取
+ oldTitles.add(existing.getTitle());
+ mergedRows.add(merge(existing, batchParam.getData()));
+ }
+ validateLevelsOnSave(mergedRows);
+ Set newTitles = new LinkedHashSet<>();
+ for (ReviewFlow row : mergedRows) {
+ newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim());
+ }
+ for (String oldTitle : oldTitles) {
+ if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) {
+ validateRemaining(oldTitle, ids);
+ }
+ }
+ }
+
+ @Override
+ public void validateLevelsOnRemove(Collection removedIds) {
+ if (removedIds == null || removedIds.isEmpty()) {
+ return;
+ }
+ List ids = new ArrayList<>();
+ for (Integer id : removedIds) {
+ if (id != null) {
+ ids.add(id);
+ }
+ }
+ if (ids.isEmpty()) {
+ return;
+ }
+ List removed = listByIds(ids);
+ if (removed == null || removed.isEmpty()) {
+ return;
+ }
+ Set titles = new LinkedHashSet<>();
+ for (ReviewFlow flow : removed) {
+ if (flow != null && flow.getTitle() != null) {
+ titles.add(flow.getTitle().trim());
+ }
+ }
+ for (String title : titles) {
+ List before = listByTitle(title);
+ if (before == null || before.isEmpty()) {
+ continue;
+ }
+ if (!isContinuous(before)) {
+ // 删除前就已经不连续:不把存量脏数据变成"永远删不掉"
+ continue;
+ }
+ validateRemaining(title, ids);
+ }
+ }
+
+ /**
+ * 校验改名/移走后原审批链剩下的节点仍连续。
+ */
+ private void validateRemaining(String title, Collection excludedIds) {
+ List remaining = new ArrayList<>();
+ List existing = title == null ? null : listByTitle(title);
+ if (existing != null) {
+ for (ReviewFlow flow : existing) {
+ if (flow != null && !excludedIds.contains(flow.getId())) {
+ remaining.add(flow);
+ }
+ }
+ }
+ ReviewFlowLevelValidator.validate(remaining);
+ }
+
+ /**
+ * 这组节点本身是否已经连续(用于决定要不要在删除前做校验)。
+ */
+ private boolean isContinuous(List flows) {
+ try {
+ ReviewFlowLevelValidator.validate(flows);
+ return true;
+ } catch (com.gxwebsoft.common.core.exception.BusinessException e) {
+ return false;
+ }
+ }
+
+ private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) {
+ if (existing == null) {
+ return incoming;
+ }
+ if (incoming.getTitle() != null) {
+ existing.setTitle(incoming.getTitle());
+ }
+ if (incoming.getLevel() != null) {
+ existing.setLevel(incoming.getLevel());
+ }
+ return existing;
+ }
+
+ private boolean sameTitle(String left, String right) {
+ return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim());
+ }
+
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java
index 9d034c7..839744f 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java
@@ -86,18 +86,10 @@ public class ReviewListServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewList item : list) {
- item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
+ item.setModuleName(ModuleRegistryService.titleOf(moduleTitleMap, item.getModule()));
}
}
- private String moduleTitleOf(Map moduleTitleMap, String module) {
- if (module == null) {
- return null;
- }
- String title = moduleTitleMap.get(module);
- return title == null || title.isEmpty() ? module : title;
- }
-
@Override
public List listByModuleNPK(String module, Integer pk){
return list(
diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
index 49888ce..15215bb 100644
--- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
+++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
@@ -1,6 +1,7 @@
package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import org.junit.jupiter.api.Test;
@@ -16,15 +17,22 @@ import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * 审批链保存入口的层级校验接线(spec #58)。
+ * 审批链写入口的层级校验接线(spec #58)。
*
- * 管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断,
- * 否则逐级保存会被误判为缺级;这里钉住这个接线。
+ *
规则实现已下沉到 {@link ReviewFlowService}(合并库内同 title 节点、校验连续性、
+ * 校验改名后原链剩余;见 ReviewFlowServiceImplLevelTest),controller 只负责
+ * 「写之前调用一次」:保存、修改、批量、删除四条路径都要接上,且校验抛错时一个字节都不落库。
+ *
+ * @author Codex
+ * @since 2026-11-03
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -46,32 +54,76 @@ class ReviewFlowControllerLevelTest {
return flow;
}
+ /** 逐级保存:controller 把校验交给 service,校验通过才落库。 */
@Test
- void savingNextLevelAfterPreviousOneIsAccepted() {
- when(reviewFlowService.listByTitle(TITLE))
- .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+ void saveDelegatesLevelValidationThenWrites() {
when(reviewFlowService.save(any())).thenReturn(true);
controller.save(flow(null, TITLE, 2));
+ verify(reviewFlowService).validateLevelsOnSave(anyList());
verify(reviewFlowService).save(any());
}
+ /** 校验拒绝(缺级):异常透出,不落库。 */
@Test
- void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() {
- when(reviewFlowService.listByTitle(TITLE))
- .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+ void saveDoesNotWriteWhenServiceRejectsGap() {
+ doThrow(new BusinessException("审批链「十佳青年」的层级必须从 1 连续升序"))
+ .when(reviewFlowService).validateLevelsOnSave(any());
assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3)));
verify(reviewFlowService, never()).save(any());
}
+ /** 批量保存同样先校验。 */
@Test
- void batchSaveRejectsGap() {
- when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList());
+ void batchSaveDelegatesLevelValidation() {
+ when(reviewFlowService.saveBatch(any())).thenReturn(true);
- assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList(
- flow(null, TITLE, 1), flow(null, TITLE, 3))));
- verify(reviewFlowService, never()).saveBatch(any());
+ controller.saveBatch(Arrays.asList(flow(null, TITLE, 1), flow(null, TITLE, 2)));
+
+ verify(reviewFlowService).validateLevelsOnSave(anyList());
+ verify(reviewFlowService).saveBatch(any());
+ }
+
+ /** 单条修改:合并库内原值后校验,再落库。 */
+ @Test
+ void updateDelegatesLevelValidation() {
+ when(reviewFlowService.updateById(any())).thenReturn(true);
+
+ controller.update(flow(1, TITLE, 2));
+
+ verify(reviewFlowService).validateLevelsOnUpdate(any());
+ verify(reviewFlowService).updateById(any());
+ }
+
+ /** 批量修改同样先校验。 */
+ @Test
+ void batchUpdateDelegatesLevelValidation() {
+ controller.updateBatch(new BatchParam<>());
+
+ verify(reviewFlowService).validateLevelsOnBatchUpdate(any());
+ }
+
+ /** 删除:删之前校验剩余链仍连续。 */
+ @Test
+ void removeDelegatesRemainingChainValidation() {
+ when(reviewFlowService.removeById(5)).thenReturn(true);
+
+ controller.remove(5);
+
+ verify(reviewFlowService).validateLevelsOnRemove(eq(Collections.singletonList(5)));
+ verify(reviewFlowService).removeById(5);
+ }
+
+ /** 批量删除同样先校验。 */
+ @Test
+ void removeBatchDelegatesRemainingChainValidation() {
+ when(reviewFlowService.removeByIds(anyList())).thenReturn(true);
+
+ controller.removeBatch(Arrays.asList(5, 6));
+
+ verify(reviewFlowService).validateLevelsOnRemove(eq(Arrays.asList(5, 6)));
+ verify(reviewFlowService).removeByIds(anyList());
}
}
diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java
new file mode 100644
index 0000000..833eb25
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java
@@ -0,0 +1,118 @@
+package com.gxwebsoft.gxmu.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.gxmu.entity.ReviewFlow;
+import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 审批链层级校验下沉到 Service 之后的接线(spec #58「层级从 1 连续升序」)。
+ *
+ *
规则实现落在 {@link ReviewFlowServiceImpl},controller 只调用;这里用 mock 掉 Mapper
+ * 的方式验证「合并库内同 title 节点 → 校验连续性 → 校验改名后原链剩余」这条链路,
+ * 以及删除后剩余链的连续性。不启 Spring、不连库。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ReviewFlowServiceImplLevelTest {
+
+ private static final String TITLE = "十佳青年";
+
+ @Mock
+ private ReviewFlowMapper reviewFlowMapper;
+
+ @InjectMocks
+ private ReviewFlowServiceImpl reviewFlowService;
+
+ /** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, ReviewFlow.class);
+ }
+
+ /** 逐级保存:库里已有第 1 级时,新增第 2 级应被接受。 */
+ @Test
+ void savingNextLevelAfterExistingOneIsAccepted() {
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+
+ assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnSave(
+ Collections.singletonList(flow(null, TITLE, 2))));
+ }
+
+ /** 缺级:库里只有第 1 级时直接保存第 3 级要被拒绝。 */
+ @Test
+ void savingLevelThreeWithoutTwoIsRejected() {
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+
+ assertThrows(BusinessException.class, () -> reviewFlowService.validateLevelsOnSave(
+ Collections.singletonList(flow(null, TITLE, 3))));
+ }
+
+ /** 改名把第 2 级挪走后,原链剩下 1、3 不连续,必须拒绝。 */
+ @Test
+ void renamingMiddleNodeLeavesOldChainGapAndIsRejected() {
+ when(reviewFlowMapper.selectById(2)).thenReturn(flow(2, TITLE, 2));
+ when(reviewFlowMapper.selectList(any()))
+ // 第一次:新 title 下暂无节点;第二次:原 title 下 1、2、3
+ .thenReturn(Collections.emptyList())
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertThrows(BusinessException.class,
+ () -> reviewFlowService.validateLevelsOnUpdate(flow(2, "新链", 1)));
+ }
+
+ /** 删除中间一级会留下 1、3 的缺口,拒绝删除。 */
+ @Test
+ void removingMiddleLevelIsRejected() {
+ when(reviewFlowMapper.selectBatchIds(any()))
+ .thenReturn(Collections.singletonList(flow(2, TITLE, 2)));
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertThrows(BusinessException.class,
+ () -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(2)));
+ }
+
+ /** 删除最后一级不产生缺口,允许。 */
+ @Test
+ void removingLastLevelIsAccepted() {
+ when(reviewFlowMapper.selectBatchIds(any()))
+ .thenReturn(Collections.singletonList(flow(3, TITLE, 3)));
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(3)));
+ }
+
+ private ReviewFlow flow(Integer id, String title, Integer level) {
+ ReviewFlow flow = new ReviewFlow();
+ flow.setId(id);
+ flow.setTitle(title);
+ flow.setLevel(level);
+ return flow;
+ }
+}