简易的后台管理权限设计

前言

因为想做一个快速的后台开发模板框架(方便以后直接开发功能而不用纠结环境和页面框架搭建的选择),当时在权限控制方面纠结于spring security和shiro,但是由于对这2个框架理解都不深,只是停留在基础的使用上面,而且一般的后台管理也用不了那么多的功能,所以思前想后还是决定自己做一套权限系统设计,第一方便扩展,第二自己做的也更熟悉,更方便做特定功能的定制。看本文之前可以先看看我做的简易开发模板框架,最好看完之后运行一下,或许更方便理解本文

需求明确

做什么事情之前首先要明确需求,以下就是我整理的我所需要的功能

一、控制用户在没有权限访问时,访问了URL会提示没有权限,没有登录时访问会跳转到登录页面
二、菜单要根据用户拥有的权限填充菜单(只出现用户有权限的菜单),包括页面
三、尽可能的不拦截静态资源,如:css、js、HTML等
四、有默认权限功能,用户一旦创建可以拥有默认的权限,而避免没有任何权限无法进入系统
五、权限以角色分组,用户可以拥有多个角色,权限累加
六、有超级用户,不用授予角色就能访问所有功能,超级用户通过特定标识来识别,标识只能通过修改数据来实现(为什么会有这个功能,是因为一旦权限开启了,默认是没有其他用户的,这时候需要用超级用户来进行创建用户和授权,当然也是方便系统数据错乱时能进入系统调整)
七、如果是直接打开的网址,在没有权限时要跳转页面,如果是Ajax请求,那么则需要返回对应没有权限的json

数据库设计

需求明确后就要开始数据库的设计了,这一步因工程而异,因为我只涉及后台,前端只是简单的展示,所以才直接设计数据库,如果是做APP这种,那么还是先把原型设计好,再来设计数据库,一共有5张表:

菜单表
用户表
角色表
角色与菜单关联表
用户与角色关联表

先放一张数据UML图

数据库UML图

首先先看菜单设计
--创建菜单表
DROP TABLE IF EXISTS menu;  
CREATE TABLE IF NOT EXISTS menu(  
    id INT NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    title VARCHAR(32) NOT NULL COMMENT '菜单名称',
    url VARCHAR(500) COMMENT '网址',
    icon VARCHAR(20) COMMENT '显示的图标',
    menu_type ENUM('0','1','2') NOT NULL DEFAULT '0' COMMENT '类型,0 菜单,1 连接网址,2 隐藏连接',
    display INT NOT NULL DEFAULT 1 COMMENT '显示排序',
    parent_id INT NOT NULL DEFAULT 0 COMMENT '父级的id,引用本表id字段',
    creator INT NOT NULL DEFAULT 0 COMMENT '创建者id,0为超级管理员',
    create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_user INT  COMMENT '更新者id',
    update_time TIMESTAMP NULL COMMENT '更新时间',
    status ENUM('0','1') NOT NULL DEFAULT '1' COMMENT '是否启用,0 禁用,1启用'
)ENGINE=InnoDB;

url 字段对应的是点击打开的网址,因为可能是父级菜单,所以可以为空,menu_type 对应的是菜单类型,0是菜单(指下面有子菜单的类型),链接网址就代表需要打开页面的菜单,这2个类型都是需要显示到菜单栏的(一般后台管理系统都有菜单栏),而隐藏链接呢就是不会显示在菜单栏,但是会显示在页面中或者页面中都不显示的,主要是针对页面中需要进入编辑页面和保存这类的,另外被禁用了的菜单也是不会显示到菜单列表的(超级用户除外),菜单是无限层级的

再看看角色表的设计
-- 创建角色表
DROP TABLE IF EXISTS role;  
CREATE TABLE IF NOT EXISTS role(  
  id INT NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '角色表主键,-1为默认角色',
  name VARCHAR(20) NOT NULL COMMENT '角色名称',
  create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  creator INT DEFAULT 0 COMMENT '创建的用户id',
  description VARCHAR(200) COMMENT '角色描述',
  update_user INT  COMMENT '更新者id',
    update_time TIMESTAMP NULL COMMENT '更新时间'
)ENGINE=InnoDB;

角色表就很简单了,唯一注意点就是当id为-1是属于默认角色,不管这个角色叫啥名字,都是属于默认角色,而且是在代码中控制的,也就是说用户不需要对默认角色进行赋予,接下来看角色和菜单的关联表

角色和菜单关联表
-- 创建角色与菜单(资源的关联表)
DROP TABLE IF EXISTS role_menu;  
CREATE TABLE IF NOT EXISTS role_menu(  
 roleid INT NOT NULL COMMENT '角色id',
 menuid INT NOT NULL COMMENT '菜单id',
 creator INT NOT NULL COMMENT '创建人,0为初始化',
 create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
)ENGINE=InnoDB;
ALTER TABLE role_menu add constraint PK01_role_menu primary key (menuid,roleid);  

