前言 因为想做一个快速的后台开发模板框架(方便以后直接开发功能而不用纠结环境和页面框架搭建的选择),当时在权限控制方面纠结于spring security和shiro,但是由于对这2个框架理解都不深,只是停留在基础的使用上面,而且一般的后台管理也用不了那么多的功能,所以思前想后还是决定自己做一套权限系统设计,第一方便扩展,第二自己做的也更熟悉,更方便做特定功能的定制。看本文之前可以先看看我做的简易开发模板框架,最好看完之后运行一下,或许更方便理解本文
需求明确 做什么事情之前首先要明确需求,以下就是我整理的我所需要的功能
一、控制用户在没有权限访问时,访问了URL会提示没有权限,没有登录时访问会跳转到登录页面 二、菜单要根据用户拥有的权限填充菜单(只出现用户有权限的菜单),包括页面 三、尽可能的不拦截静态资源,如:css、js、HTML等 四、有默认权限功能,用户一旦创建可以拥有默认的权限,而避免没有任何权限无法进入系统 五、权限以角色分组,用户可以拥有多个角色,权限累加 六、有超级用户,不用授予角色就能访问所有功能,超级用户通过特定标识来识别,标识只能通过修改数据来实现(为什么会有这个功能,是因为一旦权限开启了,默认是没有其他用户的,这时候需要用超级用户来进行创建用户和授权,当然也是方便系统数据错乱时能进入系统调整) 七、如果是直接打开的网址,在没有权限时要跳转页面,如果是Ajax请求,那么则需要返回对应没有权限的json 数据库设计 需求明确后就要开始数据库的设计了,这一步因工程而异,因为我只涉及后台,前端只是简单的展示,所以才直接设计数据库,如果是做APP这种,那么还是先把原型设计好,再来设计数据库,一共有5张表:
菜单表 用户表 角色表 角色与菜单关联表 用户与角色关联表 先放一张数据UML图
首先先看菜单设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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个类型都是需要显示到菜单栏的(一般后台管理系统都有菜单栏),而隐藏链接呢就是不会显示在菜单栏,但是会显示在页面中或者页面中都不显示的,主要是针对页面中需要进入编辑页面和保存这类的,另外被禁用了的菜单也是不会显示到菜单列表的(超级用户除外),菜单是无限层级的
再看看角色表的设计
1 2 3 4 5 6 7 8 9 10 11 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是属于默认角色,不管这个角色叫啥名字,都是属于默认角色,而且是在代码中控制的,也就是说用户不需要对默认角色进行赋予,接下来看角色和菜单的关联表
角色和菜单关联表
1 2 3 4 5 6 7 8 9 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作为主键
用户表
1 2 3 4 5 6 7 8 9 10 11 12 13 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的时候用户为超级用户
用户角色关联表
1 2 3 4 5 6 7 8 9 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语句
1 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语句
1 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的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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,不是简单的页面隐藏),至此一个简易的权限控制就设计完成了
结尾 最后附上几个存储过程
因为是无限级联的设计,所以如果删除菜单后没有删除子菜单,那么就会出现垃圾数据
删除菜单的存储过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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; 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 ;
更新角色菜单存储过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 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 $$
更新用户角色
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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