手把手带你开发一套用户权限系统,精确到按钮级

在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开

如何设计一套可以精确到按钮级别的用户权限功能呢?

今天通过这篇文章一起来了解一下相关的实现逻辑,不多说了,直接上案例代码!

01、数据库设计

在进入项目开发之前,首先我们需要进行相关的数据库设计,以便能存储相关的业务数据。

对于【用户权限控制】功能,通常5张表基本就可以搞定,分别是:用户表、角色表、用户角色表、菜单表、角色菜单表,相关表结构示例如下。

其中,用户和角色是多对多的关系角色与菜单也是多对多的关系用户通过角色来关联到菜单,当然也有的用户权限控制模型中,直接通过用户关联到菜单,实现用户对某个菜单权限独有控制,这都不是问题,可以自由灵活扩展。

用户、角色表的结构设计,比较简单。下面,我们重点来解读一下菜单表的设计,如下:

可以看到,整个菜单表就是一个父子表结构,关键字段如下

  • name:菜单名称
  • menu_code:菜单编码,用于后端权限控制
  • parent_id:菜单父节点ID,方便递归遍历菜单
  • node_type:菜单节点类型,可以是文件夹、页面或者按钮类型
  • link_url:菜单对应的地址,如果是文件夹或者按钮类型,可以为空
  • level:菜单树的层次,以便于查询指定层级的菜单
  • path:树id的路径,主要用于存放从根节点到当前树的父节点的路径,想要找父节点时会特别快

为了方便项目后续开发,在此我们创建一个名为menu_auth_db数据库,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`),
) ENGINE=InnoDB  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`),
) ENGINE=InnoDB  COMMENT='用户角色表';
CREATE TABLE `menu_auth_db`.`tb_role` (
  `id` bigint(20) unsigned NOT NULL COMMENT '角色ID',
  `name` varchar(100) NOT NULL DEFAULT '' COMMENT '角色名称',
  `code` varchar(100) NOT NULL DEFAULT '' COMMENT '角色编码',
  `is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
  PRIMARY KEY (`id`),
) ENGINE=InnoDB  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`),
) ENGINE=InnoDB  COMMENT='角色菜单表';
CREATE TABLE `menu_auth_db`.`tb_menu` (
  `id` bigint(20) NOT NULL COMMENT '菜单ID',
  `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的路径,主要用于存放从根节点到当前树的父节点的路径',
  `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  COMMENT='菜单表';

02、项目构建

菜单权限模块的数据库设计搞定之后,就可以正式进入系统开发阶段了。

2.1、创建项目

为了快速构建项目,这里采用的是springboot+mybatisPlus框架来快速开发,借助mybatisPlus提供的生成代码器,可以一键生成所需的daoserviceweb层的服务代码,以便帮助我们剩去 CRUD 中重复编程的工作量,内容如下:

CRUD 代码生成完成之后,此时我们就可以编写业务逻辑代码了,相关示例如下!

2.2、菜单功能开发

2.2.1、菜单新增逻辑示例
@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);
}
2.2.2、菜单查询逻辑示例

首先,编写一个视图对象,用于数据展示。

public class MenuVo {
    /**
     * 主键
     */
    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<MenuVo> childMenu;

    // set、get方法等...
}

接着编写菜单查询逻辑,这里需要用到递归算法来封装菜单视图。

@Override
public List<MenuVo> queryMenuTree() {
    Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
    List<Menu> allMenu = super.list(queryObj);
    // 0L:表示根节点的父ID
    List<MenuVo> resultList = transferMenuVo(allMenu, 0L);
    return resultList;
}

递归算法,方法实现逻辑如下!

/**
 * 封装菜单视图
 * @param allMenu
 * @param parentId
 * @return
 */
private List<MenuVo> transferMenuVo(List<Menu> allMenu, Long parentId){
    List<MenuVo> resultList = new ArrayList<>();
    if(!CollectionUtils.isEmpty(allMenu)){
        for (Menu source : allMenu) {
            if(parentId.longValue() == source.getParentId().longValue()){
                MenuVo menuVo = new MenuVo();
                BeanUtils.copyProperties(source, menuVo);
                //递归查询子菜单,并封装信息
                List<MenuVo> childList = transferMenuVo(allMenu, source.getId());
                if(!CollectionUtils.isEmpty(childList)){
                    menuVo.setChildMenu(childList);
                }
                resultList.add(menuVo);
            }
        }
    }
    return resultList;
}

最后编写一个菜单查询接口,将其响应给客户端。

@RestController
@RequestMapping("/menu")
public class MenuController {
    @Autowired
    private MenuService menuService;
    @PostMapping(value = "/queryMenuTree")
    public List<MenuVo> queryTreeMenu(){
        return menuService.queryMenuTree();
    }
}

为了便于演示,这里我们先在数据库中初始化几条数据,最后三条数据指的是按钮类型的菜单,用户真正请求的时候,实际上请求的是这三个功能,内容如下:

queryMenuTree接口发起请求,返回的数据结果如下图:

将返回的数据,通过页面进行渲染之后,结果类似如下图:

