文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

手把手教你搞定菜单权限设计,精确到按钮级别,建议收藏

2024-12-11 20:36

关注

在实际的项目开发过程中,菜单权限功能可以说是后端管理系统中必不可少的一个环节,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单进行相应的扩展。

[[331842]]

今天小编就和大家一起来讨论一下,怎么设计一套可以精确到按钮级别的菜单权限功能,废话不多说,直接开撸!

二、数据库设计

先来看一下,用户、角色、菜单表对应的ER图,如下:

 

其中,用户和角色是多对多的关系,角色与菜单也是多对多的关系,用户通过角色来关联到菜单,当然也有的业务系统菜单权限模型,是可以直接通过用户关联到菜单,对菜单权限可以直接控制到用户级别,不过这个都不是问题,这个也可以进行扩展。

对于用户、角色表比较简单,下面,我们重点来看看菜单表的设计,如下:

 

可以看到,整个菜单表就是一个树型结构,关键字段说明:

为了后面方便开发,我们先创建一个名为menu_auth_db的数据库,初始脚本如下:

  1. CREATE DATABASE IF NOT EXISTS menu_auth_db default charset utf8mb4 COLLATE utf8mb4_unicode_ci; 
  2.  
  3. CREATE TABLE menu_auth_db.tb_user ( 
  4.   id bigint(20) unsigned NOT NULL COMMENT '消息给过来的ID'
  5.   mobile varchar(20) NOT NULL DEFAULT '' COMMENT '手机号'
  6.   name varchar(100) NOT NULL DEFAULT '' COMMENT '姓名'
  7.   password varchar(128) NOT NULL DEFAULT '' COMMENT '密码'
  8.   is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除'
  9.   PRIMARY KEY (id), 
  10.   KEY idx_name (name) USING BTREE, 
  11.   KEY idx_mobile (mobile) USING BTREE 
  12. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'
  13.  
  14. CREATE TABLE menu_auth_db.tb_user_role ( 
  15.   id bigint(20) unsigned NOT NULL COMMENT '主键'
  16.   user_id bigint(20) NOT NULL COMMENT '用户ID'
  17.   role_id bigint(20) NOT NULL COMMENT '角色ID'
  18.   PRIMARY KEY (id), 
  19.   KEY idx_user_id (user_id) USING BTREE, 
  20.   KEY idx_role_id (role_id) USING BTREE 
  21. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色表'
  22.  
  23. CREATE TABLE menu_auth_db.tb_role ( 
  24.   id bigint(20) unsigned NOT NULL COMMENT '主键'
  25.   code varchar(100) NOT NULL DEFAULT '' COMMENT '编码'
  26.   name varchar(100) NOT NULL DEFAULT '' COMMENT '名称'
  27.   is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除'
  28.   PRIMARY KEY (id), 
  29.   KEY idx_code (code) USING BTREE, 
  30.   KEY idx_name (name) USING BTREE 
  31. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'
  32.  
  33.  
  34. CREATE TABLE menu_auth_db.tb_role_menu ( 
  35.   id bigint(20) unsigned NOT NULL COMMENT '主键'
  36.   role_id bigint(20) NOT NULL COMMENT '角色ID'
  37.   menu_id bigint(20) NOT NULL COMMENT '菜单ID'
  38.   PRIMARY KEY (id), 
  39.   KEY idx_role_id (role_id) USING BTREE, 
  40.   KEY idx_menu_id (menu_id) USING BTREE 
  41. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单关系表'
  42.  
  43.  
  44. CREATE TABLE menu_auth_db.tb_menu ( 
  45.   id bigint(20) NOT NULL COMMENT '主键'
  46.   name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称'
  47.   menu_code varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单编码'
  48.   parent_id bigint(20) DEFAULT NULL COMMENT '父节点'
  49.   node_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '节点类型,1文件夹,2页面,3按钮'
  50.   icon_url varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '图标地址'
  51.   sort int(11) NOT NULL DEFAULT '1' COMMENT '排序号'
  52.   link_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '页面对应的地址'
  53.   level int(11) NOT NULL DEFAULT '0' COMMENT '层次'
  54.   path varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '树id的路径 整个层次上的路径id,逗号分隔,想要找父节点特别快'
  55.   is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除'
  56.   PRIMARY KEY (id) USING BTREE, 
  57.   KEY idx_parent_id (parent_id) USING BTREE 
  58. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单表'

三、后端开发

菜单权限模块的数据库设计,一般5张表就可以搞定,真正有点复杂的地方在于数据的写入和渲染,当然如果老板突然让你来开发一套菜单权限系统,我们也没必要慌张,下面,我们一起来看看后端应该如何开发。

3.1、创建项目

为了方便快捷,小编我采用的是springboot+mybatisPlus组件来快速开发,直接利用mybatisPlus官方提供的快速生成代码的demo,一键生成所需的dao、service、web层的代码,结果如下:

 

3.2、编写菜单添加服务

  1. @Override 
  2. public void addMenu(Menu menu) { 
  3.     //如果插入的当前节点为根节点,parentId指定为0 
  4.     if(menu.getParentId().longValue() == 0){ 
  5.         menu.setLevel(1);//根节点层级为1 
  6.         menu.setPath(null);//根节点路径为空 
  7.     }else
  8.         Menu parentMenu = baseMapper.selectById(menu.getParentId()); 
  9.         if(parentMenu == null){ 
  10.             throw new CommonException("未查询到对应的父节点"); 
  11.         } 
  12.         menu.setLevel(parentMenu.getLevel().intValue() + 1); 
  13.         if(StringUtils.isNotEmpty(parentMenu.getPath())){ 
  14.             menu.setPath(parentMenu.getPath() + "," + parentMenu.getId()); 
  15.         }else
  16.             menu.setPath(parentMenu.getId().toString()); 
  17.         } 
  18.     } 
  19.     //可以使用雪花算法,生成ID 
  20.     menu.setId(System.currentTimeMillis()); 
  21.     super.save(menu); 

新增菜单比较简单,直接将数据插入即可,需要注意的地方是parent_id、level、path,这三个字段的写入,如果新建的是根节点,默认parent_id为0,方便后续递归遍历。

3.3、编写菜单后端查询服务

  1. @Data 
  2. @EqualsAndHashCode(callSuper = false
  3. @Accessors(chain = true
  4. public class MenuVo implements Serializable { 
  5.  
  6.     private static final long serialVersionUID = -4559267810907997111L; 
  7.  
  8.      
  9.     private Long id; 
  10.  
  11.      
  12.     private String name
  13.  
  14.      
  15.     private String menuCode; 
  16.  
  17.      
  18.     private Long parentId; 
  19.  
  20.      
  21.     private Integer nodeType; 
  22.  
  23.      
  24.     private String iconUrl; 
  25.  
  26.      
  27.     private Integer sort; 
  28.  
  29.      
  30.     private String linkUrl; 
  31.  
  32.      
  33.     private Integer level
  34.  
  35.      
  36.     private String path; 
  37.  
  38.      
  39.     List childMenu; 
  1. @Override 
  2. public List queryMenuTree() { 
  3.     Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort"); 
  4.     List allMenu = super.list(queryObj); 
  5.  // 0L:表示根节点的父ID 
  6.     List resultList = transferMenuVo(allMenu, 0L); 
  7.     return resultList; 
  8.  
  9. private List transferMenuVo(List allMenu, Long parentId){ 
  10.     List resultList = new ArrayList<>(); 
  11.     if(!CollectionUtils.isEmpty(allMenu)){ 
  12.         for (Menu source : allMenu) { 
  13.             if(parentId.longValue() == source.getParentId().longValue()){ 
  14.                 MenuVo menuVo = new MenuVo(); 
  15.                 BeanUtils.copyProperties(source, menuVo); 
  16.                 //递归查询子菜单,并封装信息 
  17.                 List childList = transferMenuVo(allMenu, source.getId()); 
  18.                 if(!CollectionUtils.isEmpty(childList)){ 
  19.                     menuVo.setChildMenu(childList); 
  20.                 } 
  21.                 resultList.add(menuVo); 
  22.             } 
  23.         } 
  24.     } 
  25.     return resultList; 

编写一个菜单树查询接口,如下:

  1. @RestController 
  2. @RequestMapping("/menu"
  3. public class MenuController { 
  4.  
  5.     @Autowired 
  6.     private MenuService menuService; 
  7.  
  8.     @PostMapping(value = "/queryMenuTree"
  9.     public List queryTreeMenu(){ 
  10.         return menuService.queryMenuTree(); 
  11.     } 

为了便于演示,我们先初始化7条数据,如下图:

 

其中最后三条是按钮类型,等下会用于后端权限控制,接口查询结果如下:

 

这个服务是针对后端管理界面查询的,会将所有的菜单全部查询出来以便于进行管理,展示结果类似如下图:

 

这个图片截图于小编正在开发的一个项目,内容可能不一致,但是数据结构基本都是一致的。

3.4、编写用户菜单权限查询服务

在上面,我们介绍到了用户通过角色来关联菜单,因此,很容易想到,流程如下:

实现过程相比菜单查询服务多了前2个步骤,过程如下:

  1. @Override 
  2. public List queryMenus(Long userId) { 
  3.     //1、先查询当前用户对应的角色 
  4.     Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId); 
  5.     List userRoles = userRoleService.list(queryUserRoleObj); 
  6.     if(!CollectionUtils.isEmpty(userRoles)){ 
  7.         //2、通过角色查询菜单(默认取第一个角色) 
  8.         Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId()); 
  9.         List roleMenus = roleMenuService.list(queryRoleMenuObj); 
  10.         if(!CollectionUtils.isEmpty(roleMenus)){ 
  11.             Set menuIds = new HashSet<>(); 
  12.             for (RoleMenu roleMenu : roleMenus) { 
  13.                 menuIds.add(roleMenu.getMenuId()); 
  14.             } 
  15.             //查询对应的菜单 
  16.             Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds)); 
  17.             List menus = super.list(queryMenuObj); 
  18.             if(!CollectionUtils.isEmpty(menus)){ 
  19.                 //将菜单下对应的父节点也一并全部查询出来 
  20.                 Set allMenuIds = new HashSet<>(); 
  21.                 for (Menu menu : menus) { 
  22.                     allMenuIds.add(menu.getId()); 
  23.                     if(StringUtils.isNotEmpty(menu.getPath())){ 
  24.                         String[] pathIds = StringUtils.split(",", menu.getPath()); 
  25.                         for (String pathId : pathIds) { 
  26.                             allMenuIds.add(Long.valueOf(pathId)); 
  27.                         } 
  28.                     } 
  29.                 } 
  30.                 //3、查询对应的所有菜单,并进行封装展示 
  31.                 List allMenus = super.list(new QueryWrapper().in("id", new ArrayList<>(allMenuIds))); 
  32.                 List resultList = transferMenuVo(allMenus, 0L); 
  33.                 return resultList; 
  34.             } 
  35.         } 
  36.  
  37.     } 
  38.     return null
  1. @PostMapping(value = "/queryMenus"
  2. public List queryMenus(Long userId){ 
  3.  //查询当前用户下的菜单权限 
  4.     return menuService.queryMenus(userId); 

有的同学,可能觉得没必要存放path这个字段,的确在某些场景下不需要。

为什么要存放这个字段呢?

小编在跟前端进行对接的时候,发现这么一个问题,有些前端的树型组件,在勾选子集的时候,不会将对应的父ID传给后端,例如,我在勾选【列表查询】的时候,前端无法将父节点【菜单管理】ID也传给后端,所有后端实际存放的是一个尾节点,需要一个字段path,来存放节点对应的父节点路径。

 

其实,前端也可以传,只不过需要修改组件的属性,前端修改完成之后,树型组件就无法全选,不满足业务需求。

所以,有些时候得根据实际得情况来进行取舍。

3.5、编写后端权限控制

后端进行权限控制目标,主要是为了防止无权限的用户,进行接口请求查询。

其中菜单编码menuCode就是一个前、后端联系的桥梁,细心的你会发现,所有后端的接口,与前端对应的都是按钮操作,所以我们可以以按钮为基准,实现前后端双向控制。

以【角色管理-查询】这个为例,前端可以通过菜单编码实现是否展示这个查询按钮,后端可以通过菜单编码来判断,当前用户是否具备请求接口的权限。

以后端为例,我们只需编写一个权限注解和代理拦截器即可!

  1. @Target({ElementType.TYPE, ElementType.METHOD}) 
  2. @Retention(RetentionPolicy.RUNTIME) 
  3. public @interface CheckPermissions { 
  4.  
  5.     String value() default ""
  1. @Aspect 
  2. @Component 
  3. public class CheckPermissionsAspect { 
  4.  
  5.     @Autowired 
  6.     private MenuMapper menuMapper; 
  7.  
  8.     @Pointcut("@annotation(com.company.project.core.annotation.CheckPermissions)"
  9.     public void checkPermissions() {} 
  10.  
  11.     @Before("checkPermissions()"
  12.     public void doBefore(JoinPoint joinPoint) throws Throwable { 
  13.         Long userId = null
  14.         Object[] args = joinPoint.getArgs(); 
  15.         Object parobj = args[0]; 
  16.         //用户请求参数实体类中的用户ID 
  17.         if(!Objects.isNull(parobj)){ 
  18.             Class userCla = parobj.getClass(); 
  19.             Field field = userCla.getDeclaredField("userId"); 
  20.             field.setAccessible(true); 
  21.             userId = (Long) field.get(parobj); 
  22.         } 
  23.         if(!Objects.isNull(userId)){ 
  24.             //获取方法上有CheckPermissions注解的参数 
  25.             Class clazz = joinPoint.getTarget().getClass(); 
  26.             String methodName = joinPoint.getSignature().getName(); 
  27.             Class[] parameterTypes = ((MethodSignature)joinPoint.getSignature()).getMethod().getParameterTypes(); 
  28.             Method method = clazz.getMethod(methodName, parameterTypes); 
  29.             if(method.getAnnotation(CheckPermissions.class) != null){ 
  30.                 CheckPermissions annotation = method.getAnnotation(CheckPermissions.class); 
  31.                 String menuCode = annotation.value(); 
  32.                 if (StringUtils.isNotBlank(menuCode)) { 
  33.                     //通过用户ID、菜单编码查询是否有关联 
  34.                     int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode); 
  35.                     if(count == 0){ 
  36.                         throw new CommonException("接口无访问权限"); 
  37.                     } 
  38.                 } 
  39.             } 
  40.         } 
  41.     } 
  1. @Data 
  2. @EqualsAndHashCode(callSuper = false
  3. @Accessors(chain = true
  4. public class RoleDto extends Role { 
  5.  
  6.  //添加用户ID 
  7.     private Long userId; 
  1. @RestController 
  2. @RequestMapping("/role"
  3. public class RoleController { 
  4.  
  5.     private RoleService roleService; 
  6.  
  7.     @CheckPermissions(value="roleMgr:list"
  8.     @PostMapping(value = "/queryRole"
  9.     public List queryRole(RoleDto roleDto){ 
  10.         return roleService.list(); 
  11.     } 
  12.  
  13.     @CheckPermissions(value="roleMgr:add"
  14.     @PostMapping(value = "/addRole"
  15.     public void addRole(RoleDto roleDto){ 
  16.         roleService.add(roleDto); 
  17.     } 
  18.  
  19.     @CheckPermissions(value="roleMgr:delete"
  20.     @PostMapping(value = "/deleteRole"
  21.     public void deleteRole(RoleDto roleDto){ 
  22.         roleService.delete(roleDto); 
  23.     } 

依次类推,当我们想对某个接口进行权限控制的时候,只需要添加一个注解@CheckPermissions,并填写对应的菜单编码即可!

四、用户权限

测试我们先初始化一个用户【张三】,然后给他分配一个角色【访客人员】,同时给这个角色分配一下2个菜单权限【系统配置】、【用户管理】,等会用于权限测试。

初始内容如下:

 

数据初始化完成之后,我们来启动项目,传入用户【张三】的ID,查询用户具备的菜单权限,结果如下:

 

 

查询结果,用户【张三】有两个菜单权限!

接着,我们来验证一下,用户【张三】是否有角色查询权限,请求角色查询接口如下:

 

因为没有配置角色查询接口,所以无权访问!

五、总结

整片内容,只介绍了后端关键的服务实现过程,可能也有遗漏的地方,欢迎网友点评、吐槽!

 

来源: Java极客技术内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