From 43f0119b2510f92ad4d046934e46f3309e97b28a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 3 Nov 2026 23:14:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(gxmu):=20=E5=AE=A1=E6=89=B9=E4=BA=BA?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=A6=86=E7=9B=96=E7=AC=AC=202..N=20?= =?UTF-8?q?=E7=BA=A7=EF=BC=8C=E5=B7=B2=E5=A4=84=E7=90=86=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=86=8D=E6=8F=90=E4=BA=A4=E7=9B=B4=E6=8E=A5=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReviewFlowStarterService 新增 advanceReview(module, pk, currentSortNumber): 通过后推进下一级也走 ReviewerResolver,不再 setUserId(nextOne.getReviewUserId()); 解析为空抛可读 BusinessException 且不写节点,最后一级则不生成节点 - ReviewListController 的推进改为调用 advanceReview;ManuscriptController 的首级 改为 startReview("cms_manuscript", id),cms 稿件不再复制 review_user_id - 推进前校验当前节点 status == 0(ReviewStatus.PENDING),已处理节点再 PUT 直接拒绝, 避免重复通过造出第二组活跃节点;批量审批同样逐行校验 - 新增 ReviewStatus 常量(gxmu_review_list.status:0待审 1通过 2驳回), 把 controller / starter / DeclareGuard / TzbcyFormServiceImpl 里的裸 1、2 换成常量 - 解析失败的兜底消息补上学院与层级,与 ReviewerResolver 口径一致 - 审批动作注释里的「撤销」改成实际语义(通过 / 驳回) refs #58 --- .../cms/controller/ManuscriptController.java | 32 +---- .../gxwebsoft/gxmu/constant/ReviewStatus.java | 26 ++++ .../gxmu/controller/ReviewListController.java | 120 ++++++++++------ .../service/ReviewFlowStarterService.java | 12 ++ .../service/impl/DeclareGuardServiceImpl.java | 7 +- .../impl/ReviewFlowStarterServiceImpl.java | 132 +++++++++++++----- .../service/impl/TzbcyFormServiceImpl.java | 3 +- .../ReviewListControllerAuthTest.java | 32 ++++- .../ReviewFlowStarterServiceImplTest.java | 90 +++++++++++- 9 files changed, 332 insertions(+), 122 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java diff --git a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java index 8a0a71f..fccb7b0 100644 --- a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java +++ b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java @@ -10,12 +10,7 @@ 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 com.gxwebsoft.gxmu.entity.ReviewFlow; -import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; -import com.gxwebsoft.gxmu.entity.ReviewList; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; -import com.gxwebsoft.gxmu.service.ReviewListService; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.security.access.prepost.PreAuthorize; @@ -38,11 +33,7 @@ public class ManuscriptController extends BaseController { @Resource private ManuscriptService manuscriptService; @Resource - private ReviewFlowConfigService reviewFlowConfigService; - @Resource - private ReviewFlowService reviewFlowService; - @Resource - private ReviewListService reviewListService; + private ReviewFlowStarterService reviewFlowStarterService; @ApiOperation("分页查询稿件") @GetMapping("/page") @@ -75,23 +66,8 @@ public class ManuscriptController extends BaseController { manuscript.setUserId(loginUser.getUserId()); } if (manuscriptService.save(manuscript)) { - // 查找该模块的审核流 - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule("cms_manuscript"); - if (reviewFlowConfig != null) { - ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); - if (reviewFlow != null) { - List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); - if (reviewFlowList != null && !reviewFlowList.isEmpty()) { - ReviewFlow firstOne = reviewFlowList.get(0); - ReviewList reviewList = new ReviewList(); - reviewList.setPk(manuscript.getId()); - reviewList.setModule("cms_manuscript"); - reviewList.setUserId(firstOne.getReviewUserId()); - reviewList.setSortNumber(0); - reviewListService.save(reviewList); - } - } - } + // 首级审批人同样经 ReviewerResolver 解析(与其它模块同一条路径),不复制 review_user_id + reviewFlowStarterService.startReview("cms_manuscript", manuscript.getId()); return success("添加成功"); } return fail("添加失败"); diff --git a/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java new file mode 100644 index 0000000..3612904 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.gxmu.constant; + +/** + * 审核待办(gxmu_review_list.status)的状态取值。 + * + *

