From f566573fce54b802491ed6637e270caec53c07cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 16 Sep 2026 09:58:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=8F=9C=E5=8D=95=E6=9D=83=E9=99=90):=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=B8=BA=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=8E=88=E6=9D=83=E7=94=A8=E6=88=B7=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E7=A5=96=E5=85=88=E5=88=86=E7=BB=84=EF=BC=88=E4=BA=8C=E7=BA=A7?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E4=B8=8D=E5=86=8D=E5=B9=B3=E9=93=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只勾选分组内子页面、未勾选分组本身时,getUserColumnPermissions 只返回 直接授权+展开后代,分组容器节点缺失,AdminMenuTreeBuilder 把子页面当根节点, 前端侧边栏渲染成一列平铺;且子页面 sort_order 是全局值、跨分组穿插显示, 与超管的「分组+组内顺序」视图完全对不上(线上 31 个账号如此,含 uid=972 阿武)。 修复:getUserColumnPermissions 增加 includeAncestorGroups 重载(默认 false), true 时沿 parentId 链把有可见后代的祖先并入集合——分组无页面路由,仅还原 展示层级,不构成授权扩展;只有 current-user/menus(后台侧边栏)启用。 刻意保持原语义的出口:/permission-users/{id}/column-permissions 与登录响应 (桌面端 tool-catalog「组键命中即整组放行」,补组键会误放行整组工具)、 dedupe/invalidasin 的精确 key 校验。 新增 3 个测试:祖先补全含多层链、树组装还原分组与组内顺序、默认出口不含祖先。 --- .../controller/AdminConsoleController.java | 4 +- .../service/PermissionMenuService.java | 55 +++++++++++++- .../service/PermissionMenuServiceTest.java | 73 +++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java index f536a59a..c497bbe7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java @@ -76,8 +76,10 @@ public class AdminConsoleController { @Operation(summary = "当前登录管理员的可见后台菜单树") public ApiResponse> currentUserMenus(HttpServletRequest request) { AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + // 补全祖先分组:部分授权用户(只授权了子页面)也要看到「一级分组 + 子页面」层级, + // 与超管的菜单组织顺序一致;分组节点无页面路由,不构成权限扩展。 List menus = permissionMenuService.getUserColumnPermissions( - operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN); + operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN, true); menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN); List> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus)); return ApiResponse.success(Map.of("items", items)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 8d32e195..193439be 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -262,6 +262,20 @@ public class PermissionMenuService { public List getUserColumnPermissions(AdminUserEntity operator, Long userId, String menuType) { + return getUserColumnPermissions(operator, userId, menuType, false); + } + + /** + * Controller-facing variant with target-user scope checks. + * + *

includeAncestorGroups=true 时额外把「有可见后代的祖先节点」并入返回集合,仅用于 + * 后台侧边栏还原二级分组层级(分组自身无页面路由,不代表授权扩展);权限判定与客户端 + * 「组键命中即整组放行」的权限键集合语义都不受影响,默认 false 保持原行为。

+ */ + public List getUserColumnPermissions(AdminUserEntity operator, + Long userId, + String menuType, + boolean includeAncestorGroups) { AdminUserEntity user = getUserById(userId); ensureTargetAccessible(operator, user); List menus = loadMenus(menuType); @@ -273,8 +287,9 @@ public class PermissionMenuService { return List.of(); } Set effectiveIds = expandDescendantIds(directIds, menus); + Set visibleIds = includeAncestorGroups ? includeAncestorIds(effectiveIds, menus) : effectiveIds; List effectiveMenus = menus.stream() - .filter(menu -> menu.getId() != null && effectiveIds.contains(menu.getId())) + .filter(menu -> menu.getId() != null && visibleIds.contains(menu.getId())) .toList(); return toItemVos(effectiveMenus, menus); } @@ -724,6 +739,44 @@ public class PermissionMenuService { return effective; } + /** + * 把可见节点沿 parentId 链上的祖先并入集合,仅供后台侧边栏还原「一级分组 + 子页面」 + * 的展示层级:分组节点无页面路由、不代表授权扩展,权限判定另有独立方法。父链缺失 + * (孤儿数据)时截断;出现自环/成环时由 visited 兜底终止。 + */ + private Set includeAncestorIds(Set effectiveIds, List menus) { + Map menuMap = new HashMap<>(); + for (PermissionMenuEntity menu : menus) { + if (menu.getId() != null) { + menuMap.put(menu.getId(), menu); + } + } + Set withAncestors = new LinkedHashSet<>(effectiveIds); + int addedCount = 0; + for (Long id : effectiveIds) { + PermissionMenuEntity current = menuMap.get(id); + Set visited = new HashSet<>(); + while (current != null && current.getId() != null && visited.add(current.getId())) { + Long parentId = current.getParentId(); + if (parentId == null) { + break; + } + PermissionMenuEntity parent = menuMap.get(parentId); + if (parent == null) { + break; + } + if (withAncestors.add(parent.getId())) { + addedCount++; + } + current = parent; + } + } + if (addedCount > 0) { + log.debug("[menu-tree-ancestor] 侧边栏树补充祖先分组节点 {} 个(仅展示层级,不改变授权)", addedCount); + } + return withAncestors; + } + private PermissionMenuEntity getMenuById(Long id) { PermissionMenuEntity entity = permissionMenuMapper.selectById(id); if (entity == null) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index 96e9380d..587a167e 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -10,6 +10,7 @@ import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdate import com.nanri.aiimage.common.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity; import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity; +import com.nanri.aiimage.modules.permission.service.support.AdminMenuTreeBuilder; import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo; import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo; @@ -169,6 +170,78 @@ class PermissionMenuServiceTest { assertThat(effective.getFirst().getRootColumnKey()).isEqualTo("brand_front_tools"); } + @Test + void adminSidebarTreeKeepsAncestorGroupWhenOnlyChildGranted() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + when(userMapper.selectById(9L)).thenReturn(user(9L, "admin", 0)); + when(permissionMapper.selectByUserId(any())).thenReturn(List.of(grant(9L, 7L), grant(9L, 5L))); + PermissionMenuEntity accountGroup = menu(98L, null, "admin", 5); + accountGroup.setColumnKey("admin_group_account"); + accountGroup.setRoutePath("group-account"); + PermissionMenuEntity accountChild = menu(7L, 98L, "admin", 6); + PermissionMenuEntity dataGroup = menu(99L, null, "admin", 15); + dataGroup.setColumnKey("admin_group_data"); + dataGroup.setRoutePath("group-data"); + PermissionMenuEntity dataChild = menu(5L, 99L, "admin", 4); + when(menuMapper.selectList(any())).thenReturn(List.of(accountGroup, accountChild, dataGroup, dataChild)); + + List sidebarMenus = service.getUserColumnPermissions(null, 9L, "admin", true); + + // 分组节点随可见子菜单一并返回,子页面不丢 + assertThat(sidebarMenus).extracting(PermissionMenuItemVo::getId) + .containsExactlyInAnyOrder(98L, 7L, 99L, 5L); + // 组装成树后还原「一级分组 + 子页面」层级(与超管视图同一顺序:组间按分组 sort) + List tree = AdminMenuTreeBuilder.build(sidebarMenus); + assertThat(tree).extracting(AdminMenuTreeBuilder.Node::key) + .containsExactly("admin_group_account", "admin_group_data"); + assertThat(tree.get(0).children()).extracting(AdminMenuTreeBuilder.Node::key) + .containsExactly("menu-7"); + assertThat(tree.get(1).children()).extracting(AdminMenuTreeBuilder.Node::key) + .containsExactly("menu-5"); + } + + @Test + void permissionKeyConsumersDoNotReceiveAncestorGroups() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + when(userMapper.selectById(9L)).thenReturn(user(9L, "admin", 0)); + when(permissionMapper.selectByUserId(any())).thenReturn(List.of(grant(9L, 7L))); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(98L, null, "admin", 5), + menu(7L, 98L, "admin", 6))); + + List keys = service.getUserColumnPermissions(null, 9L, "admin"); + + // 默认(客户端「组键命中即整组放行」的权限键集合语义):不含祖先分组 + assertThat(keys).extracting(PermissionMenuItemVo::getId).containsExactly(7L); + } + + @Test + void adminSidebarTreeIncludesFullAncestorChainForDeepLeafGrant() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0)); + when(permissionMapper.selectByUserId(any())).thenReturn(List.of(grant(9L, 3L))); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(1L, null, "admin", 1), + menu(2L, 1L, "admin", 2), + menu(3L, 2L, "admin", 3))); + + List menus = service.getUserColumnPermissions(null, 9L, "admin", true); + + assertThat(menus).extracting(PermissionMenuItemVo::getId).containsExactly(1L, 2L, 3L); + } + @Test void menuCatalogIncludesRootKeyFromLoadedHierarchy() { PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);