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:
+3
-1
@@ -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));
|
||||||
|
|||||||
+54
-1
@@ -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) {
|
||||||
|
|||||||
+73
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user