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; + } +}