简易的后台管理权限设计

前言

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

需求明确

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

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

数据库设计

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

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

先放一张数据UML图

数据库UML图

首先先看菜单设计
COPY
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个类型都是需要显示到菜单栏的(一般后台管理系统都有菜单栏),而隐藏链接呢就是不会显示在菜单栏,但是会显示在页面中或者页面中都不显示的,主要是针对页面中需要进入编辑页面和保存这类的,另外被禁用了的菜单也是不会显示到菜单列表的(超级用户除外),菜单是无限层级的

再看看角色表的设计
COPY
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是属于默认角色,不管这个角色叫啥名字,都是属于默认角色,而且是在代码中控制的,也就是说用户不需要对默认角色进行赋予,接下来看角色和菜单的关联表

角色和菜单关联表
COPY
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作为主键

用户表
COPY
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的时候用户为超级用户

用户角色关联表
COPY
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语句

COPY
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语句
COPY
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的实现

COPY
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,不是简单的页面隐藏),至此一个简易的权限控制就设计完成了

结尾

最后附上几个存储过程

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

删除菜单的存储过程
COPY
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;
-- 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;
更新角色菜单存储过程
COPY
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 $$
更新用户角色
COPY
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

Authorship: 作者
Article Link: https://raye.wang/2017/04/19/%E7%AE%80%E6%98%93%E7%9A%84%E5%90%8E%E5%8F%B0%E7%AE%A1%E7%90%86%E6%9D%83%E9%99%90%E8%AE%BE%E8%AE%A1/
Copyright: All posts on this blog are licensed under the CC BY-NC-SA 4.0 license unless otherwise stated. Please cite Raye Blog !