feat(oapi): GetUserMod 下发嵌套模块树,层级经 BoaModule.children 承载
build / build-push (push) Successful in 2m42s
build / build-push (push) Successful in 2m42s
模块表 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 往返证明层级确实经线格式下发且可反序列化。
This commit is contained in:
@@ -123,19 +123,24 @@ public interface IGroupAuthzService {
|
||||
List<UserGroupNodeVo> findUserGroupTree(Integer userId);
|
||||
|
||||
/**
|
||||
* 开放接口专用:查询用户在<b>指定调用方应用</b>下的已授权模块(扁平化 BoaModule 列表)。<br>
|
||||
* 开放接口专用:查询用户在<b>指定调用方应用</b>下的已授权模块(<b>嵌套 BoaModule 树</b>,层级由
|
||||
* {@link BoaModule#getChildren()} 承载,消费方无需再自行组树)。<br>
|
||||
* 与 {@link #findUserPermissionTree(Integer, Integer)} 的核心差别:<br>
|
||||
* - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);<br>
|
||||
* - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。<br>
|
||||
* <br>
|
||||
* 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。<br>
|
||||
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
|
||||
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。
|
||||
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
|
||||
* <br>
|
||||
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表;
|
||||
* <b>父模块未授权(不在本次结果集内)的子节点连同其整枝子孙一并丢弃</b>并打 warn——
|
||||
* 故授权配置须保证「授权子模块必先授权父模块」。
|
||||
*
|
||||
* @param appId 调用方应用ID(必填;查不到或该应用无开通=返回空列表)
|
||||
* @param schId 学校/组上下文(可选;为 null 时按"主学校→主归属组"规则推断)
|
||||
* @param userId 用户ID(必填)
|
||||
* @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表)
|
||||
* @return 该应用下用户有权限的模块树(顶层=根节点;无权限时返回空列表)
|
||||
*/
|
||||
List<BoaModule> findUserModulesForApp(Integer appId, Integer schId, Integer userId);
|
||||
|
||||
|
||||
@@ -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<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
|
||||
return flattenToBoaModules(allModules, appIcon);
|
||||
return assembleModuleTree(allModules, appIcon);
|
||||
}
|
||||
// 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)
|
||||
Set<Integer> roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId);
|
||||
@@ -594,7 +598,7 @@ public class GroupAuthzService implements IGroupAuthzService {
|
||||
List<AppModule> 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<Integer> 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<BoaModule> flattenToBoaModules(List<AppModule> modules, String icon) {
|
||||
/**
|
||||
* GetUserMod/oapi 模块树组装(本服务三处返回的唯一收口):扁平 AppModule → 嵌套 BoaModule 树。<br>
|
||||
* 层级由平台契约 {@link BoaModule#getChildren()} 承载,消费方(Notice/svc-hall/flow-center 等)不再各自重建子树。<br>
|
||||
* <br>
|
||||
* 语义(2026-09-15 定稿,勿随手改):<br>
|
||||
* 1. 顶层只放真根(parentId 为 null 或 0);<b>父模块不在本次已授权集合内的孤儿子节点,连同其整枝子孙一并丢弃</b>,
|
||||
* 「授权子模块必先授权父模块」由授权配置侧保证,被丢弃项统一经 {@link #warnDropped} 告警,不静默;<br>
|
||||
* 2. 兄弟节点按 {@link #moduleOrder()}(customId 升序)排序——沿用开放接口既有口径,<b>不与管理端
|
||||
* {@code buildModuleTree} 的 sortNo(空=999)+id 统一</b>(后者是权限回显树、绑 permMap,语义不同,勿复用);<br>
|
||||
* 3. 叶子节点 children 置<b>空列表</b>(非 null),使前端可无判空递归;null 只表示「本接口未展开」。<br>
|
||||
* 采用自顶向下遍历真根,故 parentId 脏数据成环的节点天然不可达(等同孤儿被丢弃),不存在无限递归。
|
||||
*/
|
||||
private static List<BoaModule> assembleModuleTree(List<AppModule> modules, String icon) {
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
modules.sort(moduleOrder());
|
||||
List<BoaModule> result = new ArrayList<>(modules.size());
|
||||
// id → 已转换节点(每个模块只 copy 一次)
|
||||
Map<Integer, BoaModule> nodeMap = new LinkedHashMap<>();
|
||||
// parentId → 兄弟桶(null 与 0 同桶,对齐 ROOT_PARENT_ID 语义)
|
||||
Map<Integer, List<AppModule>> childrenMap = new HashMap<>();
|
||||
for (AppModule m : modules) {
|
||||
result.add(toBoaModule(m, icon));
|
||||
if (m.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
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);
|
||||
}
|
||||
Comparator<AppModule> siblingOrder = moduleOrder();
|
||||
for (List<AppModule> siblings : childrenMap.values()) {
|
||||
siblings.sort(siblingOrder);
|
||||
}
|
||||
Set<Integer> emitted = new HashSet<>();
|
||||
List<BoaModule> 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<BoaModule> flattenToBoaModulesById(Set<Integer> moduleIds, String icon,
|
||||
/** 递归挂子:children 一律赋值(无子=空列表),保证前端无需判空即可递归。 */
|
||||
private static BoaModule buildModuleNode(AppModule node, Map<Integer, List<AppModule>> childrenMap,
|
||||
Map<Integer, BoaModule> nodeMap, Set<Integer> emitted) {
|
||||
BoaModule bm = nodeMap.get(node.getId());
|
||||
emitted.add(node.getId());
|
||||
List<BoaModule> children = new ArrayList<>();
|
||||
List<AppModule> 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,使「某角色/某组菜单少一项」能直接定位到授权配置缺口。<br>
|
||||
* 只告警不抛异常:丢弃本身是本接口的既定语义。
|
||||
*/
|
||||
private static void warnDropped(List<AppModule> modules, Set<Integer> 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<BoaModule> assembleModuleTreeById(Set<Integer> moduleIds, String icon,
|
||||
Map<Integer, AppModule> 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) {
|
||||
|
||||
@@ -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})模块树组装单元测试。<br>
|
||||
* 锁定 2026-09-15 定稿语义:层级经 {@code BoaModule.children} 下发;顶层只放真根;
|
||||
* <b>父未授权的孤儿子节点连同整枝子孙一并丢弃</b>;兄弟按 customId(数值优先)升序,sortNo 不参与;
|
||||
* 叶子 children 为空列表(非 null);parentId 脏数据成环不死循环。<br>
|
||||
* 多数用例走「超管短路」分支({@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<BoaModule> 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<BoaModule> 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<BoaModule> 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<BoaModule> 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<BoaModule> 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<BoaModule> roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId());
|
||||
|
||||
assertTrue(roots.isEmpty(), "应用无模块时返回空列表而非 null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 症结回归护栏:层级必须<b>经 wire 下发</b>。序列化后 JSON 里要能看到 children 嵌套,
|
||||
* 反序列化回来子节点仍可读——否则等于本次修复失效(消费方又得自己组树)。
|
||||
*/
|
||||
@Test
|
||||
void findUserModulesForApp_treeSurvivesJsonRoundTripOnTheWire() throws Exception {
|
||||
stubSuperAdmin(module(100, "100", null, 1, 10), module(101, "101", 100, 2, 10));
|
||||
|
||||
List<BoaModule> 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<BoaModule> parsed = new com.fasterxml.jackson.databind.ObjectMapper().readValue(json,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<List<BoaModule>>() {
|
||||
});
|
||||
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<Integer>) 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<BoaModule> 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<Integer>) 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<BoaModule> 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<SysGroup> 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<Integer> customIds(List<BoaModule> list) {
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
for (BoaModule m : list) {
|
||||
ids.add(Integer.parseInt(m.getCustomId()));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static <T> T only(List<T> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user