大家好,前面我们讲了动态菜单权限管理的前端实现,今天我们来基于Spring Boot实现后端的功能。
为什么需要动态菜单?
场景:公司里有三种员工:
- 管理员:需要管理用户、角色、系统设置等所有功能
- 内容编辑:只需要管理文章、分类等内容相关功能
- 普通员工:只能查看数据报表,不能进行任何修改
如果为每个角色开发不同的系统界面,工作量巨大且难以维护。动态菜单就是为了解决这个问题------一套系统,根据用户角色显示不同的菜单。
一、数据库设计
核心表结构设计
我们的权限系统需要5张核心表:
sql
-- 用户表:存储系统用户信息
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`nickname` varchar(50) NOT NULL COMMENT '昵称',
`avatar` varchar(200) DEFAULT NULL COMMENT '头像',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:0-禁用,1-启用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='用户表';
-- 角色表:定义系统中的角色
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_code` varchar(50) NOT NULL COMMENT '角色编码',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`description` varchar(200) DEFAULT NULL COMMENT '角色描述',
`status` tinyint(1) DEFAULT '1' COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='角色表';
-- 菜单表:系统的所有菜单项
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`parent_id` bigint(20) DEFAULT '0' COMMENT '父菜单ID,0表示根菜单',
`menu_name` varchar(50) NOT NULL COMMENT '菜单名称',
`menu_icon` varchar(50) DEFAULT NULL COMMENT '菜单图标',
`menu_type` tinyint(1) NOT NULL COMMENT '菜单类型:1-目录,2-菜单',
`route_path` varchar(200) DEFAULT NULL COMMENT '路由路径',
`sort_order` int(11) DEFAULT '0' COMMENT '排序号',
`status` tinyint(1) DEFAULT '1' COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='菜单表';
-- 角色菜单关联表:角色拥有哪些菜单权限
CREATE TABLE `sys_role_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='角色菜单关联表';
-- 用户角色关联表:用户属于哪些角色
CREATE TABLE `sys_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='用户角色关联表';
表关系图解
scss
用户表 (sys_user)
↑
用户角色表 (sys_user_role) -- 多对多关系
↑
角色表 (sys_role)
↑
角色菜单表 (sys_role_menu) -- 多对多关系
↑
菜单表 (sys_menu) -- 树形结构
初始化测试数据
sql
-- 1. 添加三种角色
INSERT INTO `sys_role` (`role_code`, `role_name`, `description`) VALUES
('admin', '管理员', '系统管理员,拥有所有权限'),
('editor', '编辑者', '内容编辑人员,可以管理内容'),
('viewer', '查看者', '数据查看人员,只能浏览数据');
-- 2. 添加菜单数据
INSERT INTO `sys_menu` (`parent_id`, `menu_name`, `menu_icon`, `menu_type`, `route_path`, `sort_order`) VALUES
(0, '仪表板', 'DataBoard', 2, '/dashboard', 1),
(0, '系统管理', 'Setting', 1, '/system', 100),
(2, '用户管理', 'User', 2, '/system/user', 1),
(2, '角色管理', 'Lock', 2, '/system/role', 2),
(2, '菜单管理', 'Menu', 2, '/system/menu', 3),
(0, '内容管理', 'Document', 1, '/content', 2),
(6, '文章管理', 'Document', 2, '/content/article', 1),
(6, '分类管理', 'Collection', 2, '/content/category', 2),
(0, '数据统计', 'DataAnalysis', 1, '/statistics', 3),
(9, '访问统计', 'TrendCharts', 2, '/statistics/visit', 1);
-- 3. 创建管理员用户
INSERT INTO `sys_user` (`username`, `password`, `nickname`) VALUES
('admin', '123456', '系统管理员');
-- 4. 设置权限关系
-- 管理员拥有所有菜单权限
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (1, 1),(1, 2),(1, 3),(1, 4),(1, 5),(1, 6),(1, 7),(1, 8),(1, 9),(1, 10);
-- 编辑者拥有部分权限
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (2, 1),(2, 6),(2, 7),(2, 8);
-- 查看者只有仪表板权限
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (3, 1);
-- 设置用户角色
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1);
二、Spring Boot后端实现
项目结构规划
bash
src/main/java/com/example/dynamicmenu/
├── config/ # 配置类
├── controller/ # 控制器 - 处理HTTP请求
├── entity/ # 实体类 - 数据库表映射
├── dto/ # 数据传输对象 - 接收前端数据
├── vo/ # 视图对象 - 返回给前端的数据
├── mapper/ # 数据访问层
├── service/ # 业务逻辑层
│ └── impl/ # 服务实现类
└── common/ # 通用工具类
核心实体类设计
菜单实体类 - Menu.java
java
@Data
@TableName("sys_menu")
public class Menu {
@TableId(type = IdType.AUTO)
private Long id;
private Long parentId; // 父菜单ID
private String menuName; // 菜单名称
private String menuIcon; // 菜单图标
private Integer menuType; // 菜单类型
private String routePath; // 路由路径
private Integer sortOrder; // 排序号
private Integer status; // 状态
@TableField(exist = false) // 非数据库字段
private List<Menu> children; // 子菜单列表
}
角色实体类 - Role.java
java
@Data
@TableName("sys_role")
public class Role {
@TableId(type = IdType.AUTO)
private Long id;
private String roleCode; // 角色编码
private String roleName; // 角色名称
private String description; // 角色描述
private Integer status;
}
数据传输对象设计
登录请求DTO - LoginDTO.java
java
@Data
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
菜单视图对象 - MenuVO.java
java
@Data
public class MenuVO {
private Long id;
private Long parentId;
private String name; // 菜单名称
private String icon; // 菜单图标
private String route; // 路由路径
private List<String> roles; // 可访问的角色
private List<MenuVO> children; // 子菜单
}
核心业务逻辑实现
菜单服务类 - MenuServiceImpl.java
java
@Service
public class MenuServiceImpl implements MenuService {
@Autowired
private MenuMapper menuMapper;
/**
* 根据角色获取菜单树
*/
@Override
public List<MenuVO> getMenuTreeByRole(String roleCode) {
// 1. 查询该角色有权限的菜单列表
List<Menu> menuList = menuMapper.selectMenusByRoleCode(roleCode);
// 2. 构建菜单树形结构
return buildMenuTree(menuList);
}
/**
* 构建菜单树 - 核心算法
*/
private List<MenuVO> buildMenuTree(List<Menu> menuList) {
if (menuList == null || menuList.isEmpty()) {
return new ArrayList<>();
}
// 找出所有根菜单(parentId = 0)
List<MenuVO> rootMenus = menuList.stream()
.filter(menu -> menu.getParentId() == 0)
.sorted(Comparator.comparing(Menu::getSortOrder))
.map(this::convertToVO)
.collect(Collectors.toList());
// 为每个根菜单递归查找子菜单
for (MenuVO rootMenu : rootMenus) {
findChildren(rootMenu, menuList);
}
return rootMenus;
}
/**
* 递归查找子菜单
*/
private void findChildren(MenuVO parentMenu, List<Menu> allMenus) {
List<MenuVO> children = allMenus.stream()
.filter(menu -> parentMenu.getId().equals(menu.getParentId()))
.sorted(Comparator.comparing(Menu::getSortOrder))
.map(this::convertToVO)
.collect(Collectors.toList());
if (!children.isEmpty()) {
parentMenu.setChildren(children);
// 递归处理子菜单的子菜单
for (MenuVO child : children) {
findChildren(child, allMenus);
}
}
}
/**
* 将Menu实体转换为MenuVO视图对象
*/
private MenuVO convertToVO(Menu menu) {
MenuVO vo = new MenuVO();
vo.setId(menu.getId());
vo.setParentId(menu.getParentId());
vo.setName(menu.getMenuName());
vo.setIcon(menu.getMenuIcon());
vo.setRoute(menu.getRoutePath());
// 设置可访问角色(实际应该从数据库查询)
List<String> roles = new ArrayList<>();
roles.add("admin");
roles.add("editor");
roles.add("viewer");
vo.setRoles(roles);
return vo;
}
}
控制器层的实现
认证控制器 - AuthController.java
java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService;
/**
* 用户登录接口
*/
@PostMapping("/login")
public Result<LoginResult> login(@RequestBody LoginDTO loginDTO) {
// 1. 验证用户名密码
if (!"admin".equals(loginDTO.getUsername()) ||
!"123456".equals(loginDTO.getPassword())) {
return Result.error("用户名或密码错误");
}
// 2. 获取用户信息(包含菜单权限)
UserInfoVO userInfo = userService.getUserInfo(loginDTO.getUsername());
// 3. 返回登录结果
LoginResult result = new LoginResult();
result.setUserInfo(userInfo);
result.setToken("mock-jwt-token-" + System.currentTimeMillis());
return Result.success(result);
}
/**
* 切换角色接口
*/
@PostMapping("/switchRole")
public Result<UserInfoVO> switchRole(@RequestParam String roleCode) {
// 模拟用户角色切换
UserInfoVO userInfo = userService.getUserInfo("admin");
userInfo.setRole(roleCode);
// 重新获取该角色的菜单
userInfo.setMenus(userService.getMenuTreeByRole(roleCode));
return Result.success(userInfo);
}
}
菜单控制器 - MenuController.java
java
@RestController
@RequestMapping("/api/menu")
public class MenuController {
@Autowired
private MenuService menuService;
/**
* 获取菜单树接口
*/
@GetMapping("/tree")
public Result<List<MenuVO>> getMenuTree(@RequestParam String roleCode) {
List<MenuVO> menuTree = menuService.getMenuTreeByRole(roleCode);
return Result.success(menuTree);
}
}
数据访问层
菜单Mapper接口 - MenuMapper.java
java
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
/**
* 根据角色编码查询有权限的菜单
*/
@Select("SELECT m.* FROM sys_menu m " +
"INNER JOIN sys_role_menu rm ON m.id = rm.menu_id " +
"INNER JOIN sys_role r ON rm.role_id = r.id " +
"WHERE r.role_code = #{roleCode} AND m.status = 1 " +
"ORDER BY m.sort_order ASC")
List<Menu> selectMenusByRoleCode(@Param("roleCode") String roleCode);
}
统一返回结果封装
Result.java - 统一响应格式
java
@Data
public class Result<T> {
private Integer code; // 状态码
private String message; // 提示信息
private T data; // 返回数据
private Long timestamp; // 时间戳
// 成功响应
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
result.setTimestamp(System.currentTimeMillis());
return result;
}
// 错误响应
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
result.setTimestamp(System.currentTimeMillis());
return result;
}
}
三、前端Vue3对接改造
菜单数据获取改造
javascript
// 从后端API获取菜单数据
const fetchUserMenu = async () => {
try {
const response = await axios.get('/api/menu/tree', {
params: { roleCode: currentUser.value.role }
});
if (response.data.code === 200) {
// 转换后端数据为前端需要的格式
menuData.value = transformMenuData(response.data.data);
}
} catch (error) {
console.error('获取菜单失败:', error);
ElMessage.error('菜单加载失败');
}
};
// 数据格式转换函数
const transformMenuData = (backendMenus) => {
return backendMenus.map(menu => ({
id: menu.id.toString(),
name: menu.name,
icon: menu.icon,
route: menu.route,
roles: menu.roles || ['admin', 'editor', 'viewer'],
children: menu.children ? transformMenuData(menu.children) : undefined
}));
};
角色切换功能增强
javascript
// 角色切换处理
const handleRoleChange = async (role) => {
try {
// 调用后端角色切换接口
const response = await axios.post('/api/auth/switchRole', null, {
params: { roleCode: role }
});
if (response.data.code === 200) {
const userInfo = response.data.data;
// 更新用户信息和菜单
currentUser.value.role = userInfo.role;
menuData.value = transformMenuData(userInfo.menus);
// 更新当前激活菜单
updateActiveMenu(userInfo.menus);
ElMessage.success(`角色已切换为: ${getRoleName(role)}`);
}
} catch (error) {
console.error('角色切换失败:', error);
ElMessage.error('角色切换失败');
}
};
四、核心技术与实现原理
1. 菜单树构建算法
菜单树构建的核心是递归算法:
java
// 伪代码说明
function buildMenuTree(所有菜单列表) {
1. 找出所有根节点(parentId=0的菜单)
2. 对每个根节点:
- 找出其直接子节点(parentId=当前节点ID)
- 递归处理每个子节点
3. 返回构建好的树形结构
}
2. 权限过滤流程
用户登录 → 获取用户角色 → 查询角色权限菜单 → 构建菜单树 → 返回前端
3. 前后端数据格式转换
后端返回的菜单数据需要转换为前端Element Plus菜单组件需要的格式:
| 后端字段 | 前端字段 | 说明 |
|---|---|---|
| menuName | name | 菜单名称 |
| menuIcon | icon | 菜单图标 |
| routePath | route | 路由路径 |
| children | children | 子菜单 |
五、可扩展方案
1. 添加权限验证中间件
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 验证token和权限
String token = request.getHeader("Authorization");
// 权限验证逻辑...
return true;
}
}
2. 添加菜单缓存
java
@Service
public class MenuService {
@Autowired
private RedisTemplate redisTemplate;
public List<MenuVO> getMenuTreeByRole(String roleCode) {
String cacheKey = "menu:tree:" + roleCode;
// 先查缓存
List<MenuVO> cachedMenu = (List<MenuVO>) redisTemplate.opsForValue().get(cacheKey);
if (cachedMenu != null) {
return cachedMenu;
}
// 缓存不存在,查询数据库
List<MenuVO> menuTree = buildMenuTreeFromDB(roleCode);
// 存入缓存
redisTemplate.opsForValue().set(cacheKey, menuTree, 30, TimeUnit.MINUTES);
return menuTree;
}
}
3. 前端路由权限控制
javascript
// 路由守卫
router.beforeEach((to, from, next) => {
const userRole = store.state.user.role;
const requiredRole = to.meta.role;
if (requiredRole && !requiredRole.includes(userRole)) {
next('/403'); // 无权限页面
} else {
next();
}
});
总结
通过本文的完整实现,我们构建了一个功能完善的动态菜单权限管理系统:
- 数据库设计:合理的表结构是权限系统的基础
- 后端实现:Spring Boot + MyBatis Plus提供RESTful API
- 核心算法:递归构建菜单树,实现权限过滤
- 前端对接:Vue3 + Element Plus渲染动态菜单
- 扩展优化:缓存、权限验证等生产级特性
这种设计模式具有很好的扩展性,可以轻松应对更复杂的权限需求。希望这篇文章能帮助你深入理解动态菜单的实现原理,并在实际项目中灵活应用。
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《SpringBoot+MySQL+Vue实现文件共享系统》