書懷 發表於 2023-2-1 16:56:00

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

<h3 id="一介绍">一、介绍</h3>
<p>在实际的项目开发过程中,<strong>菜单权限功能</strong>可以说是后端管理系统中必不可少的一个环节,根据业务的复杂度,<strong>设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单进行相应的扩展</strong>。</p>
<p>今天小编就和大家一起来讨论一下,怎么设计一套可以<strong>精确到按钮级别</strong>的菜单权限功能,废话不多说,直接开撸!</p>
<h3 id="二数据库设计">二、数据库设计</h3>
<p>先来看一下,用户、角色、菜单表对应的ER图,如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165012696-1472279778.png" alt="" loading="lazy"></p>
<p>其中,<strong>用户和角色是多对多的关系</strong>,<strong>角色与菜单也是多对多的关系</strong>,<strong>用户通过角色来关联到菜单</strong>,当然也有的业务系统菜单权限模型,是可以直接通过用户关联到菜单,对菜单权限可以直接控制到用户级别,不过这个都不是问题,这个也可以进行扩展。</p>
<p>对于用户、角色表比较简单,下面,我们重点来看看菜单表的设计,如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165023976-1504464372.png" alt="" loading="lazy"></p>
<p>可以看到,整个菜单表就是一个树型结构,<strong>关键字段说明</strong>:</p>
<ul>
<li>menu_code:菜单编码,用于后端权限控制</li>
<li>parent_id:菜单父节点ID,方便递归遍历菜单</li>
<li>node_type:节点类型,可以是文件夹、页面或者按钮类型</li>
<li>link_url:页面对应的地址,如果是文件夹或者按钮类型,可以为空</li>
<li>level:菜单树的层次,以便于查询指定层级的菜单</li>
<li>path:树id的路径,主要用于存放从根节点到当前树的父节点的路径,逗号分隔,想要找父节点会特别快</li>
</ul>
<p>为了后面方便开发,我们先创建一个名为<code>menu_auth_db</code>的数据库,初始脚本如下:</p>
<pre><code class="language-sql">CREATE DATABASE IF NOT EXISTS menu_auth_db default charset utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE menu_auth_db.tb_user (
id bigint(20) unsigned NOT NULL COMMENT '消息给过来的ID',
mobile varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
name varchar(100) NOT NULL DEFAULT '' COMMENT '姓名',
password varchar(128) NOT NULL DEFAULT '' COMMENT '密码',
is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (id),
KEY idx_name (name) USING BTREE,
KEY idx_mobile (mobile) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';

CREATE TABLE menu_auth_db.tb_user_role (
id bigint(20) unsigned NOT NULL COMMENT '主键',
user_id bigint(20) NOT NULL COMMENT '用户ID',
role_id bigint(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (id),
KEY idx_user_id (user_id) USING BTREE,
KEY idx_role_id (role_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色表';

CREATE TABLE menu_auth_db.tb_role (
id bigint(20) unsigned NOT NULL COMMENT '主键',
code varchar(100) NOT NULL DEFAULT '' COMMENT '编码',
name varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (id),
KEY idx_code (code) USING BTREE,
KEY idx_name (name) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';


CREATE TABLE menu_auth_db.tb_role_menu (
id bigint(20) unsigned NOT NULL COMMENT '主键',
role_id bigint(20) NOT NULL COMMENT '角色ID',
menu_id bigint(20) NOT NULL COMMENT '菜单ID',
PRIMARY KEY (id),
KEY idx_role_id (role_id) USING BTREE,
KEY idx_menu_id (menu_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单关系表';


CREATE TABLE menu_auth_db.tb_menu (
id bigint(20) NOT NULL COMMENT '主键',
name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',
menu_code varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单编码',
parent_id bigint(20) DEFAULT NULL COMMENT '父节点',
node_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '节点类型,1文件夹,2页面,3按钮',
icon_url varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '图标地址',
sort int(11) NOT NULL DEFAULT '1' COMMENT '排序号',
link_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '页面对应的地址',
level int(11) NOT NULL DEFAULT '0' COMMENT '层次',
path varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '树id的路径 整个层次上的路径id,逗号分隔,想要找父节点特别快',
is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (id) USING BTREE,
KEY idx_parent_id (parent_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单表';
</code></pre>
<h3 id="三后端开发">三、后端开发</h3>
<p>菜单权限模块的数据库设计,一般5张表就可以搞定,真正有点复杂的地方在于数据的写入和渲染,当然如果老板突然让你来开发一套菜单权限系统,我们也没必要慌张,下面,我们一起来看看后端应该如何开发。</p>
<h4 id="31创建项目">3.1、创建项目</h4>
<p>为了方便快捷,小编我采用的是<code>springboot+mybatisPlus</code>组件来快速开发,直接利用<code>mybatisPlus</code>官方提供的快速生成代码的<code>demo</code>,一键生成所需的<code>dao</code>、<code>service</code>、<code>web</code>层的代码,结果如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165044841-344666124.png" alt="" loading="lazy"></p>
<h4 id="32编写菜单添加服务">3.2、编写菜单添加服务</h4>
<pre><code class="language-java">@Override
public void addMenu(Menu menu) {
    //如果插入的当前节点为根节点,parentId指定为0
    if(menu.getParentId().longValue() == 0){
      menu.setLevel(1);//根节点层级为1
      menu.setPath(null);//根节点路径为空
    }else{
      Menu parentMenu = baseMapper.selectById(menu.getParentId());
      if(parentMenu == null){
            throw new CommonException("未查询到对应的父节点");
      }
      menu.setLevel(parentMenu.getLevel().intValue() + 1);
      if(StringUtils.isNotEmpty(parentMenu.getPath())){
            menu.setPath(parentMenu.getPath() + "," + parentMenu.getId());
      }else{
            menu.setPath(parentMenu.getId().toString());
      }
    }
    //可以使用雪花算法,生成ID
    menu.setId(System.currentTimeMillis());
    super.save(menu);
}
</code></pre>
<p>新增菜单比较简单,直接将数据插入即可,需要注意的地方是<code>parent_id</code>、<code>level</code>、<code>path</code>,这三个字段的写入,如果新建的是根节点,默认<code>parent_id</code>为<code>0</code>,方便后续递归遍历。</p>
<h4 id="33编写菜单后端查询服务">3.3、编写菜单后端查询服务</h4>
<ul>
<li>新建一个菜单视图实体类</li>
</ul>
<pre><code class="language-java">@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class MenuVo implements Serializable {

    private static final long serialVersionUID = -4559267810907997111L;

    /**
   * 主键
   */
    private Long id;

    /**
   * 名称
   */
    private String name;

    /**
   * 菜单编码
   */
    private String menuCode;

    /**
   * 父节点
   */
    private Long parentId;

    /**
   * 节点类型,1文件夹,2页面,3按钮
   */
    private Integer nodeType;

    /**
   * 图标地址
   */
    private String iconUrl;

    /**
   * 排序号
   */
    private Integer sort;

    /**
   * 页面对应的地址
   */
    private String linkUrl;

    /**
   * 层次
   */
    private Integer level;

    /**
   * 树id的路径 整个层次上的路径id,逗号分隔,想要找父节点特别快
   */
    private String path;

    /**
   * 子菜单集合
   */
    List&lt;MenuVo&gt; childMenu;
}
</code></pre>
<ul>
<li>编写菜单查询服务,使用递归重新封装菜单视图</li>
</ul>
<pre><code class="language-java">@Override
public List&lt;MenuVo&gt; queryMenuTree() {
    Wrapper queryObj = new QueryWrapper&lt;&gt;().orderByAsc("level","sort");
    List&lt;Menu&gt; allMenu = super.list(queryObj);
    // 0L:表示根节点的父ID
    List&lt;MenuVo&gt; resultList = transferMenuVo(allMenu, 0L);
    return resultList;
}
</code></pre>
<pre><code class="language-java">/**
* 封装菜单视图
* @param allMenu
* @param parentId
* @return
*/
private List&lt;MenuVo&gt; transferMenuVo(List&lt;Menu&gt; allMenu, Long parentId){
    List&lt;MenuVo&gt; resultList = new ArrayList&lt;&gt;();
    if(!CollectionUtils.isEmpty(allMenu)){
      for (Menu source : allMenu) {
            if(parentId.longValue() == source.getParentId().longValue()){
                MenuVo menuVo = new MenuVo();
                BeanUtils.copyProperties(source, menuVo);
                //递归查询子菜单,并封装信息
                List&lt;MenuVo&gt; childList = transferMenuVo(allMenu, source.getId());
                if(!CollectionUtils.isEmpty(childList)){
                  menuVo.setChildMenu(childList);
                }
                resultList.add(menuVo);
            }
      }
    }
    return resultList;
}
</code></pre>
<ul>
<li>编写一个菜单树查询接口,如下:</li>
</ul>
<pre><code class="language-java">@RestController
@RequestMapping("/menu")
public class MenuController {

    @Autowired
    private MenuService menuService;

    @PostMapping(value = "/queryMenuTree")
    public List&lt;MenuVo&gt; queryTreeMenu(){
      return menuService.queryMenuTree();
    }
}
</code></pre>
<p>为了便于演示,我们先初始化7条数据,如下图:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165106704-817053253.png" alt="" loading="lazy"></p>
<p>其中最后三条是按钮类型,等下会用于<strong>后端权限控制</strong>,接口查询结果如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165116459-1919172648.png" alt="" loading="lazy"></p>
<p>这个服务是针对后端管理界面查询的,会将所有的菜单全部查询出来以便于进行管理,展示结果类似如下图:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165134502-2087326754.png" alt="" loading="lazy"></p>
<p>这个图片截图于小编正在开发的一个项目,<strong>内容可能不一致,但是数据结构基本都是一致的</strong>。</p>
<h4 id="34编写用户菜单权限查询服务">3.4、编写用户菜单权限查询服务</h4>
<p>在上面,我们介绍到了用户通过角色来关联菜单,因此,很容易想到,流程如下:</p>
<ul>
<li>第一步:先通过用户查询到对应的角色;</li>
<li>第二步:然后再通过角色查询到对应的菜单;</li>
<li>第三步:最后将菜单查询出来之后进行渲染;</li>
</ul>
<p>实现过程相比菜单查询服务多了前2个步骤,过程如下:</p>
<pre><code class="language-java">@Override
public List&lt;MenuVo&gt; queryMenus(Long userId) {
    //1、先查询当前用户对应的角色
    Wrapper queryUserRoleObj = new QueryWrapper&lt;&gt;().eq("user_id", userId);
    List&lt;UserRole&gt; userRoles = userRoleService.list(queryUserRoleObj);
    if(!CollectionUtils.isEmpty(userRoles)){
      //2、通过角色查询菜单(默认取第一个角色)
      Wrapper queryRoleMenuObj = new QueryWrapper&lt;&gt;().eq("role_id", userRoles.get(0).getRoleId());
      List&lt;RoleMenu&gt; roleMenus = roleMenuService.list(queryRoleMenuObj);
      if(!CollectionUtils.isEmpty(roleMenus)){
            Set&lt;Long&gt; menuIds = new HashSet&lt;&gt;();
            for (RoleMenu roleMenu : roleMenus) {
                menuIds.add(roleMenu.getMenuId());
            }
            //查询对应的菜单
            Wrapper queryMenuObj = new QueryWrapper&lt;&gt;().in("id", new ArrayList&lt;&gt;(menuIds));
            List&lt;Menu&gt; menus = super.list(queryMenuObj);
            if(!CollectionUtils.isEmpty(menus)){
                //将菜单下对应的父节点也一并全部查询出来
                Set&lt;Long&gt; allMenuIds = new HashSet&lt;&gt;();
                for (Menu menu : menus) {
                  allMenuIds.add(menu.getId());
                  if(StringUtils.isNotEmpty(menu.getPath())){
                        String[] pathIds = StringUtils.split(",", menu.getPath());
                        for (String pathId : pathIds) {
                            allMenuIds.add(Long.valueOf(pathId));
                        }
                  }
                }
                //3、查询对应的所有菜单,并进行封装展示
                List&lt;Menu&gt; allMenus = super.list(new QueryWrapper&lt;Menu&gt;().in("id", new ArrayList&lt;&gt;(allMenuIds)));
                List&lt;MenuVo&gt; resultList = transferMenuVo(allMenus, 0L);
                return resultList;
            }
      }

    }
    return null;
}
</code></pre>
<ul>
<li>编写一个用户菜单查询接口,如下:</li>
</ul>
<pre><code class="language-java">@PostMapping(value = "/queryMenus")
public List&lt;MenuVo&gt; queryMenus(Long userId){
    //查询当前用户下的菜单权限
    return menuService.queryMenus(userId);
}
</code></pre>
<p>有的同学,可能觉得没必要存放<code>path</code>这个字段,的确在某些场景下不需要。</p>
<p>为什么要存放这个字段呢?</p>
<p>小编在跟前端进行对接的时候,发现这么一个问题,有些前端的树型组件,在勾选子集的时候,不会将对应的父ID传给后端,例如,我在勾选【列表查询】的时候,前端无法将父节点【菜单管理】ID也传给后端,<strong>所有后端实际存放的是一个尾节点</strong>,需要一个字段<code>path</code>,来存放节点对应的父节点路径。</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165201675-277797447.png" alt="" loading="lazy"></p>
<p>其实,前端也可以传,只不过需要修改组件的属性,前端修改完成之后,树型组件就无法全选,不满足业务需求。</p>
<p>所以,有些时候得根据实际得情况来进行取舍。</p>
<h4 id="35编写后端权限控制">3.5、编写后端权限控制</h4>
<p>后端进行权限控制目标,主要是为了防止无权限的用户,进行接口请求查询。</p>
<p><strong>其中菜单编码<code>menuCode</code>就是一个前、后端联系的桥梁,细心的你会发现,所有后端的接口,与前端对应的都是按钮操作,所以我们可以以按钮为基准,实现前后端双向控制</strong>。</p>
<p>以【角色管理-查询】这个为例,前端可以通过菜单编码实现是否展示这个查询按钮,后端可以通过菜单编码来判断,当前用户是否具备请求接口的权限。</p>
<p>以后端为例,我们只需编写一个权限注解和代理拦截器即可!</p>
<ul>
<li>编写一个权限注解</li>
</ul>
<pre><code class="language-java">@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermissions {

    String value() default "";
}
</code></pre>
<ul>
<li>编写一个代理拦截器,拦截有<code>@CheckPermissions</code>注解的方法</li>
</ul>
<pre><code class="language-java">@Aspect
@Component
public class CheckPermissionsAspect {

    @Autowired
    private MenuMapper menuMapper;

    @Pointcut("@annotation(com.company.project.core.annotation.CheckPermissions)")
    public void checkPermissions() {}

    @Before("checkPermissions()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
      Long userId = null;
      Object[] args = joinPoint.getArgs();
      Object parobj = args;
      //用户请求参数实体类中的用户ID
      if(!Objects.isNull(parobj)){
            Class userCla = parobj.getClass();
            Field field = userCla.getDeclaredField("userId");
            field.setAccessible(true);
            userId = (Long) field.get(parobj);
      }
      if(!Objects.isNull(userId)){
            //获取方法上有CheckPermissions注解的参数
            Class clazz = joinPoint.getTarget().getClass();
            String methodName = joinPoint.getSignature().getName();
            Class[] parameterTypes = ((MethodSignature)joinPoint.getSignature()).getMethod().getParameterTypes();
            Method method = clazz.getMethod(methodName, parameterTypes);
            if(method.getAnnotation(CheckPermissions.class) != null){
                CheckPermissions annotation = method.getAnnotation(CheckPermissions.class);
                String menuCode = annotation.value();
                if (StringUtils.isNotBlank(menuCode)) {
                  //通过用户ID、菜单编码查询是否有关联
                  int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode);
                  if(count == 0){
                        throw new CommonException("接口无访问权限");
                  }
                }
            }
      }
    }
}
</code></pre>
<ul>
<li>我们以【角色管理-查询】为例,先新建一个请求实体类<code>RoleDto</code>,添加用户ID属性</li>
</ul>
<pre><code class="language-java">@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class RoleDto extends Role {

    //添加用户ID
    private Long userId;
}
</code></pre>
<ul>
<li>在需要的接口上,添加<code>@CheckPermissions</code>注解,增加权限控制</li>
</ul>
<pre><code class="language-java">@RestController
@RequestMapping("/role")
public class RoleController {

    private RoleService roleService;

    @CheckPermissions(value="roleMgr:list")
    @PostMapping(value = "/queryRole")
    public List&lt;Role&gt; queryRole(RoleDto roleDto){
      return roleService.list();
    }

    @CheckPermissions(value="roleMgr:add")
    @PostMapping(value = "/addRole")
    public void addRole(RoleDto roleDto){
      roleService.add(roleDto);
    }

    @CheckPermissions(value="roleMgr:delete")
    @PostMapping(value = "/deleteRole")
    public void deleteRole(RoleDto roleDto){
      roleService.delete(roleDto);
    }
}
</code></pre>
<p>依次类推,当我们想对某个接口进行权限控制的时候,只需要添加一个注解<code>@CheckPermissions</code>,并填写对应的菜单编码即可!</p>
<h3 id="四用户权限测试">四、用户权限测试</h3>
<p>我们先初始化一个用户【张三】,然后给他分配一个角色【访客人员】,同时给这个角色分配一下2个菜单权限【系统配置】、【用户管理】,等会用于权限测试。</p>
<p>初始内容如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165224067-1668155125.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165235944-682687795.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165252005-1313407613.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165304157-2047870607.png" alt="" loading="lazy"></p>
<p>数据初始化完成之后,我们来启动项目,传入用户【张三】的ID,查询用户具备的菜单权限,结果如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165316100-1557421026.png" alt="" loading="lazy"></p>
<p>查询结果,用户【张三】有两个菜单权限!</p>
<p>接着,我们来验证一下,用户【张三】是否有角色查询权限,请求角色查询接口如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/1078540/202302/1078540-20230201165338003-1764555541.png" alt="" loading="lazy"></p>
<p>因为没有配置角色查询接口,所以无权访问!</p>
<h3 id="五总结">五、总结</h3>
<p>整片内容,只介绍了后端关键的服务实现过程,可能也有遗漏的地方,欢迎网友点评、吐槽!</p>


</div>
<div id="MySignature" role="contentinfo">
    <p style="border-top: #e0e0e0 1px dashed; border-right: #e0e0e0 1px dashed; border-bottom: #e0e0e0 1px dashed; border-left: #e0e0e0 1px dashed; padding-top: 5px; padding-right: 10px; padding-bottom: 10px; padding-left: 165px; background: url(&quot;//img2024.cnblogs.com/blog/1078540/202406/1078540-20240614164402310-1763967680.jpg&quot;) #e5f1f4 no-repeat 1% 50%;background-size: 150px; font-family: 微软雅黑; font-size: 11px" id="PSignature">
    <br>
    作者:<span style="font-weight: bold; font-size: large;">潘志的研发笔记</span>
    <br>
    出处:pzblog.cn
    <br>
    资源:微信搜<strong>【潘志的研发笔记】</strong>关注我,回复 <strong style=" font-size: 15px;">【技术资料】</strong>有我准备的一线程序必备计算机书籍、大厂面试资料和免费电子书。 <strong>希望可以帮助大家提升技术和能力。</strong>
    <br>
    <br>
</p><br><br>
来源:https://www.cnblogs.com/dxflqm/p/17083389.html
頁: [1]
查看完整版本: 手把手教你搞定菜单权限设计,精确到按钮级别