角色和菜单关联表,简单来说就是用来控制那个角色可以访问那些菜单,以角色id和菜单id作为主键

用户表
-- 后台管理用户表
DROP TABLE IF EXISTS admin_user;  
CREATE TABLE IF NOT EXISTS admin_user(  
  id INT NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '用户表主键,id为-1则是超级用户',
  name VARCHAR(20) NOT NULL COMMENT '用户名',
  psw VARCHAR(32) NOT NULL COMMENT '用户密码MD5加密',
  email VARCHAR(32) NOT NULL COMMENT '用户邮箱',
  creator INT NOT NULL COMMENT '创建人,0为初始化',
  flag INT(1) NOT NULL DEFAULT 1 COMMENT '用户状态,1启用,0禁用',
  last_login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '最后登录时间',
  update_user INT  COMMENT '更新者id',
  update_time TIMESTAMP NULL COMMENT '更新时间'
)ENGINE=InnoDB;

这里和角色一样,id为-1的时候用户为超级用户

用户角色关联表
-- 创建用户与角色关联表
DROP TABLE IF EXISTS user_role;  
CREATE TABLE IF NOT EXISTS user_role(  
 userid INT NOT NULL COMMENT '用户id',
 roleid INT NOT NULL COMMENT '角色id',
 creator INT NOT NULL COMMENT '创建人,0为初始化',
 create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
)ENGINE=InnoDB;
ALTER TABLE user_role add constraint PK01_user_role primary key (userid,roleid);  

此表表明用户被授予了哪些角色,用户与角色属于多对多关系,用户id和角色id为联合主键

权限拦截流程设计

不管我们做什么开发,写代码之前画好流程图是有必要的,这样既能方便后期开发,也容易查出前期的设计是否有问题,极大的减少了后期修改的可能,下面就流程图

权限拦截流程图

接下来我就根据流程图的模块一块一块的进行开发就好了

用户登录

用户登录与普通用户登录一样,只是在登录成功后需要根据用户id查询出用户拥有的权限集合,并保存在session中(因为前端页面需要用到),同时查询用户的权限的时候需要判断用户的id是否为0,如果是0则查询出所有(只是查询出所有的菜单URL),下面是查询的sql语句

