fix(gxmu): 审批人解析覆盖第 2..N 级,已处理节点再提交直接拒绝

- 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
This commit is contained in:
2026-11-03 23:14:00 +08:00
parent be011dd4f1
commit 43f0119b25
9 changed files with 332 additions and 122 deletions
@@ -10,12 +10,7 @@ import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
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 io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -38,11 +33,7 @@ public class ManuscriptController extends BaseController {
@Resource @Resource
private ManuscriptService manuscriptService; private ManuscriptService manuscriptService;
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowStarterService reviewFlowStarterService;
@Resource
private ReviewFlowService reviewFlowService;
@Resource
private ReviewListService reviewListService;
@ApiOperation("分页查询稿件") @ApiOperation("分页查询稿件")
@GetMapping("/page") @GetMapping("/page")
@@ -75,23 +66,8 @@ public class ManuscriptController extends BaseController {
manuscript.setUserId(loginUser.getUserId()); manuscript.setUserId(loginUser.getUserId());
} }
if (manuscriptService.save(manuscript)) { if (manuscriptService.save(manuscript)) {
// 查找该模块的审核流 // 首级审批人同样经 ReviewerResolver 解析(与其它模块同一条路径),不复制 review_user_id
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule("cms_manuscript"); reviewFlowStarterService.startReview("cms_manuscript", manuscript.getId());
if (reviewFlowConfig != null) {
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow != null) {
List<ReviewFlow> 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);
}
}
}
return success("添加成功"); return success("添加成功");
} }
return fail("添加失败"); return fail("添加失败");
@@ -0,0 +1,26 @@
package com.gxwebsoft.gxmu.constant;
/**
* 审核待办(gxmu_review_list.status)的状态取值。
*
* <p>这套取值与申报记录的 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() {
}
}
@@ -3,19 +3,15 @@ package com.gxwebsoft.gxmu.controller;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.param.ReviewListParam; import com.gxwebsoft.gxmu.param.ReviewListParam;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult; 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.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@@ -23,11 +19,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable;
import java.util.List; import java.util.List;
/** /**
* 审核列表控制器 * 审核列表控制器
* *
* <p>审批动作(通过 / 驳回)的归属校验、状态校验留在这里(HTTP 层的准入判断);
* 通过后「推进到下一级」属于审批链业务规则,交给 {@link ReviewFlowStarterService#advanceReview},
* 审批人一律经 {@link com.gxwebsoft.gxmu.service.ReviewerResolver} 解析,不再复制 review_user_id。
*
* @author LX * @author LX
* @since 2026-03-18 15:50:51 * @since 2026-03-18 15:50:51
*/ */
@@ -38,11 +39,9 @@ public class ReviewListController extends BaseController {
@Resource @Resource
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService;
@Resource
private ReviewFlowService reviewFlowService;
@Resource
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@Resource
private ReviewFlowStarterService reviewFlowStarterService;
@ApiOperation("分页查询审核列表") @ApiOperation("分页查询审核列表")
@GetMapping("/page") @GetMapping("/page")
@@ -100,8 +99,14 @@ public class ReviewListController extends BaseController {
@ApiOperation("修改审核列表") @ApiOperation("修改审核列表")
@PutMapping() @PutMapping()
public ApiResult<?> update(@RequestBody ReviewList reviewList) { 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) { if (denied != null) {
return denied; return denied;
} }
@@ -110,34 +115,10 @@ public class ReviewListController extends BaseController {
return denied; return denied;
} }
if (reviewListService.updateById(reviewList)) { if (reviewListService.updateById(reviewList)) {
// 通过进入下一个 // 通过则推进到下一级;下一级的审批人由审批链配置解析,不复制本级的 review_user_id
if (Integer.valueOf(1).equals(reviewList.getStatus())) { if (Integer.valueOf(ReviewStatus.APPROVED).equals(reviewList.getStatus())) {
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule()); reviewFlowStarterService.advanceReview(reviewList.getModule(), reviewList.getPk(),
if (reviewFlowConfig != null) { existing == null ? null : existing.getSortNumber());
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow != null) {
List<ReviewFlow> 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 ()
// }
// }
}
}
}
} }
declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk()); declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk());
return success("修改成功"); return success("修改成功");
@@ -166,11 +147,15 @@ public class ReviewListController extends BaseController {
@ApiOperation("批量修改审核列表") @ApiOperation("批量修改审核列表")
@PutMapping("/batch") @PutMapping("/batch")
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) { public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) {
// 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库 // 批量审批逐行校验归属与状态,只要有一行不满足就整体拒绝,避免部分落库
ApiResult<?> denied = denyIfBatchNotAssignee(batchParam); ApiResult<?> denied = denyIfBatchNotAssignee(batchParam);
if (denied != null) { if (denied != null) {
return denied; return denied;
} }
denied = denyIfBatchAlreadyHandled(batchParam);
if (denied != null) {
return denied;
}
denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData()); denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData());
if (denied != null) { if (denied != null) {
return denied; return denied;
@@ -191,7 +176,10 @@ public class ReviewListController extends BaseController {
* @return null 表示校验通过,否则返回 403 结果 * @return null 表示校验通过,否则返回 403 结果
*/ */
private ApiResult<?> denyIfNotAssignee(Integer id) { 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(); Integer loginUserId = getLoginUserId();
if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) { if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) {
return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG); return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG);
@@ -199,6 +187,25 @@ public class ReviewListController extends BaseController {
return null; return null;
} }
/**
* 当前节点必须仍是「待审核」才允许提交审批动作。
*
* <p>重复通过会给同一申报再造一组待办(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) { if (batchParam == null || batchParam.getIds() == null) {
return 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); ApiResult<?> denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null);
if (denied != null) { if (denied != null) {
return denied; return denied;
@@ -218,6 +225,26 @@ public class ReviewListController extends BaseController {
return null; return null;
} }
/**
* 批量审批的状态校验:任意一行已处理就整体拒绝。
*
* @param batchParam 批量参数
* @return null 表示全部仍是待审核,否则返回可读的失败结果
*/
private ApiResult<?> denyIfBatchAlreadyHandled(BatchParam<ReviewList> 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 表示校验通过,否则返回可读的失败结果 * @return null 表示校验通过,否则返回可读的失败结果
*/ */
private ApiResult<?> denyIfRejectWithoutContent(ReviewList data) { 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 fail("驳回必须填写审核意见");
} }
return null; return null;
@@ -236,7 +264,7 @@ public class ReviewListController extends BaseController {
if (batchParam == null || batchParam.getIds() == null) { if (batchParam == null || batchParam.getIds() == null) {
return; return;
} }
for (java.io.Serializable id : batchParam.getIds()) { for (Serializable id : batchParam.getIds()) {
ReviewList saved = reviewListService.getById(id); ReviewList saved = reviewListService.getById(id);
if (saved != null) { if (saved != null) {
declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk()); declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk());
@@ -283,4 +311,4 @@ public class ReviewListController extends BaseController {
} }
} }
} }
@@ -23,4 +23,16 @@ public interface ReviewFlowStarterService {
* @param pk 业务主键 * @param pk 业务主键
*/ */
void restartReviewWhenLastRejected(String module, Integer pk); void restartReviewWhenLastRejected(String module, Integer pk);
/**
* 当前一级通过后推进到下一级:审批人同样走 {@link ReviewerResolver} 解析,不复制上一级的 review_user_id。
*
* <p>解析不出审批人时抛可读的业务异常且一个节点都不写(宁可卡住,也不生成无人认领的待办);
* 已经是最后一级则什么都不做,链条自然结束。
*
* @param module 模块编码
* @param pk 业务主键
* @param currentSortNumber 当前已通过节点的顺序号(首级为 0,与 review_list.sort_number 同口径)
*/
void advanceReview(String module, Integer pk, Integer currentSortNumber);
} }
@@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.Declare; import com.gxwebsoft.gxmu.entity.Declare;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
@@ -160,7 +161,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return; return;
} }
boolean pending = nodes.stream() boolean pending = nodes.stream()
.anyMatch(node -> node.getStatus() != null && node.getStatus() == 0); .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.PENDING);
if (pending) { if (pending) {
declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null); declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null);
return; return;
@@ -181,7 +182,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return; return;
} }
declareRecordMapper.updateStatus(module, recordId, declareRecordMapper.updateStatus(module, recordId,
last.getStatus() == 2 ? STATUS_REJECTED : STATUS_PASSED, null); last.getStatus() == ReviewStatus.REJECTED ? STATUS_REJECTED : STATUS_PASSED, null);
} }
@Override @Override
@@ -200,7 +201,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return "只有待审核的申报可以撤回"; return "只有待审核的申报可以撤回";
} }
boolean approved = reviewListService.listByModuleNPK(module, recordId).stream() 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) { if (approved) {
return "已有审核通过的记录不能撤回,请联系管理员处理"; return "已有审核通过的记录不能撤回,请联系管理员处理";
} }
@@ -2,10 +2,14 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.exception.BusinessException; 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.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
@@ -16,28 +20,22 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 审核流程发起服务实现 * 审核流程发起服务实现
* *
* <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析, * <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析,
* 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。</p> * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。
* 首级(发起)、驳回重提、逐级推进三条路径都走同一套解析,第 2..N 级不再复制 review_user_id。</p>
* *
* @author LX * @author LX
* @since 2026-03-18 * @since 2026-03-18
*/ */
@Service @Service
public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
/** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */
private static final Set<String> COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project")));
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowConfigService reviewFlowConfigService;
@@ -49,6 +47,10 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
private ReviewerResolver reviewerResolver; private ReviewerResolver reviewerResolver;
@Resource @Resource
private DeclareRecordMapper declareRecordMapper; private DeclareRecordMapper declareRecordMapper;
@Resource
private ModuleRegistryService moduleRegistryService;
@Resource
private OrganizationService organizationService;
@Override @Override
public void startReview(String module, Integer pk) { public void startReview(String module, Integer pk) {
@@ -59,15 +61,11 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
if (reviewLists != null && !reviewLists.isEmpty()) { if (reviewLists != null && !reviewLists.isEmpty()) {
return; return;
} }
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); List<ReviewFlow> flowList = loadFlowList(module);
if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { if (flowList.isEmpty()) {
return; return;
} }
ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); saveReviewNode(module, pk, flowList.get(0), 0);
if (firstOne == null) {
return;
}
saveFirstReviewNode(module, pk, firstOne);
} }
/** /**
@@ -90,61 +88,125 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
Comparator.nullsLast(LocalDateTime::compareTo)) Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo))) .thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo)))
.orElse(null); .orElse(null);
if (latestReview == null || latestReview.getStatus() == null || latestReview.getStatus() != 2) { if (latestReview == null || latestReview.getStatus() == null
|| latestReview.getStatus() != ReviewStatus.REJECTED) {
return; return;
} }
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); List<ReviewFlow> flowList = loadFlowList(module);
if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { if (flowList.isEmpty()) {
return;
}
ReviewFlow firstOne = getFirstFlow(reviewFlowConfig);
if (firstOne == null) {
return; return;
} }
reviewListService.remove(new LambdaQueryWrapper<ReviewList>() reviewListService.remove(new LambdaQueryWrapper<ReviewList>()
.eq(ReviewList::getModule, module) .eq(ReviewList::getModule, module)
.eq(ReviewList::getPk, pk)); .eq(ReviewList::getPk, pk));
saveFirstReviewNode(module, pk, firstOne); saveReviewNode(module, pk, flowList.get(0), 0);
} }
private ReviewFlow getFirstFlow(ReviewFlowConfig reviewFlowConfig) { /**
* 当前一级通过后推进到下一级。
*
* <p>顺序号与审批链下标一一对应(首级 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<ReviewFlow> 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<ReviewFlow> 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()); ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow == null) { if (reviewFlow == null) {
return null; return java.util.Collections.emptyList();
} }
List<ReviewFlow> reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); List<ReviewFlow> reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle());
if (reviewFlowList == null || reviewFlowList.isEmpty()) { 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<String, Object> record = selectDeclareRecord(module, pk); Map<String, Object> record = selectDeclareRecord(module, pk);
Integer collegeId = intValue(record, "collegeId"); Integer collegeId = intValue(record, "collegeId");
Integer declareUserId = intValue(record, "userId"); Integer declareUserId = intValue(record, "userId");
List<Integer> reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId); List<Integer> reviewers = reviewerResolver.resolve(flow, collegeId, declareUserId);
if (reviewers == null || reviewers.isEmpty()) { if (reviewers == null || reviewers.isEmpty()) {
throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 " // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级、哪个模块/主键
+ (firstOne.getLevel() == null ? "?" : firstOne.getLevel()) String scope = collegeId == null ? "申报记录缺少学院标识," : "学院「" + collegeName(collegeId) + "」";
+ " 级审批人解析结果为空,未生成待办"); throw new BusinessException(scope + "第 " + levelOf(flow)
+ " 级审批人解析结果为空(模块 " + module + " 主键 " + pk + "),未生成待办");
} }
ReviewList reviewList = new ReviewList(); ReviewList reviewList = new ReviewList();
reviewList.setPk(pk); reviewList.setPk(pk);
reviewList.setModule(module); reviewList.setModule(module);
reviewList.setUserId(reviewers.get(0)); reviewList.setUserId(reviewers.get(0));
reviewList.setSortNumber(0); reviewList.setSortNumber(sortNumber);
reviewListService.save(reviewList); reviewListService.save(reviewList);
} }
/** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */ /**
* 读申报记录的申报人与学院标识。
*
* <p>哪张业务表有 college_id 由注册表 has_college 决定,后端不再维护模块白名单
* (ADR 0006:新增表只改注册表一行)。
*/
private Map<String, Object> selectDeclareRecord(String module, Integer pk) { private Map<String, Object> selectDeclareRecord(String module, Integer pk) {
if (COLLEGE_MODULES.contains(module)) { if (moduleRegistryService.hasCollege(module)) {
return declareRecordMapper.selectReviewContext(module, pk); return declareRecordMapper.selectReviewContext(module, pk);
} }
return declareRecordMapper.selectRecord(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<String, Object> record, String key) { private Integer intValue(Map<String, Object> record, String key) {
if (record == null) { if (record == null) {
return null; return null;
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.ReviewList;
import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.entity.TzbcyForm;
import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper; import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper;
@@ -157,7 +158,7 @@ public class TzbcyFormServiceImpl extends ServiceImpl<TzbcyFormMapper, TzbcyForm
} }
List<ReviewList> reviewList = form.getReviewList(); List<ReviewList> reviewList = form.getReviewList();
ReviewList latest = reviewList.get(reviewList.size() - 1); 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) { private String buildPersonKey(String name, String phone) {
@@ -6,8 +6,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -54,10 +53,7 @@ class ReviewListControllerAuthTest {
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Mock @Mock
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowStarterService reviewFlowStarterService;
@Mock
private ReviewFlowService reviewFlowService;
@Mock @Mock
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@@ -117,6 +113,28 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode()); assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any()); verify(reviewListService).updateById(any());
verify(declareGuardService).syncReviewStatus("jhqn", PK); 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 为空/空白时拒绝,提示要可读。 */ /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */
@@ -144,6 +162,8 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode()); assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any()); verify(reviewListService).updateById(any());
// 驳回不推进下一级
verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any());
} }
/** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */ /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */
@@ -1,10 +1,13 @@
package com.gxwebsoft.gxmu.service.impl; package com.gxwebsoft.gxmu.service.impl;
import com.gxwebsoft.common.core.exception.BusinessException; 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.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewListService; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
@@ -36,10 +40,11 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 审批流发起与驳回重走(spec #58)。 * 审批流发起、驳回重走与逐级推进(spec #58)。
* *
* <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。 * <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。
* 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。</p> * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、推进时「第 2..N 级同样经解析」,
* 以及解析不出来时「一个节点都不写」。</p>
* *
* @author Codex * @author Codex
* @since 2026-11-10 * @since 2026-11-10
@@ -52,6 +57,7 @@ class ReviewFlowStarterServiceImplTest {
private static final Integer PK = 11; private static final Integer PK = 11;
private static final Integer COLLEGE_ID = 100; private static final Integer COLLEGE_ID = 100;
private static final Integer DECLARE_USER_ID = 12; private static final Integer DECLARE_USER_ID = 12;
private static final String COLLEGE_NAME = "药学院";
@Mock @Mock
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowConfigService reviewFlowConfigService;
@@ -68,6 +74,12 @@ class ReviewFlowStarterServiceImplTest {
@Mock @Mock
private DeclareRecordMapper declareRecordMapper; private DeclareRecordMapper declareRecordMapper;
@Mock
private ModuleRegistryService moduleRegistryService;
@Mock
private OrganizationService organizationService;
@InjectMocks @InjectMocks
private ReviewFlowStarterServiceImpl reviewFlowStarterService; private ReviewFlowStarterServiceImpl reviewFlowStarterService;
@@ -87,6 +99,12 @@ class ReviewFlowStarterServiceImplTest {
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext());
when(declareRecordMapper.selectRecord(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 // 默认:解析出第一级审批人 7
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7));
} }
@@ -118,8 +136,12 @@ class ReviewFlowStarterServiceImplTest {
void startReview_noReviewerThrowsAndWritesNoNode() { void startReview_noReviewerThrowsAndWritesNoNode() {
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); 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()); verify(reviewListService, never()).save(any());
} }
@@ -136,6 +158,68 @@ class ReviewFlowStarterServiceImplTest {
verify(reviewListService, never()).save(any()); 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<ReviewList> 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());
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 驳回重走:先清旧节点,再解析第一级 // 驳回重走:先清旧节点,再解析第一级
// ------------------------------------------------------------------ // ------------------------------------------------------------------