2.3、用户权限开发

在上文,我们提到了用户通过角色来关联菜单,因此,很容易想到,用户控制菜单的流程如下:

  • 第一步:用户登陆系统之后,查询当前用户拥有哪些角色;
  • 第二步:再通过角色查询关联的菜单权限点;
  • 第三步:最后将用户拥有的角色名下所有的菜单权限点,封装起来返回给用户;

带着这个思路,我们一起来看看具体的实现过程。

2.3.1、用户权限点查询逻辑示例

首先,编写一个通过用户ID查询菜单的服务,代码示例如下!

@Override
public List<MenuVo> queryMenus(Long userId) {
    // 第一步:先查询当前用户对应的角色
    Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
    List<UserRole> userRoles = userRoleService.list(queryUserRoleObj);
    if(!CollectionUtils.isEmpty(userRoles)){
        // 第二步:通过角色查询菜单(默认取第一个角色)
        Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId());
        List<RoleMenu> roleMenus = roleMenuService.list(queryRoleMenuObj);
        if(!CollectionUtils.isEmpty(roleMenus)){
            Set<Long> menuIds = new HashSet<>();
            for (RoleMenu roleMenu : roleMenus) {
                menuIds.add(roleMenu.getMenuId());
            }
            //查询对应的菜单
            Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
            List<Menu> menus = super.list(queryMenuObj);
            if(!CollectionUtils.isEmpty(menus)){
                //将菜单下对应的父节点也一并全部查询出来
                Set<Long> allMenuIds = new HashSet<>();
                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));
                        }
                    }
                }
                // 第三步:查询对应的所有菜单,并进行封装展示
                List<Menu> allMenus = super.list(new QueryWrapper<Menu>().in("id", new ArrayList<>(allMenuIds)));
                List<MenuVo> resultList = transferMenuVo(allMenus, 0L);
                return resultList;
            }
        }
    }
    return null;
}

然后,编写一个通过用户ID查询菜单的接口,将数据结果返回给用户,代码示例如下!

@PostMapping(value = "/queryMenus")
public List<MenuVo> queryMenus(Long userId){
    //查询当前用户下的菜单权限
    return menuService.queryMenus(userId);
}

2.4、用户鉴权开发

完成以上的逻辑开发之后,可以实现哪些用户拥有哪些菜单权限点的操作,比如用户【张三】,拥有【用户管理】菜单,那么他只能看到【用户管理】的界面;用户【李四】,用于【角色管理】菜单,同样的,他只能看到【角色管理】的界面,无法看到其他的界面。

但是某些技术人员发生漏洞之后,可能会绕过页面展示逻辑,直接对接口服务发起请求,依然能正常操作,例如利用用户【张三】的账户,操作【角色管理】的数据,这个时候就会发生数据安全隐患的问题。

为此,我们还需要一套用户鉴权的功能,对接口请求进行验证,只有满足要求的才能获取数据。

其中上文提到的菜单编码menuCode就是一个前、后端联系的桥梁。其实所有后端的接口,与前端对应的都是按钮操作,因此我们可以以按钮为基准,实现前后端双向权限控制

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

2.4.1、权限控制逻辑示例

在此,我们采用权限注解+代理拦截器的方式,来实现接口权限的安全验证。

首先,编写一个权限注解CheckPermissions

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermissions {
    String value() default "";
}

然后,编写一个代理拦截器,拦截所有被@CheckPermissions注解标注的方法

@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 requestParam = args[0];
        // 用户请求参数实体类中的用户ID
        if(!Objects.isNull(requestParam)){
            // 获取请求对象中属性为【userId】的值
            Field field = requestParam.getClass().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("接口无访问权限");
                    }
                }
            }
        }
    }
}
2.4.2、鉴权逻辑验证

我们以上文说到的【角色管理-查询】为例,编写一个服务接口来验证一下逻辑的正确性。

首先,编写一个请求实体类RoleDTO,添加userId属性

public class RoleDTO extends Role {
    //添加用户ID
    private Long userId;

    // set、get方法等...
}

其次,编写一个角色查询接口,并在方法上添加@CheckPermissions注解,表示此方法需要鉴权,满足条件的用户才能请求通过。

@RestController
@RequestMapping("/role")
public class RoleController {
    private RoleService roleService;
    @CheckPermissions(value="roleMgr:list")
    @PostMapping(value = "/queryRole")
    public List<Role> queryRole(RoleDTO roleDTO){
        return roleService.list();
    }
}

最后,在数据库中初始化相关的数据。例如给用户【张三】分配一个【访客人员】角色,同时这个角色只有【系统配置】、【用户管理】菜单权限。




启动项目,在postman中传入用户【张三】的ID,查询用户具备的菜单权限,只有两个,结果如下:

同时,利用用户【张三】发起【角色管理-查询】操作,提示:接口无访问权限,结果如下:

与预期结果一致!因为没有配置角色查询接口,所以无权访问!

03、小结

最后总结一下,用户权限系统功能在实际的软件系统中非常常见,希望本篇的知识能帮助到大家。

1