fix(菜单权限): 后台侧边栏为部分授权用户补全祖先分组(二级菜单不再平铺)

只勾选分组内子页面、未勾选分组本身时,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 个测试:祖先补全含多层链、树组装还原分组与组内顺序、默认出口不含祖先。
This commit is contained in:
2026-09-16 09:58:33 +08:00
parent 8fcceb3226
commit f566573fce
3 changed files with 130 additions and 2 deletions
@@ -76,8 +76,10 @@ public class AdminConsoleController {
@Operation(summary = "当前登录管理员的可见后台菜单树") @Operation(summary = "当前登录管理员的可见后台菜单树")
public ApiResponse<Map<String, Object>> currentUserMenus(HttpServletRequest request) { public ApiResponse<Map<String, Object>> currentUserMenus(HttpServletRequest request) {
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
// 补全祖先分组:部分授权用户(只授权了子页面)也要看到「一级分组 + 子页面」层级,
// 与超管的菜单组织顺序一致;分组节点无页面路由,不构成权限扩展。
List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions( List<PermissionMenuItemVo> 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); menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN);
List<Map<String, Object>> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus)); List<Map<String, Object>> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus));
return ApiResponse.success(Map.of("items", items)); return ApiResponse.success(Map.of("items", items));
@@ -262,6 +262,20 @@ public class PermissionMenuService {
public List<PermissionMenuItemVo> getUserColumnPermissions(AdminUserEntity operator, public List<PermissionMenuItemVo> getUserColumnPermissions(AdminUserEntity operator,
Long userId, Long userId,
String menuType) { String menuType) {
return getUserColumnPermissions(operator, userId, menuType, false);
}
/**
* Controller-facing variant with target-user scope checks.
*
* <p>includeAncestorGroups=true 时额外把「有可见后代的祖先节点」并入返回集合,仅用于
* 后台侧边栏还原二级分组层级(分组自身无页面路由,不代表授权扩展);权限判定与客户端
* 「组键命中即整组放行」的权限键集合语义都不受影响,默认 false 保持原行为。</p>
*/
public List<PermissionMenuItemVo> getUserColumnPermissions(AdminUserEntity operator,
Long userId,
String menuType,
boolean includeAncestorGroups) {
AdminUserEntity user = getUserById(userId); AdminUserEntity user = getUserById(userId);
ensureTargetAccessible(operator, user); ensureTargetAccessible(operator, user);
List<PermissionMenuEntity> menus = loadMenus(menuType); List<PermissionMenuEntity> menus = loadMenus(menuType);
@@ -273,8 +287,9 @@ public class PermissionMenuService {
return List.of(); return List.of();
} }
Set<Long> effectiveIds = expandDescendantIds(directIds, menus); Set<Long> effectiveIds = expandDescendantIds(directIds, menus);
Set<Long> visibleIds = includeAncestorGroups ? includeAncestorIds(effectiveIds, menus) : effectiveIds;
List<PermissionMenuEntity> effectiveMenus = menus.stream() List<PermissionMenuEntity> effectiveMenus = menus.stream()
.filter(menu -> menu.getId() != null && effectiveIds.contains(menu.getId())) .filter(menu -> menu.getId() != null && visibleIds.contains(menu.getId()))
.toList(); .toList();
return toItemVos(effectiveMenus, menus); return toItemVos(effectiveMenus, menus);
} }
@@ -724,6 +739,44 @@ public class PermissionMenuService {
return effective; return effective;
} }
/**
* 把可见节点沿 parentId 链上的祖先并入集合,仅供后台侧边栏还原「一级分组 + 子页面」
* 的展示层级:分组节点无页面路由、不代表授权扩展,权限判定另有独立方法。父链缺失
* (孤儿数据)时截断;出现自环/成环时由 visited 兜底终止。
*/
private Set<Long> includeAncestorIds(Set<Long> effectiveIds, List<PermissionMenuEntity> menus) {
Map<Long, PermissionMenuEntity> menuMap = new HashMap<>();
for (PermissionMenuEntity menu : menus) {
if (menu.getId() != null) {
menuMap.put(menu.getId(), menu);
}
}
Set<Long> withAncestors = new LinkedHashSet<>(effectiveIds);
int addedCount = 0;
for (Long id : effectiveIds) {
PermissionMenuEntity current = menuMap.get(id);
Set<Long> 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) { private PermissionMenuEntity getMenuById(Long id) {
PermissionMenuEntity entity = permissionMenuMapper.selectById(id); PermissionMenuEntity entity = permissionMenuMapper.selectById(id);
if (entity == null) { if (entity == null) {
@@ -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.common.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity; import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity; 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.ImageVideoDataPermissionUserVo;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo; import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo;
@@ -169,6 +170,78 @@ class PermissionMenuServiceTest {
assertThat(effective.getFirst().getRootColumnKey()).isEqualTo("brand_front_tools"); 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<PermissionMenuItemVo> sidebarMenus = service.getUserColumnPermissions(null, 9L, "admin", true);
// 分组节点随可见子菜单一并返回,子页面不丢
assertThat(sidebarMenus).extracting(PermissionMenuItemVo::getId)
.containsExactlyInAnyOrder(98L, 7L, 99L, 5L);
// 组装成树后还原「一级分组 + 子页面」层级(与超管视图同一顺序:组间按分组 sort)
List<AdminMenuTreeBuilder.Node> 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<PermissionMenuItemVo> 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<PermissionMenuItemVo> menus = service.getUserColumnPermissions(null, 9L, "admin", true);
assertThat(menus).extracting(PermissionMenuItemVo::getId).containsExactly(1L, 2L, 3L);
}
@Test @Test
void menuCatalogIncludesRootKeyFromLoadedHierarchy() { void menuCatalogIncludesRootKeyFromLoadedHierarchy() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);