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:
2026-11-03 22:28:02 +08:00
parent 1f2a837609
commit f96613a0ee
7 changed files with 661 additions and 1 deletions
@@ -51,6 +51,9 @@ public class ReviewFlow implements Serializable {
private Integer reviewUserId; private Integer reviewUserId;
@ApiModelProperty(value = "审批人规则,;分隔:USER:<userId> / COLLEGE_ROLE:<roleCode>;为空回退 review_user_id")
private String reviewerRule;
@TableField(exist = false) @TableField(exist = false)
private List<ReviewFlow> flows; private List<ReviewFlow> flows;
@@ -48,6 +48,11 @@ public interface DeclareRecordMapper {
+ "FROM ${table} WHERE id = #{id} AND deleted = 0") + "FROM ${table} WHERE id = #{id} AND deleted = 0")
Map<String, Object> selectRecord(@Param("table") String table, @Param("id") Integer id); 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 " @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 " + "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; package com.gxwebsoft.gxmu.service.impl;
import com.gxwebsoft.common.core.exception.BusinessException;
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.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;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import com.gxwebsoft.gxmu.service.ReviewerResolver;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
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.Set;
/** /**
* 审核流程发起服务实现 * 审核流程发起服务实现
* *
* <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析,
* 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。</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;
@Resource @Resource
private ReviewFlowService reviewFlowService; private ReviewFlowService reviewFlowService;
@Resource @Resource
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Resource
private ReviewerResolver reviewerResolver;
@Resource
private DeclareRecordMapper declareRecordMapper;
@Override @Override
public void startReview(String module, Integer pk) { 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) { 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 reviewList = new ReviewList();
reviewList.setPk(pk); reviewList.setPk(pk);
reviewList.setModule(module); reviewList.setModule(module);
reviewList.setUserId(firstOne.getReviewUserId()); reviewList.setUserId(reviewers.get(0));
reviewList.setSortNumber(0); reviewList.setSortNumber(0);
reviewListService.save(reviewList); 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;
}
}