SELECT url FROM menu WHERE id IN(SELECT menuid FROM role_menu WHERE roleid IN(SELECT roleid FROM user_role WHERE userid=#{userid}) OR roleid=-1) AND `status`='1'"  

or roleid=-1 这是为了方便查询出默认的角色,而我们的拦截器主要是根据请求的URL拦截,所以我们这里只需要查询菜单的URL集合就行,而页面展示的菜单是需要在另外一个地方查询出来的

查询菜单的sql语句
SELECT id, name, url, icon, menu_type, display, parent_id FROM menu WHERE id IN(SELECT menuid FROM role_menu WHERE roleid IN (SELECT roleid FROM user_role WHERE userid=#{userid}) OR roleid=-1) AND menu_type<>'2' AND `status`='1'"  

查询出来后需要在代码中进行分级,一下是Java的实现

        HashMap<Integer,ArrayList<Menu>> map = new HashMap<Integer, ArrayList<Menu>>();
        List<Menu> tempMenus = null;
        if(userId == -1){
            MenuCriteria criteria = new MenuCriteria();
            criteria.createCriteria().andStatusEqualTo("1").andMenuTypeNotEqualTo("2");
            tempMenus = mapper.selectByExample(criteria);
        }else {
            tempMenus = mapper.selectByUser(userId);
        }
        for(Menu menu : tempMenus){
            int parentid = menu.getParentId();
            if(map.containsKey(parentid)){
                map.get(parentid).add(menu);
            }else{
                ArrayList<Menu> temp = new ArrayList<Menu>();
                temp.add(menu);
                map.put(menu.getParentId(),temp);
            }
        }
        for(Menu menu : tempMenus){
            int id = menu.getId();
            if(map.containsKey(id)){
                menu.setType("folder");
                menu.setChildren(map.get(id));
            }else{
                menu.setType("item");
            }
        }
        return map.get(0);

拦截器核心

根据前面的需求,所以我是直接采用的Spring的aop来进行拦截,这样做的好处就是不会拦截到页面和静态资源,只会对Controller进行拦截,进入拦截器中,首先会判断请求的URL是否是登录页面,如果是直接不拦截,如果不是则从session中获取当前登录的用户,然后判断是否为null,如果是null,则判断被调用的方法的返回值类型(因为没有找到获取方法有哪些注解方法,所以只能通过这种方法来曲线救国),如果是返回的String或者ModelAndView,则返回跳转到登录页面的ModelAndView,如果返回值是WebResult(指定的用@ResponseBody),则返回没有登录的json,如果是登录了,则判断是否为超级用户(超级用户无需拦截),不是则判断当前请求的URL是否在权限集合中(登录时查询出来的URL集合),如果没有在权限集合则同没有登录相同处理,只是返回的值不一样而已,如果有,则不拦截

前端不展示没有权限的链接

菜单本身是根据角色查询出来的,所以不会存在可见的菜单没有权限,但是页面中的一些链接,就需要进行控制了,而我采用的是用标签判断链接的URL或者按钮事件会请求的URL是否在权限集合中,如果存在则显示,不存在则不显示(是直接不会有这段HTML,不是简单的页面隐藏),至此一个简易的权限控制就设计完成了

结尾

最后附上几个存储过程

因为是无限级联的设计,所以如果删除菜单后没有删除子菜单,那么就会出现垃圾数据

删除菜单的存储过程
-- 删除菜单的存储过程
DROP PROCEDURE IF EXISTS `delete_menu`;  
CREATE  PROCEDURE `delete_menu`(IN `menuid` int)  
BEGIN

    DECLARE rowNUM INT DEFAULT 0;
    create temporary table if not exists menu_del_temp -- 不存在则创建临时表
  (
     id INT
  );
    create temporary table if not exists menu_del_temp2 -- 不存在则创建临时表
  (
     id INT
  );
create temporary table if not exists menu_del_temp3 -- 不存在则创建临时表  
  (
     id INT
  );
    TRUNCATE TABLE menu_del_temp2;
    TRUNCATE TABLE menu_del_temp; -- 清空临时表
        INSERT INTO menu_del_temp SELECT id FROM  menu where parent_id=menuid;
    -- DELETE FROM category WHERE ID IN (SELECT id FROM category_del_temp);
    INSERT INTO menu_del_temp2 SELECT id FROM  menu where parent_id IN (SELECT id FROM menu_del_temp);
    SELECT COUNT(id) INTO rowNUM FROM menu_del_temp2;
    WHILE rowNUM > 0 DO
        INSERT INTO menu_del_temp SELECT id FROM menu_del_temp2;
        TRUNCATE TABLE menu_del_temp3;
        INSERT INTO menu_del_temp3 SELECT id FROM menu_del_temp2;
        TRUNCATE TABLE menu_del_temp2;
        INSERT INTO menu_del_temp2 SELECT id FROM  menu where parent_id IN (SELECT id FROM menu_del_temp3);
        SELECT COUNT(id) INTO rowNUM FROM menu_del_temp2;
    END WHILE;
    INSERT INTO menu_del_temp(id) values(menuid);
    DELETE FROM menu WHERE id IN (SELECT id FROM menu_del_temp);
    DELETE FROM role_menu WHERE menuid IN (SELECT id FROM menu_del_temp);
END;  
更新角色菜单存储过程
DELIMITER $$

DROP function IF EXISTS `func_split_TotalLength` $$

CREATE FUNCTION `func_split_TotalLength`

(f_string varchar(1000),f_delimiter varchar(5)) RETURNS int(11)

BEGIN

    return 1+(length(f_string) - length(replace(f_string,f_delimiter,'')));

END$$

DELIMITER;  
-- 拆分传入的字符串,返回拆分后的新字符串
DELIMITER $$

DROP function IF EXISTS `func_split` $$

CREATE  FUNCTION `func_split`

(f_string varchar(1000),f_delimiter varchar(5),f_order int) RETURNS varchar(255) CHARSET utf8

BEGIN  
        declare result varchar(255) default '';

        set result = reverse(substring_index(reverse(substring_index(f_string,f_delimiter,f_order)),f_delimiter,1));

        return result;

END$$

DELIMITER;  
-- 更新角色权限的存储过程
delimiter $$  
DROP PROCEDURE IF EXISTS `role_menu_update` ;

CREATE PROCEDURE `role_menu_update`

(IN menuids varchar(3000),IN i_roleid INT,IN userid INT)

BEGIN

-- 拆分结果

DECLARE cnt INT DEFAULT 0;

DECLARE i INT DEFAULT 0;

SET cnt = func_split_TotalLength(menuids,',');  
DELETE FROM role_menu WHERE roleid = i_roleid;

WHILE i < cnt

DO

    SET i = i + 1;

    INSERT INTO role_menu(roleid,menuid,creator) VALUES (i_roleid,func_split(menuids,',',i),userid);

END WHILE;

END $$  
更新用户角色
-- 更新用户角色信息
delimiter $$  
DROP PROCEDURE IF EXISTS `user_role_update` ;

CREATE PROCEDURE `user_role_update`

(IN roleids varchar(3000),IN i_userid INT,IN i_creator INT)

BEGIN

-- 拆分结果

DECLARE cnt INT DEFAULT 0;

DECLARE i INT DEFAULT 0;

SET cnt = func_split_TotalLength(roleids,',');  
DELETE FROM user_role WHERE userid = i_userid;

WHILE i < cnt

DO

    SET i = i + 1;

    INSERT INTO user_role(userid,roleid,creator) VALUES (i_userid,func_split(roleids,',',i),i_creator);

END WHILE;

END $$  

另外附上本项目地址

github

oschina git

Copyright © 2017 Raye Blog

如果我的文章对你有帮助,或许可以打赏一下呀!

支付宝
微信