From 70213ab7838c2fda39d8b661ba37145659a84316 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 3 Nov 2026 23:14:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(gxmu):=20=E5=AE=A1=E6=89=B9=E9=93=BE?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E6=A0=A1=E9=AA=8C=E4=B8=8B=E6=B2=89=E5=88=B0?= =?UTF-8?q?=20Service=EF=BC=8C=E5=88=A0=E9=99=A4=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B9=9F=E6=A0=A1=E9=AA=8C=E5=89=A9=E4=BD=99=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReviewFlowController 里约 115 行的 validateLevels / validateUpdate / validateBatchUpdate / validateRemaining / merge 下沉为 ReviewFlowService.validateLevelsOnSave / OnUpdate / OnBatchUpdate / OnRemove, controller 只在写操作前调用一次 - remove / removeBatch 新增删除后剩余链连续性校验;删除前就已不连续的链跳过校验, 避免存量脏数据变成「永远删不掉」 - 顺手修掉一个真缺陷:merge 会就地改 existing,导致「改名后原链剩余校验」永远不触发 (批量路径同样先取旧标题再合并) - 模块标题回退三份收敛成 ModuleRegistryService.titleOf(Map, module) 一处, ReviewFlowConfigServiceImpl / ReviewListServiceImpl 直接调用 - ReviewFlowLevelValidator 的手写 join 换成 Collectors.joining(", ") - 回填脚本头部补「表名 → 学院文本列」对照表,注明新增表按此表同步 4 处 refs #58 --- sql/20261110_backfill_college_id.sql | 20 ++ .../gxmu/controller/ReviewFlowController.java | 163 +--------------- .../service/ReviewFlowLevelValidator.java | 10 +- .../gxmu/service/ReviewFlowService.java | 38 ++++ .../impl/ReviewFlowConfigServiceImpl.java | 10 +- .../service/impl/ReviewFlowServiceImpl.java | 184 ++++++++++++++++++ .../service/impl/ReviewListServiceImpl.java | 10 +- .../ReviewFlowControllerLevelTest.java | 80 ++++++-- .../impl/ReviewFlowServiceImplLevelTest.java | 118 +++++++++++ 9 files changed, 440 insertions(+), 193 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java 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 管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
- * 否则逐级保存会被误判为缺级。
- */
- private void validateLevels(List 管理端是"先删旧节点、再逐级新增",所以单条保存时必须把库里同 title 的已有节点一起看,
+ * 否则逐级保存会被误判为缺级。不合法时抛
+ * {@link com.gxwebsoft.common.core.exception.BusinessException}。
+ *
+ * @param incoming 本次提交的节点(可含多条审批链)
+ */
+ void validateLevelsOnSave(List 删除前就已经不连续的链不做校验(否则存量脏数据会变成"永远删不掉")。
+ *
+ * @param removedIds 待删除的节点 id
+ */
+ void validateLevelsOnRemove(Collection 管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 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;
+ }
+}