这套取值与申报记录的 declare_status(0草稿 1待审核 2通过 3驳回 4作废,见 + * {@link com.gxwebsoft.gxmu.service.impl.DeclareGuardServiceImpl} 的 STATUS_* 常量) + * 是两套语义不同的编码:同为 "2",这里是驳回、那里是通过,因此不要混用字面量。 + * + * @author Codex + * @since 2026-11-11 + */ +public final class ReviewStatus { + + /** 待审核:当前一级的责任人可以审批 */ + public static final int PENDING = 0; + + /** 通过:节点已处理,链条推进到下一级 */ + public static final int APPROVED = 1; + + /** 驳回:节点已处理,申报退回申报人 */ + public static final int REJECTED = 2; + + private ReviewStatus() { + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java index de68ad3..548c99f 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java @@ -3,19 +3,15 @@ package com.gxwebsoft.gxmu.controller; import cn.hutool.core.util.StrUtil; import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.web.BaseController; -import com.gxwebsoft.gxmu.entity.ReviewFlow; -import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; +import com.gxwebsoft.gxmu.constant.ReviewStatus; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.param.ReviewListParam; 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; @@ -23,11 +19,16 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import java.io.Serializable; import java.util.List; /** * 审核列表控制器 * + *

审批动作(通过 / 驳回)的归属校验、状态校验留在这里(HTTP 层的准入判断); + * 通过后「推进到下一级」属于审批链业务规则,交给 {@link ReviewFlowStarterService#advanceReview}, + * 审批人一律经 {@link com.gxwebsoft.gxmu.service.ReviewerResolver} 解析,不再复制 review_user_id。 + * * @author LX * @since 2026-03-18 15:50:51 */ @@ -38,11 +39,9 @@ public class ReviewListController extends BaseController { @Resource private ReviewListService reviewListService; @Resource - private ReviewFlowConfigService reviewFlowConfigService; - @Resource - private ReviewFlowService reviewFlowService; - @Resource private DeclareGuardService declareGuardService; + @Resource + private ReviewFlowStarterService reviewFlowStarterService; @ApiOperation("分页查询审核列表") @GetMapping("/page") @@ -100,8 +99,14 @@ public class ReviewListController extends BaseController { @ApiOperation("修改审核列表") @PutMapping() public ApiResult update(@RequestBody ReviewList reviewList) { - // 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行 - ApiResult denied = denyIfNotAssignee(reviewList.getId()); + // 审批动作(通过 / 驳回)只能由待办的责任人本人执行 + ReviewList existing = reviewList.getId() == null ? null : reviewListService.getById(reviewList.getId()); + ApiResult denied = denyIfNotAssignee(existing); + if (denied != null) { + return denied; + } + // 已处理过的节点再提交直接拒绝:否则会再插一条下一级待办,同一申报出现第二组活跃节点 + denied = denyIfAlreadyHandled(existing); if (denied != null) { return denied; } @@ -110,34 +115,10 @@ public class ReviewListController extends BaseController { return denied; } if (reviewListService.updateById(reviewList)) { - // 通过进入下一个 - if (Integer.valueOf(1).equals(reviewList.getStatus())) { - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule()); - if (reviewFlowConfig != null) { - ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); - if (reviewFlow != null) { - List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); - if (reviewFlowList != null && !reviewFlowList.isEmpty()) { - if (reviewList.getSortNumber() + 1 < reviewFlowList.size()) { - ReviewFlow nextOne = reviewFlowList.get(reviewList.getSortNumber() + 1); - if (nextOne != null) { - ReviewList nextReviewList = new ReviewList(); - nextReviewList.setPk(reviewList.getPk()); - nextReviewList.setModule(reviewList.getModule()); - nextReviewList.setUserId(nextOne.getReviewUserId()); - nextReviewList.setSortNumber(reviewList.getSortNumber() + 1); - reviewListService.save(nextReviewList); - } - } -// switch (reviewList.getModule()) { -// case "cms_manuscript": { -// // 社团指导老师/二级学院团委书记-团委社团管理部/团委办公室-学校团委组织宣传部,拟发布后,再提交给团委副书记-团委书记 -// if () -// } -// } - } - } - } + // 通过则推进到下一级;下一级的审批人由审批链配置解析,不复制本级的 review_user_id + if (Integer.valueOf(ReviewStatus.APPROVED).equals(reviewList.getStatus())) { + reviewFlowStarterService.advanceReview(reviewList.getModule(), reviewList.getPk(), + existing == null ? null : existing.getSortNumber()); } declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk()); return success("修改成功"); @@ -166,11 +147,15 @@ public class ReviewListController extends BaseController { @ApiOperation("批量修改审核列表") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { - // 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库 + // 批量审批逐行校验归属与状态,只要有一行不满足就整体拒绝,避免部分落库 ApiResult denied = denyIfBatchNotAssignee(batchParam); if (denied != null) { return denied; } + denied = denyIfBatchAlreadyHandled(batchParam); + if (denied != null) { + return denied; + } denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData()); if (denied != null) { return denied; @@ -191,7 +176,10 @@ public class ReviewListController extends BaseController { * @return null 表示校验通过,否则返回 403 结果 */ private ApiResult denyIfNotAssignee(Integer id) { - ReviewList existing = id == null ? null : reviewListService.getById(id); + return denyIfNotAssignee(id == null ? null : reviewListService.getById(id)); + } + + private ApiResult denyIfNotAssignee(ReviewList existing) { Integer loginUserId = getLoginUserId(); if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) { return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG); @@ -199,6 +187,25 @@ public class ReviewListController extends BaseController { return null; } + /** + * 当前节点必须仍是「待审核」才允许提交审批动作。 + * + *

重复通过会给同一申报再造一组待办(gxmu_review_list 没有 (module, pk, level) 唯一键兜底), + * 双击或多标签页即可复现,所以在落库前就拒绝。 + * + * @param existing 库里现有的待办行 + * @return null 表示仍是待审核,否则返回可读的失败结果 + */ + private ApiResult denyIfAlreadyHandled(ReviewList existing) { + if (existing == null || existing.getStatus() == null) { + return null; + } + if (existing.getStatus() != ReviewStatus.PENDING) { + return fail("该审批已处理(通过 / 驳回),请勿重复提交"); + } + return null; + } + /** * 批量审批的归属校验:逐行确认都属于当前用户。 * @@ -209,7 +216,7 @@ public class ReviewListController extends BaseController { if (batchParam == null || batchParam.getIds() == null) { return null; } - for (java.io.Serializable id : batchParam.getIds()) { + for (Serializable id : batchParam.getIds()) { ApiResult denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null); if (denied != null) { return denied; @@ -218,6 +225,26 @@ public class ReviewListController extends BaseController { return null; } + /** + * 批量审批的状态校验:任意一行已处理就整体拒绝。 + * + * @param batchParam 批量参数 + * @return null 表示全部仍是待审核,否则返回可读的失败结果 + */ + private ApiResult denyIfBatchAlreadyHandled(BatchParam batchParam) { + if (batchParam == null || batchParam.getIds() == null) { + return null; + } + for (Serializable id : batchParam.getIds()) { + ReviewList existing = id instanceof Number ? reviewListService.getById(((Number) id).intValue()) : null; + ApiResult denied = denyIfAlreadyHandled(existing); + if (denied != null) { + return denied; + } + } + return null; + } + /** * 驳回必须携带意见,否则申报人不知道要改什么。 * @@ -225,7 +252,8 @@ public class ReviewListController extends BaseController { * @return null 表示校验通过,否则返回可读的失败结果 */ private ApiResult denyIfRejectWithoutContent(ReviewList data) { - if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) { + if (data != null && Integer.valueOf(ReviewStatus.REJECTED).equals(data.getStatus()) + && StrUtil.isBlank(data.getContent())) { return fail("驳回必须填写审核意见"); } return null; @@ -236,7 +264,7 @@ public class ReviewListController extends BaseController { if (batchParam == null || batchParam.getIds() == null) { return; } - for (java.io.Serializable id : batchParam.getIds()) { + for (Serializable id : batchParam.getIds()) { ReviewList saved = reviewListService.getById(id); if (saved != null) { declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk()); @@ -283,4 +311,4 @@ public class ReviewListController extends BaseController { } } -} \ No newline at end of file +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java index 9a6b233..e855037 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java @@ -23,4 +23,16 @@ public interface ReviewFlowStarterService { * @param pk 业务主键 */ void restartReviewWhenLastRejected(String module, Integer pk); + + /** + * 当前一级通过后推进到下一级:审批人同样走 {@link ReviewerResolver} 解析,不复制上一级的 review_user_id。 + * + *

解析不出审批人时抛可读的业务异常且一个节点都不写(宁可卡住,也不生成无人认领的待办); + * 已经是最后一级则什么都不做,链条自然结束。 + * + * @param module 模块编码 + * @param pk 业务主键 + * @param currentSortNumber 当前已通过节点的顺序号(首级为 0,与 review_list.sort_number 同口径) + */ + void advanceReview(String module, Integer pk, Integer currentSortNumber); } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java index 046e3c3..e66b888 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java @@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.Declare; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; @@ -160,7 +161,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return; } boolean pending = nodes.stream() - .anyMatch(node -> node.getStatus() != null && node.getStatus() == 0); + .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.PENDING); if (pending) { declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null); return; @@ -181,7 +182,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return; } declareRecordMapper.updateStatus(module, recordId, - last.getStatus() == 2 ? STATUS_REJECTED : STATUS_PASSED, null); + last.getStatus() == ReviewStatus.REJECTED ? STATUS_REJECTED : STATUS_PASSED, null); } @Override @@ -200,7 +201,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return "只有待审核的申报可以撤回"; } boolean approved = reviewListService.listByModuleNPK(module, recordId).stream() - .anyMatch(node -> node.getStatus() != null && node.getStatus() == 1); + .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.APPROVED); if (approved) { return "已有审核通过的记录不能撤回,请联系管理员处理"; } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java index dd44440..071d892 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java @@ -2,10 +2,14 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.service.OrganizationService; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; @@ -16,28 +20,22 @@ import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; /** * 审核流程发起服务实现 * *

审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析, - * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。

+ * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。 + * 首级(发起)、驳回重提、逐级推进三条路径都走同一套解析,第 2..N 级不再复制 review_user_id。

* * @author LX * @since 2026-03-18 */ @Service public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { - /** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */ - private static final Set COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( - "gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project"))); @Resource private ReviewFlowConfigService reviewFlowConfigService; @@ -49,6 +47,10 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { private ReviewerResolver reviewerResolver; @Resource private DeclareRecordMapper declareRecordMapper; + @Resource + private ModuleRegistryService moduleRegistryService; + @Resource + private OrganizationService organizationService; @Override public void startReview(String module, Integer pk) { @@ -59,15 +61,11 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { if (reviewLists != null && !reviewLists.isEmpty()) { return; } - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); - if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { return; } - ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); - if (firstOne == null) { - return; - } - saveFirstReviewNode(module, pk, firstOne); + saveReviewNode(module, pk, flowList.get(0), 0); } /** @@ -90,61 +88,125 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { Comparator.nullsLast(LocalDateTime::compareTo)) .thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo))) .orElse(null); - if (latestReview == null || latestReview.getStatus() == null || latestReview.getStatus() != 2) { + if (latestReview == null || latestReview.getStatus() == null + || latestReview.getStatus() != ReviewStatus.REJECTED) { return; } - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); - if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { - return; - } - ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); - if (firstOne == null) { + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { return; } reviewListService.remove(new LambdaQueryWrapper() .eq(ReviewList::getModule, module) .eq(ReviewList::getPk, pk)); - saveFirstReviewNode(module, pk, firstOne); + saveReviewNode(module, pk, flowList.get(0), 0); } - private ReviewFlow getFirstFlow(ReviewFlowConfig reviewFlowConfig) { + /** + * 当前一级通过后推进到下一级。 + * + *

顺序号与审批链下标一一对应(首级 sort_number = 0),所以下一级就是 flowList 的 + * {@code currentSortNumber + 1};越界说明这一级已是最后一级,链条结束。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void advanceReview(String module, Integer pk, Integer currentSortNumber) { + if (module == null || pk == null || currentSortNumber == null) { + return; + } + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { + return; + } + int nextIndex = currentSortNumber + 1; + if (nextIndex >= flowList.size()) { + return; + } + ReviewFlow nextOne = flowList.get(nextIndex); + if (nextOne == null) { + return; + } + saveReviewNode(module, pk, nextOne, nextIndex); + } + + /** + * 读模块的审批链节点(按 level 升序,下标即 sort_number)。 + * + * @param module 模块编码 + * @return 审批链节点;没有配置时返回空列表 + */ + private List loadFlowList(String module) { + ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); + if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { + return java.util.Collections.emptyList(); + } ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); if (reviewFlow == null) { - return null; + return java.util.Collections.emptyList(); } List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); if (reviewFlowList == null || reviewFlowList.isEmpty()) { - return null; + return java.util.Collections.emptyList(); } - return reviewFlowList.get(0); + return reviewFlowList; } - private void saveFirstReviewNode(String module, Integer pk, ReviewFlow firstOne) { + /** + * 解析该级审批人并写一个待办节点。 + * + * @param module 模块编码 + * @param pk 业务主键 + * @param flow 该级审批链配置 + * @param sortNumber 待办顺序号(0 起) + */ + private void saveReviewNode(String module, Integer pk, ReviewFlow flow, int sortNumber) { Map record = selectDeclareRecord(module, pk); Integer collegeId = intValue(record, "collegeId"); Integer declareUserId = intValue(record, "userId"); - List reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId); + List reviewers = reviewerResolver.resolve(flow, collegeId, declareUserId); if (reviewers == null || reviewers.isEmpty()) { - throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 " - + (firstOne.getLevel() == null ? "?" : firstOne.getLevel()) - + " 级审批人解析结果为空,未生成待办"); + // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级、哪个模块/主键 + String scope = collegeId == null ? "申报记录缺少学院标识," : "学院「" + collegeName(collegeId) + "」"; + throw new BusinessException(scope + "第 " + levelOf(flow) + + " 级审批人解析结果为空(模块 " + module + " 主键 " + pk + "),未生成待办"); } ReviewList reviewList = new ReviewList(); reviewList.setPk(pk); reviewList.setModule(module); reviewList.setUserId(reviewers.get(0)); - reviewList.setSortNumber(0); + reviewList.setSortNumber(sortNumber); reviewListService.save(reviewList); } - /** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */ + /** + * 读申报记录的申报人与学院标识。 + * + *

哪张业务表有 college_id 由注册表 has_college 决定,后端不再维护模块白名单 + * (ADR 0006:新增表只改注册表一行)。 + */ private Map selectDeclareRecord(String module, Integer pk) { - if (COLLEGE_MODULES.contains(module)) { + if (moduleRegistryService.hasCollege(module)) { return declareRecordMapper.selectReviewContext(module, pk); } return declareRecordMapper.selectRecord(module, pk); } + private String collegeName(Integer collegeId) { + if (collegeId == null) { + return "学院标识为空"; + } + Organization organization = organizationService.getById(collegeId); + if (organization == null || organization.getOrganizationName() == null + || organization.getOrganizationName().isEmpty()) { + return "未知学院(" + collegeId + ")"; + } + return organization.getOrganizationName(); + } + + private String levelOf(ReviewFlow flow) { + return flow == null || flow.getLevel() == null ? "?" : String.valueOf(flow.getLevel()); + } + private Integer intValue(Map record, String key) { if (record == null) { return null; diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java index cc1ff6d..0100dc0 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper; @@ -157,7 +158,7 @@ public class TzbcyFormServiceImpl extends ServiceImpl reviewList = form.getReviewList(); ReviewList latest = reviewList.get(reviewList.size() - 1); - return latest != null && latest.getStatus() != null && latest.getStatus() == 1; + return latest != null && latest.getStatus() != null && latest.getStatus() == ReviewStatus.APPROVED; } private String buildPersonKey(String name, String phone) { diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java index 7cdd178..8e2fd0f 100644 --- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java @@ -6,8 +6,7 @@ import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.service.DeclareGuardService; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewListService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -54,10 +53,7 @@ class ReviewListControllerAuthTest { private ReviewListService reviewListService; @Mock - private ReviewFlowConfigService reviewFlowConfigService; - - @Mock - private ReviewFlowService reviewFlowService; + private ReviewFlowStarterService reviewFlowStarterService; @Mock private DeclareGuardService declareGuardService; @@ -117,6 +113,28 @@ class ReviewListControllerAuthTest { assertEquals(Constants.RESULT_OK_CODE, result.getCode()); verify(reviewListService).updateById(any()); verify(declareGuardService).syncReviewStatus("jhqn", PK); + // 通过后推进下一级:交给审批链服务按规则解析审批人 + verify(reviewFlowStarterService).advanceReview("jhqn", PK, 1); + } + + /** + * 已处理过的节点再 PUT 一次直接拒绝:否则会再插一条下一级待办, + * 同一申报出现第二组活跃节点(双击 / 多标签页即可复现)。 + */ + @Test + void approveIsDeniedWhenNodeIsAlreadyHandled() { + ReviewList handled = row(ROW_ID, OWNER_ID); + handled.setStatus(1); + when(reviewListService.getById(ROW_ID)).thenReturn(handled); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertNotEquals(Constants.RESULT_OK_CODE, result.getCode()); + assertTrue(result.getMessage() != null && result.getMessage().contains("已处理"), + "重复提交要给出可读提示,实际:" + result.getMessage()); + verify(reviewListService, never()).updateById(any()); + verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any()); } /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */ @@ -144,6 +162,8 @@ class ReviewListControllerAuthTest { assertEquals(Constants.RESULT_OK_CODE, result.getCode()); verify(reviewListService).updateById(any()); + // 驳回不推进下一级 + verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any()); } /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */ diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java index 35e0fbe..17bf56c 100644 --- a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java @@ -1,10 +1,13 @@ package com.gxwebsoft.gxmu.service.impl; import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewListService; @@ -28,6 +31,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; @@ -36,10 +40,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 审批流发起与驳回重走(spec #58)。 + * 审批流发起、驳回重走与逐级推进(spec #58)。 * *

接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。 - * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。

+ * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、推进时「第 2..N 级同样经解析」, + * 以及解析不出来时「一个节点都不写」。

* * @author Codex * @since 2026-11-10 @@ -52,6 +57,7 @@ class ReviewFlowStarterServiceImplTest { private static final Integer PK = 11; private static final Integer COLLEGE_ID = 100; private static final Integer DECLARE_USER_ID = 12; + private static final String COLLEGE_NAME = "药学院"; @Mock private ReviewFlowConfigService reviewFlowConfigService; @@ -68,6 +74,12 @@ class ReviewFlowStarterServiceImplTest { @Mock private DeclareRecordMapper declareRecordMapper; + @Mock + private ModuleRegistryService moduleRegistryService; + + @Mock + private OrganizationService organizationService; + @InjectMocks private ReviewFlowStarterServiceImpl reviewFlowStarterService; @@ -87,6 +99,12 @@ class ReviewFlowStarterServiceImplTest { when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); when(declareRecordMapper.selectRecord(MODULE, PK)).thenReturn(recordContext()); + // 学院标识由注册表 has_college 决定是否读 + when(moduleRegistryService.hasCollege(MODULE)).thenReturn(true); + Organization college = new Organization(); + college.setOrganizationId(COLLEGE_ID); + college.setOrganizationName(COLLEGE_NAME); + when(organizationService.getById(COLLEGE_ID)).thenReturn(college); // 默认:解析出第一级审批人 7 when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); } @@ -118,8 +136,12 @@ class ReviewFlowStarterServiceImplTest { void startReview_noReviewerThrowsAndWritesNoNode() { when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); - assertThrows(BusinessException.class, () -> reviewFlowStarterService.startReview(MODULE, PK)); + BusinessException error = assertThrows(BusinessException.class, + () -> reviewFlowStarterService.startReview(MODULE, PK)); + // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级 + assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage()); + assertTrue(error.getMessage().contains("第 1 级"), error.getMessage()); verify(reviewListService, never()).save(any()); } @@ -136,6 +158,68 @@ class ReviewFlowStarterServiceImplTest { verify(reviewListService, never()).save(any()); } + // ------------------------------------------------------------------ + // 逐级推进:第 2..N 级同样经 ReviewerResolver,不复制 review_user_id + // ------------------------------------------------------------------ + + /** 通过后推进:下一级节点的人来自解析,而不是上一级配置里写死的 review_user_id。 */ + @Test + void advanceReview_writesResolvedReviewerAsNextNode() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43)); + + reviewFlowStarterService.advanceReview(MODULE, PK, 0); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + ReviewList saved = captor.getValue(); + assertEquals(MODULE, saved.getModule()); + assertEquals(PK, saved.getPk()); + assertEquals(Integer.valueOf(1), saved.getSortNumber()); + assertEquals(Integer.valueOf(43), saved.getUserId(), "下一级应落在解析出的人上,而不是 review_user_id=8"); + // 解析的是第 2 级那条配置,并且带上了学院上下文 + verify(reviewerResolver).resolve( + org.mockito.ArgumentMatchers.argThat(item -> item != null && Integer.valueOf(2).equals(item.getLevel())), + eq(COLLEGE_ID), eq(DECLARE_USER_ID)); + } + + /** 已是最后一级:链条结束,不再生成节点。 */ + @Test + void advanceReview_lastLevelWritesNothing() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + + reviewFlowStarterService.advanceReview(MODULE, PK, 1); + + verify(reviewListService, never()).save(any()); + } + + /** 推进时解析为空:抛可读异常(带学院与层级)且不写节点,避免第二组无人认领的待办。 */ + @Test + void advanceReview_emptyReviewerThrowsAndWritesNoNode() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); + + BusinessException error = assertThrows(BusinessException.class, + () -> reviewFlowStarterService.advanceReview(MODULE, PK, 0)); + + assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage()); + assertTrue(error.getMessage().contains("第 2 级"), error.getMessage()); + verify(reviewListService, never()).save(any()); + } + + /** 没有审批链配置时推进是 no-op,不写节点也不报错。 */ + @Test + void advanceReview_withoutFlowConfigDoesNothing() { + when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null); + + reviewFlowStarterService.advanceReview(MODULE, PK, 0); + + verify(reviewListService, never()).save(any()); + } + // ------------------------------------------------------------------ // 驳回重走:先清旧节点,再解析第一级 // ------------------------------------------------------------------