feat(spec58): 审批人解析组件 ReviewerResolver 与发起接线
- 新增 ReviewerResolver:USER:<userId> / COLLEGE_ROLE:<roleCode>,; 分隔多段取并集升序 - reviewer_rule 为空回退 review_user_id;按学院解析空集合抛 BusinessException,消息含学院名/层级/缺什么 - ReviewFlowStarterService 发起与驳回重提改由解析取人,解析为空不写任何节点 - ReviewFlow 增加 reviewerRule 字段;DeclareRecordMapper 增加 selectReviewContext(仅 4 张在跑表)
This commit is contained in:
@@ -51,6 +51,9 @@ public class ReviewFlow implements Serializable {
|
||||
|
||||
private Integer reviewUserId;
|
||||
|
||||
@ApiModelProperty(value = "审批人规则,;分隔:USER:<userId> / COLLEGE_ROLE:<roleCode>;为空回退 review_user_id")
|
||||
private String reviewerRule;
|
||||
|
||||
@TableField(exist = false)
|
||||
private List<ReviewFlow> flows;
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ public interface DeclareRecordMapper {
|
||||
+ "FROM ${table} WHERE id = #{id} AND deleted = 0")
|
||||
Map<String, Object> selectRecord(@Param("table") String table, @Param("id") Integer id);
|
||||
|
||||
/** 审批人解析用:申报记录的申报人与学院标识(仅 4 张有 college_id 的在跑表可用) */
|
||||
@Select("SELECT id AS id, user_id AS userId, college_id AS collegeId "
|
||||
+ "FROM ${table} WHERE id = #{id} AND deleted = 0")
|
||||
Map<String, Object> selectReviewContext(@Param("table") String table, @Param("id") Integer id);
|
||||
|
||||
/** 同项目下其他有效申报的负责人手机号与团队成员,用于团队重复校验 */
|
||||
@Select("SELECT id AS id, phone AS phone, team_members AS teamMembers FROM gxmu_tzbcy_form "
|
||||
+ "WHERE declare_id = #{declareId} AND declare_status IN (1, 2, 3) AND deleted = 0 "
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.gxmu.service;
|
||||
|
||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批人解析(见 docs/adr/0006-approval-chain-convergence.md)。
|
||||
*
|
||||
* <p>输入一条审批链配置(reviewerRule / reviewUserId / organizationId)与申报记录的学院标识、申报人,
|
||||
* 输出该级审批人的 userId 集合(升序、去重)。规则语法:{@code ;} 分隔可拼装多段,
|
||||
* {@code USER:<userId>} 固定人,{@code COLLEGE_ROLE:<roleCode>} 在申报人学院的组织子树内找持有该角色的用户;
|
||||
* reviewerRule 为空时回退 reviewUserId(存量配置零迁移)。</p>
|
||||
*
|
||||
* <p>解析不出来时抛 {@link com.gxwebsoft.common.core.exception.BusinessException},
|
||||
* 消息里带学院名、第几级与缺什么——宁可发不出单,也不要发出无人认领的单。</p>
|
||||
*/
|
||||
public interface ReviewerResolver {
|
||||
|
||||
/**
|
||||
* 解析某一级审批链的审批人。
|
||||
*
|
||||
* @param flow 审批链配置(含 reviewerRule / reviewUserId / level)
|
||||
* @param collegeId 申报记录的学院标识(sys_organization.organization_id),可为空
|
||||
* @param declareUserId 申报人 userId,仅用于报错时定位,可为空
|
||||
* @return 审批人 userId 集合,升序去重且非空
|
||||
*/
|
||||
List<Integer> resolve(ReviewFlow flow, Integer collegeId, Integer declareUserId);
|
||||
}
|
||||
@@ -1,33 +1,52 @@
|
||||
package com.gxwebsoft.gxmu.service.impl;
|
||||
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
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.service.ReviewFlowConfigService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewListService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewerResolver;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 审核流程发起服务实现
|
||||
*
|
||||
* <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析,
|
||||
* 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。</p>
|
||||
*
|
||||
* @author LX
|
||||
* @since 2026-03-18
|
||||
*/
|
||||
@Service
|
||||
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
|
||||
private ReviewFlowConfigService reviewFlowConfigService;
|
||||
@Resource
|
||||
private ReviewFlowService reviewFlowService;
|
||||
@Resource
|
||||
private ReviewListService reviewListService;
|
||||
@Resource
|
||||
private ReviewerResolver reviewerResolver;
|
||||
@Resource
|
||||
private DeclareRecordMapper declareRecordMapper;
|
||||
|
||||
@Override
|
||||
public void startReview(String module, Integer pk) {
|
||||
@@ -91,11 +110,46 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
|
||||
}
|
||||
|
||||
private void saveFirstReviewNode(String module, Integer pk, ReviewFlow firstOne) {
|
||||
Map<String, Object> record = selectDeclareRecord(module, pk);
|
||||
Integer collegeId = intValue(record, "collegeId");
|
||||
Integer declareUserId = intValue(record, "userId");
|
||||
List<Integer> reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId);
|
||||
if (reviewers == null || reviewers.isEmpty()) {
|
||||
throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 "
|
||||
+ (firstOne.getLevel() == null ? "?" : firstOne.getLevel())
|
||||
+ " 级审批人解析结果为空,未生成待办");
|
||||
}
|
||||
ReviewList reviewList = new ReviewList();
|
||||
reviewList.setPk(pk);
|
||||
reviewList.setModule(module);
|
||||
reviewList.setUserId(firstOne.getReviewUserId());
|
||||
reviewList.setUserId(reviewers.get(0));
|
||||
reviewList.setSortNumber(0);
|
||||
reviewListService.save(reviewList);
|
||||
}
|
||||
|
||||
/** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */
|
||||
private Map<String, Object> selectDeclareRecord(String module, Integer pk) {
|
||||
if (COLLEGE_MODULES.contains(module)) {
|
||||
return declareRecordMapper.selectReviewContext(module, pk);
|
||||
}
|
||||
return declareRecordMapper.selectRecord(module, pk);
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> record, String key) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = record.get(key);
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.gxwebsoft.gxmu.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
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.entity.Role;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.entity.UserRole;
|
||||
import com.gxwebsoft.common.system.service.OrganizationService;
|
||||
import com.gxwebsoft.common.system.service.RoleService;
|
||||
import com.gxwebsoft.common.system.service.UserRoleService;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||
import com.gxwebsoft.gxmu.service.ReviewerResolver;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 审批人解析实现(ADR 0006)。
|
||||
*
|
||||
* <p>规则语法:{@code ;} 分隔可拼装多段,{@code USER:<userId>} 固定人,
|
||||
* {@code COLLEGE_ROLE:<roleCode>} 在申报人学院的组织子树内找持有该角色的用户。
|
||||
* reviewerRule 为空回退 reviewUserId,存量配置零迁移。多段取并集、按 userId 升序。</p>
|
||||
*
|
||||
* <p>按学院解析走不通时抛 {@link BusinessException},消息带学院名、第几级与缺什么:
|
||||
* 组织树与角色没维护时是「发不出单」,而不是「发出无人认领的单」。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-11-10
|
||||
*/
|
||||
@Service
|
||||
public class ReviewerResolverImpl implements ReviewerResolver {
|
||||
private static final String RULE_SEPARATOR = ";";
|
||||
private static final String PREFIX_USER = "USER:";
|
||||
private static final String PREFIX_COLLEGE_ROLE = "COLLEGE_ROLE:";
|
||||
|
||||
@Resource
|
||||
private OrganizationService organizationService;
|
||||
@Resource
|
||||
private RoleService roleService;
|
||||
@Resource
|
||||
private UserRoleService userRoleService;
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
@Override
|
||||
public List<Integer> resolve(ReviewFlow flow, Integer collegeId, Integer declareUserId) {
|
||||
if (flow == null) {
|
||||
throw new BusinessException("审批链配置不存在,无法解析审批人");
|
||||
}
|
||||
String rule = flow.getReviewerRule();
|
||||
if (StrUtil.isBlank(rule)) {
|
||||
if (flow.getReviewUserId() == null) {
|
||||
throw new BusinessException("第 " + levelOf(flow) + " 级未配置审批人规则,也未指定审批人,无法发起审批");
|
||||
}
|
||||
return Collections.singletonList(flow.getReviewUserId());
|
||||
}
|
||||
Set<Integer> reviewers = new TreeSet<>();
|
||||
for (String rawSegment : rule.split(RULE_SEPARATOR)) {
|
||||
String segment = rawSegment.trim();
|
||||
if (segment.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (segment.startsWith(PREFIX_USER)) {
|
||||
reviewers.add(parseFixedUser(flow, segment));
|
||||
} else if (segment.startsWith(PREFIX_COLLEGE_ROLE)) {
|
||||
String roleCode = segment.substring(PREFIX_COLLEGE_ROLE.length()).trim();
|
||||
if (StrUtil.isBlank(roleCode)) {
|
||||
throw unrecognizedRule(flow, segment);
|
||||
}
|
||||
reviewers.addAll(resolveByCollegeRole(flow, collegeId, declareUserId, roleCode));
|
||||
} else {
|
||||
throw unrecognizedRule(flow, segment);
|
||||
}
|
||||
}
|
||||
if (reviewers.isEmpty()) {
|
||||
throw new BusinessException("第 " + levelOf(flow) + " 级审批人解析结果为空(规则:" + rule + ")");
|
||||
}
|
||||
return new ArrayList<>(reviewers);
|
||||
}
|
||||
|
||||
/** USER:<userId> */
|
||||
private Integer parseFixedUser(ReviewFlow flow, String segment) {
|
||||
String value = segment.substring(PREFIX_USER.length()).trim();
|
||||
try {
|
||||
return Integer.valueOf(value);
|
||||
} catch (NumberFormatException e) {
|
||||
throw unrecognizedRule(flow, segment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COLLEGE_ROLE:<roleCode>:在学院组织子树内找持该角色的在岗用户。
|
||||
*/
|
||||
private Set<Integer> resolveByCollegeRole(ReviewFlow flow, Integer collegeId, Integer declareUserId,
|
||||
String roleCode) {
|
||||
Integer level = flow.getLevel();
|
||||
if (collegeId == null) {
|
||||
throw new BusinessException("第 " + levelOf(flow) + " 级审批人规则按学院解析,但申报记录缺少学院标识"
|
||||
+ (declareUserId == null ? "" : "(申报人 " + declareUserId + ")")
|
||||
+ ",无法定位学院;请先按学生档案回填申报记录的学院标识");
|
||||
}
|
||||
String collegeName = collegeName(collegeId);
|
||||
Set<Integer> organizationIds = subtreeOf(collegeId);
|
||||
Set<Integer> roleIds = roleIdsOf(roleCode);
|
||||
if (roleIds.isEmpty()) {
|
||||
throw new BusinessException("学院「" + collegeName + "」第 " + level + " 级审批人无法解析:" + roleCode
|
||||
+ " 角色在系统中不存在,请先维护角色");
|
||||
}
|
||||
List<UserRole> userRoles = userRoleService.list(new LambdaQueryWrapper<UserRole>()
|
||||
.select(UserRole::getUserId, UserRole::getRoleId)
|
||||
.in(UserRole::getRoleId, roleIds));
|
||||
List<Integer> roleUserIds = userRoles == null ? Collections.emptyList()
|
||||
: userRoles.stream().map(UserRole::getUserId).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
if (roleUserIds.isEmpty()) {
|
||||
throw new BusinessException("学院「" + collegeName + "」第 " + level + " 级审批人无法解析:没有用户持有角色 "
|
||||
+ roleCode + ",请先维护该角色的审核人");
|
||||
}
|
||||
List<User> users = userService.list(new LambdaQueryWrapper<User>()
|
||||
.select(User::getUserId)
|
||||
.in(User::getUserId, roleUserIds)
|
||||
.in(User::getOrganizationId, organizationIds)
|
||||
.eq(User::getStatus, 0));
|
||||
if (users == null || users.isEmpty()) {
|
||||
throw new BusinessException("学院「" + collegeName + "」第 " + level + " 级审批人无法解析:该学院组织子树内"
|
||||
+ "缺少持有角色 " + roleCode + " 的在岗用户,请先在组织树上维护审核人");
|
||||
}
|
||||
return users.stream().map(User::getUserId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/** 学院及其全部下级组织 id(参照 BaseController#resolveCurrentAndChildOrganizationIds) */
|
||||
private Set<Integer> subtreeOf(Integer organizationId) {
|
||||
Set<Integer> result = new LinkedHashSet<>();
|
||||
result.add(organizationId);
|
||||
List<Organization> organizations = organizationService.list(new LambdaQueryWrapper<Organization>()
|
||||
.select(Organization::getOrganizationId, Organization::getParentId));
|
||||
if (organizations == null || organizations.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, List<Integer>> childrenMap = new HashMap<>();
|
||||
for (Organization organization : organizations) {
|
||||
if (organization.getOrganizationId() == null) {
|
||||
continue;
|
||||
}
|
||||
childrenMap.computeIfAbsent(organization.getParentId(), key -> new ArrayList<>())
|
||||
.add(organization.getOrganizationId());
|
||||
}
|
||||
Deque<Integer> queue = new ArrayDeque<>();
|
||||
queue.add(organizationId);
|
||||
while (!queue.isEmpty()) {
|
||||
Integer current = queue.poll();
|
||||
for (Integer child : childrenMap.getOrDefault(current, Collections.emptyList())) {
|
||||
if (result.add(child)) {
|
||||
queue.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<Integer> roleIdsOf(String roleCode) {
|
||||
List<Role> roles = roleService.list(new LambdaQueryWrapper<Role>()
|
||||
.select(Role::getRoleId, Role::getRoleCode)
|
||||
.eq(Role::getRoleCode, roleCode));
|
||||
if (roles == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return roles.stream().map(Role::getRoleId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private String collegeName(Integer collegeId) {
|
||||
Organization organization = organizationService.getById(collegeId);
|
||||
if (organization == null || StrUtil.isBlank(organization.getOrganizationName())) {
|
||||
return "未知学院(" + collegeId + ")";
|
||||
}
|
||||
return organization.getOrganizationName();
|
||||
}
|
||||
|
||||
private BusinessException unrecognizedRule(ReviewFlow flow, String segment) {
|
||||
return new BusinessException("第 " + levelOf(flow) + " 级审批人规则无法识别:" + segment
|
||||
+ "(仅支持 USER:<userId> 与 COLLEGE_ROLE:<roleCode>)");
|
||||
}
|
||||
|
||||
private String levelOf(ReviewFlow flow) {
|
||||
return flow.getLevel() == null ? "?" : String.valueOf(flow.getLevel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.gxwebsoft.gxmu.service.impl;
|
||||
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
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.service.ReviewFlowConfigService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewListService;
|
||||
import com.gxwebsoft.gxmu.service.ReviewerResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批流发起测试(mock 掉审批链配置、待办与申报记录读取,不启动 Spring 容器、不连数据库)。
|
||||
*
|
||||
* <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法。断言「写进待办的人是谁」
|
||||
* 与「解析不出来时一个节点都不写」。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-11-10
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ReviewFlowStarterServiceImplTest {
|
||||
|
||||
private static final String MODULE = "gxmu_sjqn";
|
||||
private static final Integer PK = 5;
|
||||
private static final Integer COLLEGE_ID = 100;
|
||||
private static final Integer DECLARE_USER_ID = 12;
|
||||
|
||||
@Mock
|
||||
private ReviewFlowConfigService reviewFlowConfigService;
|
||||
|
||||
@Mock
|
||||
private ReviewFlowService reviewFlowService;
|
||||
|
||||
@Mock
|
||||
private ReviewListService reviewListService;
|
||||
|
||||
@Mock
|
||||
private ReviewerResolver reviewerResolver;
|
||||
|
||||
@Mock
|
||||
private DeclareRecordMapper declareRecordMapper;
|
||||
|
||||
@InjectMocks
|
||||
private ReviewFlowStarterServiceImpl reviewFlowStarterService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReviewFlowConfig config = new ReviewFlowConfig();
|
||||
config.setModule(MODULE);
|
||||
config.setFlowId(11);
|
||||
when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config);
|
||||
|
||||
ReviewFlow root = new ReviewFlow();
|
||||
root.setId(11);
|
||||
root.setTitle("十佳青年");
|
||||
when(reviewFlowService.getById(11)).thenReturn(root);
|
||||
|
||||
ReviewFlow first = new ReviewFlow();
|
||||
first.setId(11);
|
||||
first.setTitle("十佳青年");
|
||||
first.setLevel(1);
|
||||
first.setReviewerRule("USER:42");
|
||||
when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Collections.singletonList(first));
|
||||
|
||||
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
|
||||
when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext());
|
||||
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Arrays.asList(42, 43));
|
||||
}
|
||||
|
||||
/** 发起:待办的责任人来自解析结果(按 userId 升序取第一个),且带上模块、主键与顺序号。 */
|
||||
@Test
|
||||
void startReview_writesResolvedReviewerAsFirstNode() {
|
||||
reviewFlowStarterService.startReview(MODULE, PK);
|
||||
|
||||
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(42), saved.getUserId());
|
||||
assertEquals(Integer.valueOf(0), saved.getSortNumber());
|
||||
// 解析时带上申报记录的学院标识与申报人
|
||||
verify(reviewerResolver).resolve(any(), eq(COLLEGE_ID), eq(DECLARE_USER_ID));
|
||||
}
|
||||
|
||||
/** 解析为空:抛业务异常且一个节点都不写(宁可发不出单,也不发无人认领的单)。 */
|
||||
@Test
|
||||
void startReview_noReviewerThrowsAndWritesNoNode() {
|
||||
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
assertThrows(BusinessException.class, () -> reviewFlowStarterService.startReview(MODULE, PK));
|
||||
|
||||
verify(reviewListService, never()).save(any());
|
||||
}
|
||||
|
||||
/** 解析器直接报错(按学院无人):异常透出,同样不写节点。 */
|
||||
@Test
|
||||
void startReview_resolverErrorPropagatesAndWritesNoNode() {
|
||||
when(reviewerResolver.resolve(any(), any(), any()))
|
||||
.thenThrow(new BusinessException("学院「药学院」第 1 级审批人无法解析:缺少持角色的人"));
|
||||
|
||||
BusinessException error = assertThrows(BusinessException.class,
|
||||
() -> reviewFlowStarterService.startReview(MODULE, PK));
|
||||
|
||||
assertEquals("学院「药学院」第 1 级审批人无法解析:缺少持角色的人", error.getMessage());
|
||||
verify(reviewListService, never()).save(any());
|
||||
}
|
||||
|
||||
/** 驳回后重提:重新解析审批人,不复制旧的 review_user_id。 */
|
||||
@Test
|
||||
void restartReviewWhenLastRejected_resolvesReviewerAgain() {
|
||||
ReviewList rejected = new ReviewList();
|
||||
rejected.setId(3);
|
||||
rejected.setModule(MODULE);
|
||||
rejected.setPk(PK);
|
||||
rejected.setStatus(2);
|
||||
rejected.setCreateTime(LocalDateTime.of(2026, 11, 1, 10, 0));
|
||||
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.singletonList(rejected));
|
||||
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43));
|
||||
|
||||
reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||
|
||||
ArgumentCaptor<ReviewList> captor = ArgumentCaptor.forClass(ReviewList.class);
|
||||
verify(reviewListService).save(captor.capture());
|
||||
assertEquals(Integer.valueOf(43), captor.getValue().getUserId());
|
||||
}
|
||||
|
||||
private Map<String, Object> recordContext() {
|
||||
Map<String, Object> record = new HashMap<>();
|
||||
record.put("id", PK);
|
||||
record.put("userId", DECLARE_USER_ID);
|
||||
record.put("collegeId", COLLEGE_ID);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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.entity.Role;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.entity.UserRole;
|
||||
import com.gxwebsoft.common.system.service.OrganizationService;
|
||||
import com.gxwebsoft.common.system.service.RoleService;
|
||||
import com.gxwebsoft.common.system.service.UserRoleService;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 java.util.List;
|
||||
|
||||
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.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批人解析测试(mock 掉组织 / 角色 / 用户查询,不启动 Spring 容器、不连数据库)。
|
||||
*
|
||||
* <p>覆盖规则语法与「解析不出来即报错」的约定:固定人、按学院命中、按学院空集合报错、
|
||||
* 空规则回退 review_user_id、多段规则并集后取最小 userId、学院标识缺失给可读错误。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-11-10
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ReviewerResolverImplTest {
|
||||
|
||||
private static final String COLLEGE_ROLE = "YouthLeagueCommitteeOfSecondaryColleges";
|
||||
private static final Integer COLLEGE_ID = 100;
|
||||
private static final Integer DECLARE_USER_ID = 12;
|
||||
|
||||
@Mock
|
||||
private OrganizationService organizationService;
|
||||
|
||||
@Mock
|
||||
private RoleService roleService;
|
||||
|
||||
@Mock
|
||||
private UserRoleService userRoleService;
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@InjectMocks
|
||||
private ReviewerResolverImpl reviewerResolver;
|
||||
|
||||
/** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */
|
||||
@BeforeAll
|
||||
static void initLambdaCache() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, Organization.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Role.class);
|
||||
TableInfoHelper.initTableInfo(assistant, UserRole.class);
|
||||
TableInfoHelper.initTableInfo(assistant, User.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 药学院(100) 下有两个班级/支部节点(101、102)
|
||||
when(organizationService.list(any())).thenReturn(Arrays.asList(
|
||||
organization(100, 50, "药学院"),
|
||||
organization(101, 100, "药学院 2023 级 1 班"),
|
||||
organization(102, 100, "药学院 2023 级 2 班")
|
||||
));
|
||||
when(organizationService.getById(COLLEGE_ID)).thenReturn(organization(COLLEGE_ID, 50, "药学院"));
|
||||
when(roleService.list(any())).thenReturn(Collections.singletonList(role(5, COLLEGE_ROLE)));
|
||||
when(userRoleService.list(any())).thenReturn(Arrays.asList(
|
||||
userRole(7, 5),
|
||||
userRole(9, 5)
|
||||
));
|
||||
when(userService.list(any())).thenReturn(Arrays.asList(user(7), user(9)));
|
||||
}
|
||||
|
||||
/** USER:<userId>:直接返回固定人。 */
|
||||
@Test
|
||||
void resolve_fixedUserRule() {
|
||||
List<Integer> result = reviewerResolver.resolve(flow("USER:42", null, 1), null, DECLARE_USER_ID);
|
||||
|
||||
assertEquals(Collections.singletonList(42), result);
|
||||
}
|
||||
|
||||
/** COLLEGE_ROLE:在申报人学院的组织子树内找持该角色的人。 */
|
||||
@Test
|
||||
void resolve_collegeRoleHitsUserInSubtree() {
|
||||
List<Integer> result = reviewerResolver.resolve(
|
||||
flow("COLLEGE_ROLE:" + COLLEGE_ROLE, null, 2), COLLEGE_ID, DECLARE_USER_ID);
|
||||
|
||||
assertEquals(Arrays.asList(7, 9), result);
|
||||
}
|
||||
|
||||
/** 按学院解析空集合:报错消息里要能看出是哪个学院、第几级、缺什么。 */
|
||||
@Test
|
||||
void resolve_collegeRoleEmptyThrowsWithCollegeAndLevel() {
|
||||
when(userService.list(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||
reviewerResolver.resolve(flow("COLLEGE_ROLE:" + COLLEGE_ROLE, null, 2), COLLEGE_ID, DECLARE_USER_ID));
|
||||
|
||||
String message = error.getMessage();
|
||||
assertTrue(message.contains("药学院"), "消息应包含学院名:" + message);
|
||||
assertTrue(message.contains("第 2 级"), "消息应包含层级:" + message);
|
||||
assertTrue(message.contains(COLLEGE_ROLE), "消息应包含缺失的角色码:" + message);
|
||||
}
|
||||
|
||||
/** 学院标识缺失(存量 / 研究生无班级):给可读错误,不能 NPE。 */
|
||||
@Test
|
||||
void resolve_missingCollegeIdThrowsReadableError() {
|
||||
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||
reviewerResolver.resolve(flow("COLLEGE_ROLE:" + COLLEGE_ROLE, null, 3), null, DECLARE_USER_ID));
|
||||
|
||||
String message = error.getMessage();
|
||||
assertTrue(message.contains("学院标识"), "消息应说明缺少学院标识:" + message);
|
||||
assertTrue(message.contains("第 3 级"), "消息应包含层级:" + message);
|
||||
}
|
||||
|
||||
/** reviewer_rule 为空:回退 review_user_id,存量配置零迁移。 */
|
||||
@Test
|
||||
void resolve_blankRuleFallsBackToReviewUserId() {
|
||||
assertEquals(Collections.singletonList(9), reviewerResolver.resolve(flow(null, 9, 1), null, DECLARE_USER_ID));
|
||||
assertEquals(Collections.singletonList(9), reviewerResolver.resolve(flow(" ", 9, 1), null, DECLARE_USER_ID));
|
||||
}
|
||||
|
||||
/** 多段规则取并集并升序,最小 userId 排在最前(一期串行单审取第一个)。 */
|
||||
@Test
|
||||
void resolve_multipleSegmentsUnionSortedAscending() {
|
||||
when(userService.list(any())).thenReturn(Collections.singletonList(user(3)));
|
||||
|
||||
List<Integer> result = reviewerResolver.resolve(
|
||||
flow("USER:8; COLLEGE_ROLE:" + COLLEGE_ROLE, null, 1), COLLEGE_ID, DECLARE_USER_ID);
|
||||
|
||||
assertEquals(Arrays.asList(3, 8), result);
|
||||
assertEquals(Integer.valueOf(3), result.get(0));
|
||||
}
|
||||
|
||||
/** 无法识别的规则段:明确报错,不静默跳过。 */
|
||||
@Test
|
||||
void resolve_unknownSegmentThrows() {
|
||||
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||
reviewerResolver.resolve(flow("ROLE:abc", null, 1), COLLEGE_ID, DECLARE_USER_ID));
|
||||
|
||||
assertTrue(error.getMessage().contains("ROLE:abc"), "消息应回显无法识别的规则:" + error.getMessage());
|
||||
}
|
||||
|
||||
private ReviewFlow flow(String reviewerRule, Integer reviewUserId, Integer level) {
|
||||
ReviewFlow flow = new ReviewFlow();
|
||||
flow.setId(level);
|
||||
flow.setLevel(level);
|
||||
flow.setReviewerRule(reviewerRule);
|
||||
flow.setReviewUserId(reviewUserId);
|
||||
flow.setOrganizationId(COLLEGE_ID);
|
||||
return flow;
|
||||
}
|
||||
|
||||
private Organization organization(Integer id, Integer parentId, String name) {
|
||||
Organization organization = new Organization();
|
||||
organization.setOrganizationId(id);
|
||||
organization.setParentId(parentId);
|
||||
organization.setOrganizationName(name);
|
||||
return organization;
|
||||
}
|
||||
|
||||
private Role role(Integer id, String code) {
|
||||
Role role = new Role();
|
||||
role.setRoleId(id);
|
||||
role.setRoleCode(code);
|
||||
return role;
|
||||
}
|
||||
|
||||
private UserRole userRole(Integer userId, Integer roleId) {
|
||||
UserRole userRole = new UserRole();
|
||||
userRole.setUserId(userId);
|
||||
userRole.setRoleId(roleId);
|
||||
return userRole;
|
||||
}
|
||||
|
||||
private User user(Integer userId) {
|
||||
User user = new User();
|
||||
user.setUserId(userId);
|
||||
user.setStatus(0);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user