后台管理系统,特别是门户端涉及用户内容发布的网站时,后台管理系统有两块功能必不可少,一个是内容审查和反馈的功能,一个是菜单权限控制的功能。
以前在公司做权限控制做的都是前端方面的工作,对权限控制这块内容后台的业务逻辑不是很清楚,所以这里做个简要的demo来梳理下权限控制的整体业务逻辑。
基于我的理解,阐述下权限控制的业务逻辑:
从整体上来看,系统会将用户划分为不同的角色,管理员、运营人员、超级管理员、以及没有注册的浏览器用户。
从这些角色来看,不同的角色拥有不同的菜单展示权限,而这些不同的菜单中,有些菜单的权限仅是作为是否整体展示的权限,有些则是需要根据角色的不同对不同按钮甚至接口做权限验证,因此菜单下又需要对这些按钮进行权限控制。
业务逻辑上来说,前端需要根据后台返回的路由权限进行路由的动态添加,并对路由菜单中的权限按钮进行显示与隐藏,但前端的控制往往是不安全的,更重要的是后台还要对权限进行二次验证(防止有些用户利用第三方工具窃取普通管理员的信息去执行超级管理员的按钮权限)
业务逻辑说清楚了,接下来就是数据库设计,数据库设计必须有用户表、角色表、用户角色关联表、菜单表、菜单角色关联表。
用户表user:
js
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
return sequelize.define('user', {
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true
},
username: {
type: DataTypes.STRING(255),
allowNull: true
},
sex: {
type: DataTypes.INTEGER,
allowNull: true
},
phone: {
type: DataTypes.STRING(255),
allowNull: true
},
headimg: {
type: DataTypes.STRING(255),
allowNull: true
},
email: {
type: DataTypes.STRING(255),
allowNull: true
},
usersign: {
type: DataTypes.STRING(255),
allowNull: true
},
password: {
type: DataTypes.STRING(255),
allowNull: true
},
createtime: {
type: DataTypes.DATE,
allowNull: true
},
status: {
type: DataTypes.STRING(255),
allowNull: true
},
isauthor: {
type: DataTypes.INTEGER,
allowNull: true
},
modifytime: {
type: DataTypes.DATE,
allowNull: true
}
}, {
sequelize,
tableName: 'user',
timestamps: false,
indexes: [
{
name: "PRIMARY",
unique: true,
using: "BTREE",
fields: [
{ name: "id" },
]
},
]
});
};
用户角色关联表user_role:
js
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
return sequelize.define('user_role', {
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true
},
roleid: {
type: DataTypes.INTEGER,
allowNull: false
},
userid: {
type: DataTypes.STRING(100),
allowNull: false
},
createtime: {
type: DataTypes.DATE,
allowNull: true
}
}, {
sequelize,
tableName: 'user_role',
timestamps: false,
indexes: [
{
name: "PRIMARY",
unique: true,
using: "BTREE",
fields: [
{ name: "id" },
]
},
]
});
};
角色表role:
js
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
return sequelize.define('role', {
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true
},
rolename: {
type: DataTypes.STRING(100),
allowNull: false
},
describle: {
type: DataTypes.STRING(255),
allowNull: true
},
basetype: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
createtime: {
type: DataTypes.DATE,
allowNull: true
},
modifytime: {
type: DataTypes.DATE,
allowNull: true
},
rolecode: {
type: DataTypes.STRING(255),
allowNull: true
}
}, {
sequelize,
tableName: 'role',
timestamps: false,
indexes: [
{
name: "PRIMARY",
unique: true,
using: "BTREE",
fields: [
{ name: "id" },
]
},
]
});
};
菜单表menu:
js
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
return sequelize.define('menu', {
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true
},
pid: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
menutitle: {
type: DataTypes.STRING(255),
allowNull: true
},
menuname: {
type: DataTypes.STRING(255),
allowNull: true
},
menutype: {
type: DataTypes.STRING(255),
allowNull: true
},
url: {
type: DataTypes.STRING(255),
allowNull: true
},
sortnumber: {
type: DataTypes.INTEGER,
allowNull: true
},
icon: {
type: DataTypes.STRING(255),
allowNull: true
},
ishide: {
type: DataTypes.STRING(255),
allowNull: true
},
userid: {
type: DataTypes.INTEGER,
allowNull: true
},
createtime: {
type: DataTypes.DATE,
allowNull: true
},
modifytime: {
type: DataTypes.DATE,
allowNull: true
}
}, {
sequelize,
tableName: 'menu',
timestamps: false,
indexes: [
{
name: "PRIMARY",
unique: true,
using: "BTREE",
fields: [
{ name: "id" },
]
},
]
});
};
菜单表中:
menuname为菜单名
menucode为菜单标识,如果该菜单是按钮,编写格式最好按照这样的格式:菜单code:按钮code
比如:SysConfig:showlist,后面这个code,其实就是接口名,本质上来说,按钮其实就是接口,至于为什么最好采用这个格式,后面再说。
menutype为菜单类型,0为目录,1为菜单,2为按钮
并且我还加了userid,其实这个字段加或不加无所谓,可以记录下谁操作了菜单,但如果针对管理员设计的,其实意义不大。
菜单角色关联表rele_menu:
js
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
return sequelize.define('role_menu', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true
},
roleid: {
type: DataTypes.INTEGER,
allowNull: true
},
menuid: {
type: DataTypes.INTEGER,
allowNull: true
},
createtime: {
type: DataTypes.DATE,
allowNull: true
}
}, {
sequelize,
tableName: 'role_menu',
timestamps: false,
indexes: [
{
name: "PRIMARY",
unique: true,
using: "BTREE",
fields: [
{ name: "id" },
]
},
]
});
};
用户和角色是多对多的关系,角色与菜单也是多对多的关系。
接口编写:
1、添加菜单或修改saveMenu 首先创建一个admin的路由,并分发saveMenu
js
router.get('/admin/addMenu',[authMiddleware.auth],controller.addMenu);
(当然,这样写目前肯定是有问题的哈,因为这是admin管理员的操作,所以不能只通过authMiddleware.auth验证token是否有效,还要验证token所指定的用户是否有访问该接口的权限)
saveMenu的controller层:
js
async saveMenu(req,res){
try{
const {error,value} = Joi.object({
pid:Joi.number(),
menuname:Joi.string().required(),
menutitle:Joi.string().required(),
menutype:Joi.number().required(),
sortnumber:Joi.number().required()
}).unknown().validate(req.body)
if(error) return res.send(error)
let params = {
...value,
// createtime:new Date(),
modifytime:new Date(),
}
if(params.id){
params.createtime = new Date()
}
// return console.log(params)
let result = await adminDao.saveMenu(params)
res.send({
msg:'成功',
code:10000,
data:result
})
}catch(err){
console.log(err)
}
}
这里首先验证前端传递的参数,并转化参数的类型。
addMenu的dao层:
js
try{
let result = null;
if(params.id){
result = await Model.menu.update(params,{
where:{
id:params.id
}
})
}else{
result = await Model.menu.create(params)
}
return result
}catch(err){
console.log(err)
}
这里根据id的传入,判断是插入还是更新。
2、获取菜单列表
路由分发:
js
router.get('/admin/getMenuList',[authMiddleware.auth],controller.getMenuList);
getMenuListcontroller:
js
async getMenuList(params) {
try {
let sql = `
WITH RECURSIVE folder_recursion AS (
SELECT
id,
pid,
menutitle,
menuname,
menutype,
url,
sortnumber,
icon,
ishide,
0 AS depth
FROM
menu
WHERE
pid = 0
UNION ALL
SELECT
t.id,
t.pid,
t.menutitle,
t.menuname,
t.menutype,
t.url,
t.sortnumber,
t.icon,
t.ishide,
folder_recursion.depth + 1
FROM
menu t
INNER JOIN
folder_recursion ON t.pid = folder_recursion.id
)
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (ORDER BY sortnumber DESC) AS row_num
FROM
folder_recursion
) AS sub_query
WHERE 1=1
`;
// 先查询总量是否不为零
let countsql = `SELECT COUNT(id) as total FROM menu where pid = 0`;
let [count, meta] = await Model.sequelize.query(countsql);
let total = count[0]?.total;
// 查询总数大于0方可查询列表
if (total > 0) {
// 继续分页
sql += `and row_num BETWEEN ${
(Number(params.pagenum) - 1) * Number(params.pagesize)
} AND ${Number(params.pagesize)} `;
sql += ` ORDER BY sortnumber desc`;
let [results] = await Model.sequelize.query(sql);
results = utils.convertToNestedStructure(results, {
listname: "children",
});
return {
menuList: results,
page: {
pagenum: params.pagenum,
pagesize: params.pagesize,
total: total,
},
};
} else {
// tatol为0,没有评论
return {
menuList: [],
page: {
pagenum: params.pagenum,
pagesize: params.pagesize,
total: 0,
},
};
}
} catch (err) {
console.log(err);
}
},
这里分页其实挺没有意义的,递归查询的分页需要记录遍历的pid为0的数量,然后进行分页,但是如果不分页,菜单页面看起来就乱糟糟的,分页后,前端获取数据:
3、删除菜单
菜单删除就非常简单了,拿到id去做删除就可以了,这里就不贴逻辑代码了,但是要注意,删除某个菜单,需要把其子菜单全部删除,贴个核心业务代码:
js
let sql = `
WITH RECURSIVE cte AS (
SELECT id FROM menu WHERE id = :id
UNION ALL
SELECT t.id FROM menu t INNER JOIN cte ON t.pid = cte.id
)
DELETE FROM menu WHERE id IN (SELECT id FROM cte);`
return await Model.sequelize.query(sql,{
replacements:{
id:params.id
}
})
那么菜单的增删改查操作就完成了,接下来是角色,角色的增删改查就更简单了,没有嵌套结构,都是扁平化的。
角色属于极少量的数据,分页没有必要。
需要注意的是,一般来说每个系统都会有内置角色,即basetype为0的角色,basetype不为0的角色,记录的值就是userid,也就是谁添加的,归属为0的这类角色是数据库直添的,而非接口添加的,这类角色被删除可能会引发系统一系列问题,所以前端需要做控制不允许用户删除,后台做删除时也需要控制,在删除时做做两层判断,一个是id,一个是bastype不为0。
核心代码如下:
js
return Model.role.destroy({
where:{
id:params.id,
basetype: {
[Model.Sequelize.Op.ne]: 0
}
}
})
同样的,编辑时也要加上这个判断,防止被直接通过调用接口的方式误改。
效果如下:
角色的增删改查就完成了。
接下来就是权限分配,即给角色分配权限。
页面结构大概是这样,左侧选角色,右侧就是权限树,后面的权限树的接口还没有写:
1、获取权限树接口
其实这个权限树不用把它想太复杂,其实就是菜单树加上role_menu,调接口去查询这个角色的role_menu,把角色所拥有的菜单id给拿出来即可。
2、保存和修改权限
核心代码如下:
js
async saveRoleMenu(params){
let newPush = []
// 先删
await Model.role_menu.destroy({
where:{
roleid:params.roleid
}
})
// 再添加
params.menuids.length&¶ms.menuids.forEach(item=>{
newPush.push({
roleid:params.roleid,
createtime:new Date(),
menuid:item.menuid
})
})
return Model.role_menu.bulkCreate(newPush)
}
这个关系每次都是先删,然后重新批量插入,这样之后,角色权限的状态就保存了,效果如下:
至于下面初始化的功能,就是前端控制,重新请求权限列表即可。
现在后台的逻辑,已经完成了菜单、角色和权限分配的功能了,现在只需要给用户绑定角色,权限功能大体上就完成了。
接下来是用户列表,获取用户列表:
js
try{
let baseSql = `select *,
(SELECT GROUP_CONCAT(user_role.roleid) from user_role WHERE user_role.userid = user.id) albumids
from user where 1=1 `
let countsql = `SELECT COUNT(id) as total FROM user where 1=1 `
if(params.search) {
baseSql += `and username like :search `
countsql+= `and username like :search `
}
if(params.roleid){
baseSql += ` and id IN (
SELECT userid
FROM user_role
WHERE roleid = :roleid
)`
countsql += ` and id IN (
SELECT userid
FROM user_role
WHERE roleid = :roleid
)`
}
let [count,meta] = await Model.sequelize.query(countsql,{
replacements:{
search:params.search,
roleid:params.roleid
}
})
let total = count[0]?.total
if(total>0){
baseSql += ` ORDER BY id desc limit :start,:end`
let [results,metadata] = await Model.sequelize.query(baseSql,{
replacements:{
search:params.search,
roleid:params.roleid,
start:(Number(params.pagenum) - 1) * Number(params.pagesize),
end:Number(params.pagesize)
}
})
return {
userList:results,
page:{
pagesize:params.pagesize,
pagenum:params.pagenum,
total
}
}
}else{
return {
userList:[],
page:{
pagesize:params.pagesize,
pagenum:params.pagenum,
total
}
}
}
}catch(err){
console.log(err)
}
前端调用接口,获取的数据结构是没问题的,一个分页参数,一个列表:
前端效果:
查询角色,功能正常:
模糊查询有问题,前面少加了%,改正后,功能都没啥问题了。
然后是其他功能,对于新增用户,原则上管理员不允许新增用户,但可以删除用户,或者对某些用户进行禁用处理,但这个功能跟目前权限业务逻辑也没啥关系,所以暂时先不做。
接下来核心的业务,就是绑定角色关系(刚才那个超级管理员的角色是我直接在数据库添加的)
绑定角色的逻辑和绑定权限的逻辑是一样的,通过user_role进行关联,每次更新角色关系,逻辑都是先删角色关系表,再重新绑定关系。
绑定角色后,前端效果如下:
至此,权限的功能就基本完成了,现在就是添加菜单数据,前端做动态的路由加载,增加按钮权限,控制接口的访问。
菜单的拦截,就是看这个页面是否需要展示,我们可以直接查询用户角色下的所有菜单,然后前端做addRoutes动态加载。
比如以用户列表菜单而言,我们可以控制哪些角色是可以访问这个列表页面的,哪些角色是可以访问列表页面并可以删除用户的。
比如现在,我们用系统管理员角色的权限,给超级管理员增加权限菜单:
那么切换超级管理员角色,那么菜单将会动态加载,并可以进行访问:
没有获取这个权限列表,前端路由就没有加载,直接通过url访问即空白页面。
然后就是按钮权限的用法,按钮权限该如何去控制呢?
其实最基本的想法还是通过中间件拦截,通过中间件去获取用户的信息来验证用户权限。
我们可以构造一个装饰器,当访问到需要有权限的接口时,触发装饰器并执行相应的权限控制逻辑。
可以在根目录中,创建文件夹middlevare,作为统一的中间件控制。
这里面有我之前写过的验证token的权限逻辑:
js
const jwtService = require('../service/jwtService/index')
const path = require('path')
// const Model = require('../../../model')
const Model = require(path.resolve('./model/index'))
// 全局中间件
module.exports = {
async auth(req, res, next) {
let token = req.headers['authorization']
if (!token) {
res.send({
code: 400,
msg: "没有token"
})
} else {
let user = await jwtService.varifyToken(token)
if(user.id){
if(req.method=='POST'){
req.body.userid = user.id
}else{
req.query.userid = user.id
}
next()
}else{
res.send({
code:400,
msg:'用户没有查看权限'
})
}
}
},
}
按钮权限可以在这里做集中配置,将需要有权限访问的接口配置在这里,当中间件走到这里时,我们就拿解析用户token后的userid,以及这个权限按钮code去查用户有没有访问这个接口的权限,有才进入下一个中间件。
以用户管理页面为例,给获取用户列表加上菜单权限控制。
新增一个按钮权限,并将按钮权限去掉。
然后在刚才的中间件中增加如下代码:
首先是配置权限接口列表:
js
let primssion = [
{
api:'/admin/getUserList',
code:'SysRole:list'
},
{
api:'/dgdgdg',
code:'test'
}
]
其次,在解析token之后,匹配接口code,如果匹配到,则说明需要权限,则要去查询用户是否有这个权限访问:
js
let matchedItem = primssion.find(item => item.api === req.path);
if(matchedItem){
// 这里做权限验证
let result = await authDao.checkPrimssion(user.id,matchedItem.code)
if(result){
next()
}else{
res.send({
msg:'没有权限'
})
}
查询的代码如下:
js
async checkPrimssion(userid,meuncode){
console.log(userid,meuncode)
let sql = `select user_role.* from menu
left join role_menu on role_menu.menuid = menu.id
left join user_role on user_role.roleid = role_menu.roleid
WHERE menu.menuname = :meuncode
and user_role.userid = :userid`
let [results] = await Model.sequelize.query(sql,{
replacements:{
userid:userid,
meuncode:meuncode
}
})
if(results.length) return true
else return false
}
逻辑处理完成,现在回去访问用户列表页面:
按钮权限拦截成功。
那么将按钮权限加回去:
查询成功:
权限控制的核心逻辑其实基本也就这些,目前来说还不太完善,后续再优化优化。