From b0a02e56b03ea4544bb18e929ff62966cf52286c Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 16:20:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(oapi):=20GetUserMod=20=E4=B8=8B=E5=8F=91?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E6=A8=A1=E5=9D=97=E6=A0=91=EF=BC=8C=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E7=BB=8F=20BoaModule.children=20=E6=89=BF=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块表 app_module 是 level<=5 的树,管理端下发时带 children,但开放接口这条链 转成平台 BoaModule 时层级被拍平(且旧 SDK 契约无 children 字段可承载),导致每个 子应用各自复制一份 buildModuleTree 重建父子关系。 现按同目录 BoaGroup.children 的既有口径补齐:findUserModulesForApp 的三个返回点 (超管短路 / 应用无模块 / 正常路径)统一收口到 assembleModuleTree 建树。 语义:顶层只放真根(parentId 为空),兄弟沿用开放接口原有 customId 升序口径(不与 管理端 sortNo+id 统一),叶子 children 为空列表以便前端无判空递归;父模块不在本次已 授权集合内的孤儿子节点连同整枝子孙一并丢弃,并逐条打 warn([oapi:modules] drop orphan module)避免静默少菜单——授权配置须保证「授权子模块必先授权父模块」。 新增 GroupAuthzServiceModuleTreeTest 9 例:三级嵌套与排序、sortNo 不参与、叶子空 列表、parentId 成环不死循环、全 level-1 时与改造前扁平输出等价、无模块返空、孤儿整 枝丢弃、父子同授权则嵌套、JSON 往返证明层级确实经线格式下发且可反序列化。 --- .../userbase/service/IGroupAuthzService.java | 11 +- .../service/impl/GroupAuthzService.java | 87 ++++- .../impl/GroupAuthzServiceModuleTreeTest.java | 357 ++++++++++++++++++ 3 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index e2305ee..42a1aeb 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -123,19 +123,24 @@ public interface IGroupAuthzService { List findUserGroupTree(Integer userId); /** - * 开放接口专用:查询用户在指定调用方应用下的已授权模块(扁平化 BoaModule 列表)。
+ * 开放接口专用:查询用户在指定调用方应用下的已授权模块(嵌套 BoaModule 树,层级由 + * {@link BoaModule#getChildren()} 承载,消费方无需再自行组树)。
* 与 {@link #findUserPermissionTree(Integer, Integer)} 的核心差别:
* - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);
* - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。
*
* 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。
- * 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。 + * 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。
+ *
+ * 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表; + * 父模块未授权(不在本次结果集内)的子节点连同其整枝子孙一并丢弃并打 warn—— + * 故授权配置须保证「授权子模块必先授权父模块」。 * * @param appId 调用方应用ID(必填;查不到或该应用无开通=返回空列表) * @param schId 学校/组上下文(可选;为 null 时按"主学校→主归属组"规则推断) * @param userId 用户ID(必填) - * @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表) + * @return 该应用下用户有权限的模块树(顶层=根节点;无权限时返回空列表) */ List findUserModulesForApp(Integer appId, Integer schId, Integer userId); diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 713420f..4119f1e 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -37,6 +37,8 @@ import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.resolve.MainGroupResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; @@ -71,6 +73,8 @@ import java.util.stream.Collectors; @Service public class GroupAuthzService implements IGroupAuthzService { + private static final Logger log = LoggerFactory.getLogger(GroupAuthzService.class); + /** 虚拟父节点:根节点的 parentId,表示「无上级」(与 SysGroupService 保持一致)。 */ private static final int ROOT_PARENT_ID = 0; @@ -545,7 +549,7 @@ public class GroupAuthzService implements IGroupAuthzService { // 2. 超级管理员短路:不再返回全应用模块=仅返回该应用下全部模块(数据库过滤第二层:appId 限定) if (isSuperAdmin(user.getType(), userId)) { List allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); - return flattenToBoaModules(allModules, appIcon); + return assembleModuleTree(allModules, appIcon); } // 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用) Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId); @@ -594,7 +598,7 @@ public class GroupAuthzService implements IGroupAuthzService { List appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); if (appModules.isEmpty()) { return (roleModuleIdsInApp.isEmpty()) ? Collections.emptyList() - : flattenToBoaModulesById(roleModuleIdsInApp, appIcon, buildAppModuleMap(appModules)); + : assembleModuleTreeById(roleModuleIdsInApp, appIcon, buildAppModuleMap(appModules)); } Set appModuleIds = new LinkedHashSet<>(); for (AppModule m : appModules) { @@ -636,7 +640,7 @@ public class GroupAuthzService implements IGroupAuthzService { if (permittedModuleIds.isEmpty()) { return Collections.emptyList(); } - return flattenToBoaModulesById(permittedModuleIds, appIcon, buildAppModuleMap(appModules)); + return assembleModuleTreeById(permittedModuleIds, appIcon, buildAppModuleMap(appModules)); } /** @@ -668,19 +672,82 @@ public class GroupAuthzService implements IGroupAuthzService { return map; } - private static List flattenToBoaModules(List modules, String icon) { + /** + * GetUserMod/oapi 模块树组装(本服务三处返回的唯一收口):扁平 AppModule → 嵌套 BoaModule 树。
+ * 层级由平台契约 {@link BoaModule#getChildren()} 承载,消费方(Notice/svc-hall/flow-center 等)不再各自重建子树。
+ *
+ * 语义(2026-09-15 定稿,勿随手改):
+ * 1. 顶层只放真根(parentId 为 null 或 0);父模块不在本次已授权集合内的孤儿子节点,连同其整枝子孙一并丢弃, + * 「授权子模块必先授权父模块」由授权配置侧保证,被丢弃项统一经 {@link #warnDropped} 告警,不静默;
+ * 2. 兄弟节点按 {@link #moduleOrder()}(customId 升序)排序——沿用开放接口既有口径,不与管理端 + * {@code buildModuleTree} 的 sortNo(空=999)+id 统一(后者是权限回显树、绑 permMap,语义不同,勿复用);
+ * 3. 叶子节点 children 置空列表(非 null),使前端可无判空递归;null 只表示「本接口未展开」。
+ * 采用自顶向下遍历真根,故 parentId 脏数据成环的节点天然不可达(等同孤儿被丢弃),不存在无限递归。 + */ + private static List assembleModuleTree(List modules, String icon) { if (modules == null || modules.isEmpty()) { return Collections.emptyList(); } - modules.sort(moduleOrder()); - List result = new ArrayList<>(modules.size()); + // id → 已转换节点(每个模块只 copy 一次) + Map nodeMap = new LinkedHashMap<>(); + // parentId → 兄弟桶(null 与 0 同桶,对齐 ROOT_PARENT_ID 语义) + Map> childrenMap = new HashMap<>(); for (AppModule m : modules) { - result.add(toBoaModule(m, icon)); + if (m.getId() == null) { + continue; + } + nodeMap.put(m.getId(), toBoaModule(m, icon)); + Integer parentKey = (m.getParentId() == null) ? ROOT_PARENT_ID : m.getParentId(); + childrenMap.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(m); } - return result; + Comparator siblingOrder = moduleOrder(); + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + Set emitted = new HashSet<>(); + List roots = new ArrayList<>(); + for (AppModule root : childrenMap.getOrDefault(ROOT_PARENT_ID, Collections.emptyList())) { + roots.add(buildModuleNode(root, childrenMap, nodeMap, emitted)); + } + warnDropped(modules, emitted); + return roots; } - private static List flattenToBoaModulesById(Set moduleIds, String icon, + /** 递归挂子:children 一律赋值(无子=空列表),保证前端无需判空即可递归。 */ + private static BoaModule buildModuleNode(AppModule node, Map> childrenMap, + Map nodeMap, Set emitted) { + BoaModule bm = nodeMap.get(node.getId()); + emitted.add(node.getId()); + List children = new ArrayList<>(); + List subs = childrenMap.get(node.getId()); + if (subs != null) { + for (AppModule sub : subs) { + // 同一 id 只挂一次,防脏数据重复行让同一实例被挂两处形成环 + if (sub.getId() != null && !emitted.contains(sub.getId())) { + children.add(buildModuleNode(sub, childrenMap, nodeMap, emitted)); + } + } + } + bm.setChildren(children); + return bm; + } + + /** + * 对被丢弃(父未授权 → 自根不可达)的模块各打一条 warn,使「某角色/某组菜单少一项」能直接定位到授权配置缺口。
+ * 只告警不抛异常:丢弃本身是本接口的既定语义。 + */ + private static void warnDropped(List modules, Set emitted) { + for (AppModule m : modules) { + if (m.getId() == null || emitted.contains(m.getId())) { + continue; + } + log.warn("[oapi:modules] drop orphan module id={} customId={} name={} parentId={} appId={}" + + "(父模块不在本次已授权集合内,整枝未下发;请在角色/组授权中一并勾选其父模块)", + m.getId(), m.getCustomId(), m.getName(), m.getParentId(), m.getAppId()); + } + } + + private static List assembleModuleTreeById(Set moduleIds, String icon, Map moduleMap) { if (moduleIds == null || moduleIds.isEmpty()) { return Collections.emptyList(); @@ -692,7 +759,7 @@ public class GroupAuthzService implements IGroupAuthzService { list.add(m); } } - return flattenToBoaModules(list, icon); + return assembleModuleTree(list, icon); } private static BoaModule toBoaModule(AppModule m, String icon) { diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java new file mode 100644 index 0000000..d87b3c7 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java @@ -0,0 +1,357 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysGroupAppRel; +import com.yunhai.userbase.entity.SysGroupModulePerm; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.model.GroupSnapshot; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.IApplicationInfoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +/** + * GetUserMod({@code findUserModulesForApp})模块树组装单元测试。
+ * 锁定 2026-09-15 定稿语义:层级经 {@code BoaModule.children} 下发;顶层只放真根; + * 父未授权的孤儿子节点连同整枝子孙一并丢弃;兄弟按 customId(数值优先)升序,sortNo 不参与; + * 叶子 children 为空列表(非 null);parentId 脏数据成环不死循环。
+ * 多数用例走「超管短路」分支({@code GroupAuthzService:546}),它与正常权限分支共用同一个建树收口 + * {@code assembleModuleTree},故可最小化桩;另有一例走真实组授权分支验证丢弃语义。 + */ +class GroupAuthzServiceModuleTreeTest { + + private static final int APP_ID = 100003; + private static final String APP_ICON = "/icon/notice.png"; + + @InjectMocks + private GroupAuthzService groupAuthzService; + + @Mock + private GroupSnapshotService groupSnapshotService; + @Mock + private SysGroupAppRelRepository sysGroupAppRelRepository; + @Mock + private SysGroupModulePermRepository sysGroupModulePermRepository; + @Mock + private SysGroupRepository sysGroupRepository; + @Mock + private ApplicationInfoRepository applicationInfoRepository; + @Mock + private IApplicationInfoService applicationInfoService; + @Mock + private AppCategoryRepository appCategoryRepository; + @Mock + private AppModuleRepository appModuleRepository; + @Mock + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Mock + private SysUserRepository sysUserRepository; + @Mock + private SysAppRoleRepository sysAppRoleRepository; + @Mock + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + @Mock + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private com.yunhai.userbase.service.resolve.MainGroupResolver mainGroupResolver; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ApplicationInfo app = new ApplicationInfo(); + app.setId(APP_ID); + app.setName("通知公告"); + app.setPcIcon(APP_ICON); + when(applicationInfoService.getById(APP_ID)).thenReturn(app); + } + + // ===================== 超管短路:纯建树逻辑 ===================== + + @Test + void findUserModulesForApp_whenThreeLevels_shouldNestAndOrderByCustomIdNumerically() { + // 树:100(工作台) > 101(我的办件) > 102(在办) ;100 另有兄弟 200(设置) + AppModule r1 = module(100, "100", null, 1, 900); + AppModule c1 = module(101, "101", 100, 2, 10); + AppModule g1 = module(102, "102", 101, 3, 10); + AppModule r2 = module(200, "200", null, 1, 10); + stubSuperAdmin(r1, c1, g1, r2); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + assertEquals(2, roots.size(), "顶层只应有两个真根"); + assertEquals("100", roots.get(0).getCustomId(), "customId 数值序:100 在 200 前"); + assertEquals(APP_ICON, roots.get(0).getIcon(), "icon 仍按应用级下发到每个节点"); + + BoaModule child = only(roots.get(0).getChildren()); + assertEquals(101, child.getId()); + BoaModule grand = only(child.getChildren()); + assertEquals(102, grand.getId(), "三级层级应完整保留"); + assertEquals("3", String.valueOf(grand.getLevel())); + } + + @Test + void findUserModulesForApp_whenSortNoContradictsCustomId_shouldStillOrderByCustomId() { + // 故意让 sortNo 与 customId 反序:证明开放接口不采用管理端的 sortNo(空=999)+id 口径 + AppModule a = module(11, "2", null, 1, 1); + AppModule b = module(12, "10", null, 1, 999); + stubSuperAdmin(a, b); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + assertEquals("2", roots.get(0).getCustomId(), "兄弟序按 customId 数值升序,sortNo 不参与"); + assertEquals("10", roots.get(1).getCustomId()); + } + + @Test + void findUserModulesForApp_whenLeaf_shouldEmitEmptyChildrenListNotNull() { + stubSuperAdmin(module(100, "100", null, 1, 10)); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + BoaModule leaf = only(roots); + assertNotNull(leaf.getChildren(), "叶子 children 不得为 null,前端需无判空递归"); + assertTrue(leaf.getChildren().isEmpty(), "叶子 children 应为空列表"); + } + + @Test + void findUserModulesForApp_whenParentIdCycle_shouldNotHangAndDropCycleBranch() { + AppModule root = module(100, "100", null, 1, 10); + AppModule a = module(201, "201", 202, 2, 10); // a 的父是 b + AppModule b = module(202, "202", 201, 2, 10); // b 的父是 a → 成环,自根不可达 + AppModule self = module(203, "203", 203, 2, 10); // 自环 + stubSuperAdmin(root, a, b, self); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + assertEquals(1, roots.size(), "成环/自环节点整体不下发,只剩真根"); + assertEquals(100, only(roots).getId()); + } + + @Test + void findUserModulesForApp_whenOnlyLevelOne_shouldKeepLegacyFlatSetAndOrder() { + // 回归护栏:全 level-1(现网常态)时,顶层集合与顺序应与改造前的扁平输出一致,仅多出空 children + AppModule m1 = module(5, "1005", null, 1, 50); + AppModule m2 = module(6, "1006", null, 1, 10); + stubSuperAdmin(m1, m2); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + assertEquals(Arrays.asList(1005, 1006), customIds(roots), "全扁平模块时下发顺序=customId 升序,等价旧行为"); + for (BoaModule m : roots) { + assertTrue(m.getChildren().isEmpty()); + } + } + + @Test + void findUserModulesForApp_whenAppHasNoModule_shouldReturnEmptyList() { + stubSuperAdmin(); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + assertTrue(roots.isEmpty(), "应用无模块时返回空列表而非 null"); + } + + /** + * 症结回归护栏:层级必须经 wire 下发。序列化后 JSON 里要能看到 children 嵌套, + * 反序列化回来子节点仍可读——否则等于本次修复失效(消费方又得自己组树)。 + */ + @Test + void findUserModulesForApp_treeSurvivesJsonRoundTripOnTheWire() throws Exception { + stubSuperAdmin(module(100, "100", null, 1, 10), module(101, "101", 100, 2, 10)); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId()); + + String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(roots); + assertTrue(json.contains("\"children\":["), "JSON 里必须带 children 嵌套,实际=" + json); + assertTrue(json.indexOf("\"customId\":\"101\"") > json.indexOf("\"customId\":\"100\""), + "子节点应嵌在父节点内部(出现在父的 children 里)"); + + List parsed = new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, + new com.fasterxml.jackson.core.type.TypeReference>() { + }); + assertEquals(1, parsed.size(), "反序列化后顶层只应有父节点"); + assertEquals("101", only(parsed.get(0).getChildren()).getCustomId(), "反序列化后子节点仍可读出"); + } + + // ===================== 真实权限分支:孤儿丢弃 ===================== + + @Test + void findUserModulesForApp_whenChildGrantedButParentNot_shouldDropWholeBranch() { + // 云海中学(16) 归属用户;应用 100003 已开通 + SysUser normal = user(2, UserTypeEnum.NORMAL); + when(sysUserRepository.findById(2)).thenReturn(Optional.of(normal)); + when(sysUserGroupRelRepository.findByUserId(2)) + .thenReturn(Collections.singletonList(rel(2, 16))); + initGroups(); + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> + ((List) inv.getArgument(0)).contains(16) + ? Collections.singletonList(appRel(16, APP_ID)) : Collections.emptyList()); + when(sysUserAppRoleRelRepository.findByUserId(2)).thenReturn(Collections.emptyList()); + + // 模块:父 100 → 子 101 → 孙 102;另有独立根 200 + AppModule parent = module(100, "100", null, 1, 10); + AppModule child = module(101, "101", 100, 2, 10); + AppModule grand = module(102, "102", 101, 3, 10); + AppModule otherRoot = module(200, "200", null, 1, 20); + when(appModuleRepository.findByAppIdIn(anyList())) + .thenReturn(new ArrayList<>(Arrays.asList(parent, child, grand, otherRoot))); + // 关键:只授权了子 101/孙 102 与根 200,父 100 未授权 + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList(perm(16, 101), perm(16, 102), perm(16, 200))); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, 16, 2); + + assertEquals(1, roots.size(), "父未授权 → 子与孙整枝丢弃,只剩已授权的独立根"); + assertEquals("200", roots.get(0).getCustomId()); + assertTrue(roots.get(0).getChildren().isEmpty()); + } + + @Test + void findUserModulesForApp_whenParentAndChildBothGranted_shouldNestChildUnderParent() { + SysUser normal = user(3, UserTypeEnum.NORMAL); + when(sysUserRepository.findById(3)).thenReturn(Optional.of(normal)); + when(sysUserGroupRelRepository.findByUserId(3)) + .thenReturn(Collections.singletonList(rel(3, 16))); + initGroups(); + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> + ((List) inv.getArgument(0)).contains(16) + ? Collections.singletonList(appRel(16, APP_ID)) : Collections.emptyList()); + when(sysUserAppRoleRelRepository.findByUserId(3)).thenReturn(Collections.emptyList()); + + AppModule parent = module(100, "100", null, 1, 10); + AppModule child = module(101, "101", 100, 2, 10); + when(appModuleRepository.findByAppIdIn(anyList())) + .thenReturn(new ArrayList<>(Arrays.asList(parent, child))); + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList(perm(16, 100), perm(16, 101))); + + List roots = groupAuthzService.findUserModulesForApp(APP_ID, 16, 3); + + assertEquals(1, roots.size(), "父子均授权时子应挂在父下而非浮到顶层"); + assertEquals("100", roots.get(0).getCustomId()); + assertEquals("101", only(roots.get(0).getChildren()).getCustomId()); + } + + // ===================== 夹具 ===================== + + /** 超管短路:只需 app 存在 + 用户为超管 + 该应用下模块清单。 */ + private void stubSuperAdmin(AppModule... modules) { + int userId = superAdminId(); + when(sysUserRepository.findById(userId)).thenReturn(Optional.of(user(userId, UserTypeEnum.SUPER_ADMIN))); + when(appModuleRepository.findByAppIdIn(anyList())) + .thenReturn(new ArrayList<>(Arrays.asList(modules))); + } + + private int superAdminId() { + return com.yunhai.userbase.utils.DicUtil.SUPER_ADMIN_ID; + } + + /** 固定组层级:5 教育云中心 → 27 信阳教育局 → 16 云海中学。 */ + private void initGroups() { + List groups = Arrays.asList( + group(5, "教育云中心", 0, GroupTypeEnum.NORMAL_GROUP), + group(27, "信阳教育局", 5, GroupTypeEnum.AREA_GROUP), + group(16, "云海中学", 27, GroupTypeEnum.SCHOOL_GROUP)); + when(groupSnapshotService.snapshot()).thenReturn(new GroupSnapshot(groups)); + } + + private static List customIds(List list) { + List ids = new ArrayList<>(); + for (BoaModule m : list) { + ids.add(Integer.parseInt(m.getCustomId())); + } + return ids; + } + + private static T only(List list) { + assertEquals(1, list.size(), "断言前置:列表应恰好 1 个元素"); + return list.get(0); + } + + private static SysGroup group(Integer id, String name, Integer parentId, GroupTypeEnum type) { + SysGroup g = new SysGroup(); + g.setId(id); + g.setName(name); + g.setParentId(parentId); + g.setType(type); + return g; + } + + private static SysUser user(Integer id, UserTypeEnum type) { + SysUser u = new SysUser(); + u.setId(id); + u.setType(type); + u.setAccount("u" + id); + u.setRealName("用户" + id); + return u; + } + + private static SysUserGroupRel rel(Integer userId, Integer groupId) { + SysUserGroupRel r = new SysUserGroupRel(); + r.setUserId(userId); + r.setGroupId(groupId); + r.setIsMain(1); + return r; + } + + private static SysGroupAppRel appRel(Integer groupId, Integer appId) { + SysGroupAppRel r = new SysGroupAppRel(); + r.setGroupId(groupId); + r.setAppId(appId); + return r; + } + + private static SysGroupModulePerm perm(Integer groupId, Integer moduleId) { + SysGroupModulePerm p = new SysGroupModulePerm(); + p.setGroupId(groupId); + p.setModuleId(moduleId); + p.setInheritFlag(Boolean.FALSE); + return p; + } + + private static AppModule module(Integer id, String customId, Integer parentId, int level, Integer sortNo) { + AppModule m = new AppModule(); + m.setId(id); + m.setAppId(APP_ID); + m.setName("模块" + customId); + m.setCustomId(customId); + m.setParentId(parentId); + m.setLevel(level); + m.setSortNo(sortNo); + return m; + } +}