在开发权限管理系统(如 OA、RBAC 权限模型)时,用户的菜单通常是树状嵌套结构(即:目录 -> 菜单 -> 按钮,子节点存放在
children属性中)。而在登录时,后端需要将用户的所有权限标识(perms)一次性返回给前端用于做按钮级鉴权。
今天在开发时遇到一个经典 Bug:明明数据库里配了权限标识,但最终获取到的权限列表却是一片空白。 经过排查,发现是因为菜单是树形的,直接用普通的 Stream 无法穿透深层子节点。本文记录完美的解决方案。
💥 问题复现与原因分析
1. 错误的直觉写法
起初,我们很容易写出下面这种看似完美的 Java 8 Stream 代码:
// 错误示范:直接对树形菜单进行 map
List<String> permissions = nodes.stream()
.map(SysMenu::getPerms)
.filter(StringUtils::hasText)
.toList();
2. 为什么拿不到权限?
因为 nodes 是一个树状结构(Tree List):
最外层
nodes里面只包含一级菜单/目录。真正的按钮级权限(比如
sys:user:add、sys:user:delete)往往藏在二级、三级菜单的children属性里。普通的
.map(SysMenu::getPerms)只会去遍历最外层的perms,根本不会“钻”进children内部。如果你的外层目录没有配置perms,拿到的结果自然就是空的。
🛠️ 解决方案:使用 Stream 的 flatMap + 递归展平
要解决这个问题,思路非常清晰:在提取权限之前,必须先把这棵树“拍平(Flatten)”成一个一维的线性列表。
完整实现代码
在你的业务层(Service 实现类)中加入递归展平逻辑:
@Override
public UserInfoVo queryUserInfo() {
long loginUserId = StpUtil.getLoginIdAsLong();
SysUser sysUser = sysUserMapper.selectById(loginUserId);
UserInfoVo userInfoVo = new UserInfoVo();
UserBaseVo userBaseVo = new UserBaseVo();
BeanUtils.copyProperties(sysUser, userBaseVo);
userInfoVo.setUser(userBaseVo);
// 1. 查询当前用户的树状菜单信息
List<SysMenu> treeNodes = sysMenuService.querySysMenuByUserId(loginUserId);
userInfoVo.setMenus(treeNodes);
// 2. 核心:通过递归将树状菜单“拉平”,再提取权限标识
List<String> permissions = treeNodes.stream()
.flatMap(this::flattenMenu) // 递归压平所有层级的子节点
.map(SysMenu::getPerms) // 提取权限标识
.filter(StringUtils::hasText)// 过滤掉 null 或空字符串
.distinct() // 去重
.toList(); // 收集为 List
userInfoVo.setPermissions(permissions);
return userInfoVo;
}
/**
* 递归将菜单树压平成一维列表的辅助方法
*/
private Stream<SysMenu> flattenMenu(SysMenu menu) {
if (menu == null) {
return Stream.empty();
}
// 当前菜单
Stream<SysMenu> current = Stream.of(menu);
// 如果包含子节点,递归展平子节点并与当前菜单合并
if (menu.getChildren() != null && !menu.getChildren().isEmpty()) {
Stream<SysMenu> childrenStream = menu.getChildren().stream().flatMap(this::flattenMenu);
return Stream.concat(current, childrenStream);
}
return current;
}
💡 核心知识点总结
mapvsflatMap:map()用于“一对一”转换,把对象变成另一个对象。flatMap()用于“一对多”或扁平化处理,能够把嵌套的流(如树的children流)无缝展开并合并成一个一维流。
递归思想的应用:处理树状、Id-Parent 嵌套结构时,递归依然是最优雅、最通用的解法。配合 Java 8 的
Stream.concat()可以非常轻松地实现流的拼接。安全过滤:提取完成后,务必加上
.filter(StringUtils::hasText)和.distinct(),既能过滤掉脏数据、空指针,又能防止因为角色绑定重复导致权限标识重复。