diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/ReviewFlow.java b/src/main/java/com/gxwebsoft/gxmu/entity/ReviewFlow.java index ec74d51..2b1c783 100644 --- a/src/main/java/com/gxwebsoft/gxmu/entity/ReviewFlow.java +++ b/src/main/java/com/gxwebsoft/gxmu/entity/ReviewFlow.java @@ -51,6 +51,9 @@ public class ReviewFlow implements Serializable { private Integer reviewUserId; + @ApiModelProperty(value = "审批人规则,;分隔:USER: / COLLEGE_ROLE:;为空回退 review_user_id") + private String reviewerRule; + @TableField(exist = false) private List flows; diff --git a/src/main/java/com/gxwebsoft/gxmu/mapper/DeclareRecordMapper.java b/src/main/java/com/gxwebsoft/gxmu/mapper/DeclareRecordMapper.java index 13aca9b..7b30390 100644 --- a/src/main/java/com/gxwebsoft/gxmu/mapper/DeclareRecordMapper.java +++ b/src/main/java/com/gxwebsoft/gxmu/mapper/DeclareRecordMapper.java @@ -48,6 +48,11 @@ public interface DeclareRecordMapper { + "FROM ${table} WHERE id = #{id} AND deleted = 0") Map 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 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 " diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewerResolver.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewerResolver.java new file mode 100644 index 0000000..d594b18 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewerResolver.java @@ -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)。 + * + *

输入一条审批链配置(reviewerRule / reviewUserId / organizationId)与申报记录的学院标识、申报人, + * 输出该级审批人的 userId 集合(升序、去重)。规则语法:{@code ;} 分隔可拼装多段, + * {@code USER:} 固定人,{@code COLLEGE_ROLE:} 在申报人学院的组织子树内找持有该角色的用户; + * reviewerRule 为空时回退 reviewUserId(存量配置零迁移)。

+ * + *

解析不出来时抛 {@link com.gxwebsoft.common.core.exception.BusinessException}, + * 消息里带学院名、第几级与缺什么——宁可发不出单,也不要发出无人认领的单。

+ */ +public interface ReviewerResolver { + + /** + * 解析某一级审批链的审批人。 + * + * @param flow 审批链配置(含 reviewerRule / reviewUserId / level) + * @param collegeId 申报记录的学院标识(sys_organization.organization_id),可为空 + * @param declareUserId 申报人 userId,仅用于报错时定位,可为空 + * @return 审批人 userId 集合,升序去重且非空 + */ + List resolve(ReviewFlow flow, Integer collegeId, Integer declareUserId); +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java index 007aa24..dd44440 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java @@ -1,35 +1,54 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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 org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; /** * 审核流程发起服务实现 * + *

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

+ * * @author LX * @since 2026-03-18 */ @Service public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { + /** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */ + private static final Set COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project"))); + @Resource private ReviewFlowConfigService reviewFlowConfigService; @Resource private ReviewFlowService reviewFlowService; @Resource private ReviewListService reviewListService; + @Resource + private ReviewerResolver reviewerResolver; + @Resource + private DeclareRecordMapper declareRecordMapper; @Override public void startReview(String module, Integer pk) { @@ -101,11 +120,46 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { } private void saveFirstReviewNode(String module, Integer pk, ReviewFlow firstOne) { + Map record = selectDeclareRecord(module, pk); + Integer collegeId = intValue(record, "collegeId"); + Integer declareUserId = intValue(record, "userId"); + List 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 selectDeclareRecord(String module, Integer pk) { + if (COLLEGE_MODULES.contains(module)) { + return declareRecordMapper.selectReviewContext(module, pk); + } + return declareRecordMapper.selectRecord(module, pk); + } + + private Integer intValue(Map 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; + } + } } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImpl.java new file mode 100644 index 0000000..fb0cd1b --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImpl.java @@ -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)。 + * + *

规则语法:{@code ;} 分隔可拼装多段,{@code USER:} 固定人, + * {@code COLLEGE_ROLE:} 在申报人学院的组织子树内找持有该角色的用户。 + * reviewerRule 为空回退 reviewUserId,存量配置零迁移。多段取并集、按 userId 升序。

+ * + *

按学院解析走不通时抛 {@link BusinessException},消息带学院名、第几级与缺什么: + * 组织树与角色没维护时是「发不出单」,而不是「发出无人认领的单」。

+ * + * @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 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 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: */ + 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::在学院组织子树内找持该角色的在岗用户。 + */ + private Set 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 organizationIds = subtreeOf(collegeId); + Set roleIds = roleIdsOf(roleCode); + if (roleIds.isEmpty()) { + throw new BusinessException("学院「" + collegeName + "」第 " + level + " 级审批人无法解析:" + roleCode + + " 角色在系统中不存在,请先维护角色"); + } + List userRoles = userRoleService.list(new LambdaQueryWrapper() + .select(UserRole::getUserId, UserRole::getRoleId) + .in(UserRole::getRoleId, roleIds)); + List 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 users = userService.list(new LambdaQueryWrapper() + .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 subtreeOf(Integer organizationId) { + Set result = new LinkedHashSet<>(); + result.add(organizationId); + List organizations = organizationService.list(new LambdaQueryWrapper() + .select(Organization::getOrganizationId, Organization::getParentId)); + if (organizations == null || organizations.isEmpty()) { + return result; + } + Map> childrenMap = new HashMap<>(); + for (Organization organization : organizations) { + if (organization.getOrganizationId() == null) { + continue; + } + childrenMap.computeIfAbsent(organization.getParentId(), key -> new ArrayList<>()) + .add(organization.getOrganizationId()); + } + Deque 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 roleIdsOf(String roleCode) { + List roles = roleService.list(new LambdaQueryWrapper() + .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: 与 COLLEGE_ROLE:)"); + } + + private String levelOf(ReviewFlow flow) { + return flow.getLevel() == null ? "?" : String.valueOf(flow.getLevel()); + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java index 6abc751..35e0fbe 100644 --- a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java @@ -1,11 +1,15 @@ 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; @@ -19,23 +23,26 @@ import org.mockito.quality.Strictness; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collections; -import java.util.List; +import java.util.HashMap; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +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.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 驳回后重发审批链(spec #58:同一申报记录任一时刻只有一组活跃节点)。 + * 审批流发起与驳回重走(spec #58)。 * - *

修复前 {@code restartReviewWhenLastRejected} 只追加一条新节点,旧节点留在待办里, - * 同一份申报会出现多条待办、还会叠加多轮历史。这里钉住"先清旧节点、再建第一级"。 + *

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

+ * + * @author Codex + * @since 2026-11-10 */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -43,6 +50,8 @@ class ReviewFlowStarterServiceImplTest { private static final String MODULE = "gxmu_sjqn"; private static final Integer PK = 11; + private static final Integer COLLEGE_ID = 100; + private static final Integer DECLARE_USER_ID = 12; @Mock private ReviewFlowConfigService reviewFlowConfigService; @@ -53,8 +62,153 @@ class ReviewFlowStarterServiceImplTest { @Mock private ReviewListService reviewListService; + @Mock + private ReviewerResolver reviewerResolver; + + @Mock + private DeclareRecordMapper declareRecordMapper; + @InjectMocks - private ReviewFlowStarterServiceImpl starterService; + private ReviewFlowStarterServiceImpl reviewFlowStarterService; + + @BeforeEach + void setUp() { + ReviewFlowConfig config = new ReviewFlowConfig(); + config.setModule(MODULE); + config.setFlowId(3); + when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config); + + ReviewFlow root = flow("十佳青年", null, 99); + root.setId(3); + when(reviewFlowService.getById(3)).thenReturn(root); + when(reviewFlowService.listByTitle("十佳青年")) + .thenReturn(Collections.singletonList(flow("十佳青年", 1, 7))); + + when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); + when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); + when(declareRecordMapper.selectRecord(MODULE, PK)).thenReturn(recordContext()); + // 默认:解析出第一级审批人 7 + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); + } + + // ------------------------------------------------------------------ + // 发起:审批人来自解析 + // ------------------------------------------------------------------ + + /** 发起:待办的责任人来自解析结果(按 userId 升序取第一个),且带上模块、主键与顺序号。 */ + @Test + void startReview_writesResolvedReviewerAsFirstNode() { + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Arrays.asList(42, 43)); + + reviewFlowStarterService.startReview(MODULE, PK); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + ReviewList saved = captor.getValue(); + assertEquals(MODULE, saved.getModule()); + assertEquals(PK, saved.getPk()); + assertEquals(Integer.valueOf(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()); + } + + // ------------------------------------------------------------------ + // 驳回重走:先清旧节点,再解析第一级 + // ------------------------------------------------------------------ + + @Test + void rejectedRecordClearsOldNodesBeforeRestarting() { + LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0); + when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Arrays.asList( + reviewNode(90, 1, rejectedAt.minusDays(2)), + reviewNode(91, 2, rejectedAt))); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + InOrder order = inOrder(reviewListService); + order.verify(reviewListService).remove(any()); + order.verify(reviewListService).save(any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + ReviewList saved = captor.getValue(); + assertEquals(MODULE, saved.getModule()); + assertEquals(PK, saved.getPk()); + assertEquals(Integer.valueOf(7), saved.getUserId(), "新节点应落在重新解析出的第一级审批人上"); + assertEquals(Integer.valueOf(0), saved.getSortNumber()); + } + + /** 驳回后重提:重新解析审批人,不复制旧的 review_user_id。 */ + @Test + void restartReviewWhenLastRejected_resolvesReviewerAgain() { + LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0); + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 2, rejectedAt))); + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43)); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + assertEquals(Integer.valueOf(43), captor.getValue().getUserId()); + } + + @Test + void approvedRecordDoesNotRestart() { + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 1, LocalDateTime.now()))); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } + + @Test + void recordWithoutNodesIsLeftAlone() { + when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } + + @Test + void missingFlowConfigDoesNotTouchNodes() { + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 2, LocalDateTime.now()))); + when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } private ReviewList reviewNode(Integer id, Integer status, LocalDateTime createTime) { ReviewList node = new ReviewList(); @@ -76,69 +230,11 @@ class ReviewFlowStarterServiceImplTest { return flow; } - private void stubFirstFlow() { - ReviewFlowConfig config = new ReviewFlowConfig(); - config.setFlowId(3); - when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config); - when(reviewFlowService.getById(3)).thenReturn(flow("十佳青年", 1, 99)); - when(reviewFlowService.listByTitle("十佳青年")) - .thenReturn(Collections.singletonList(flow("十佳青年", 1, 7))); - } - - @Test - void rejectedRecordClearsOldNodesBeforeRestarting() { - LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0); - when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Arrays.asList( - reviewNode(90, 1, rejectedAt.minusDays(2)), - reviewNode(91, 2, rejectedAt))); - stubFirstFlow(); - - starterService.restartReviewWhenLastRejected(MODULE, PK); - - InOrder order = inOrder(reviewListService); - order.verify(reviewListService).remove(any()); - order.verify(reviewListService).save(any()); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); - verify(reviewListService).save(captor.capture()); - ReviewList saved = captor.getValue(); - assertEquals(MODULE, saved.getModule()); - assertEquals(PK, saved.getPk()); - assertEquals(Integer.valueOf(7), saved.getUserId(), "新节点应落在重新解析出的第一级审批人上"); - assertEquals(Integer.valueOf(0), saved.getSortNumber()); - } - - @Test - void approvedRecordDoesNotRestart() { - when(reviewListService.listByModuleNPK(MODULE, PK)) - .thenReturn(Collections.singletonList(reviewNode(91, 1, LocalDateTime.now()))); - - starterService.restartReviewWhenLastRejected(MODULE, PK); - - verify(reviewListService, never()).remove(any()); - verify(reviewListService, never()).save(any()); - } - - @Test - void recordWithoutNodesIsLeftAlone() { - when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); - - starterService.restartReviewWhenLastRejected(MODULE, PK); - - verify(reviewListService, never()).remove(any()); - verify(reviewListService, never()).save(any()); - } - - @Test - void missingFlowConfigDoesNotTouchNodes() { - when(reviewListService.listByModuleNPK(MODULE, PK)) - .thenReturn(Collections.singletonList(reviewNode(91, 2, LocalDateTime.now()))); - when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null); - - starterService.restartReviewWhenLastRejected(MODULE, PK); - - verify(reviewListService, never()).remove(any()); - verify(reviewListService, never()).save(any()); - assertTrue(true); + private Map recordContext() { + Map record = new HashMap<>(); + record.put("id", PK); + record.put("userId", DECLARE_USER_ID); + record.put("collegeId", COLLEGE_ID); + return record; } } diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImplTest.java new file mode 100644 index 0000000..5296350 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewerResolverImplTest.java @@ -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 容器、不连数据库)。 + * + *

覆盖规则语法与「解析不出来即报错」的约定:固定人、按学院命中、按学院空集合报错、 + * 空规则回退 review_user_id、多段规则并集后取最小 userId、学院标识缺失给可读错误。

+ * + * @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::直接返回固定人。 */ + @Test + void resolve_fixedUserRule() { + List result = reviewerResolver.resolve(flow("USER:42", null, 1), null, DECLARE_USER_ID); + + assertEquals(Collections.singletonList(42), result); + } + + /** COLLEGE_ROLE:在申报人学院的组织子树内找持该角色的人。 */ + @Test + void resolve_collegeRoleHitsUserInSubtree() { + List 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 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; + } +}