到目前为止,我们的系统已经具备了完整的功能(菜单、路由、页面)。但是,作为一个安全且可追溯的企业级系统,我们还缺两样东西:
- 细粒度的权限控制:不能只防“进页面”,还要防“点按钮”。
- 操作日志:谁在什么时候删除了哪条数据?这是出了事故甩锅(划掉)定责的关键证据。
摘要:权限控制不仅要控制“能不能看”,还要控制“能不能动”。本文将深入 Vue 3 自定义指令原理,封装 v-hasPermi 实现按钮级权限屏蔽。同时,在后端 Node.js 中,我们将利用“中间件”模拟 AOP(面向切面编程)思想,实现非侵入式的操作日志记录,让每一次增删改查都有迹可循。
一、 引言:颗粒度与痕迹
在之前的“菜单管理”中,我们定义了 system:user:add 这样的权限字符。现在,它们终于要派上用场了。
- 前端:如果用户没有 system:user:add 权限,页面上的“新增用户”按钮应该直接移除(Remove from DOM),防止恶意点击。
- 后端:当用户真的调用了删除接口,我们需要自动记录:张三 (IP: 192.168.1.5) 在 2023-10-27 10:00 删除了 ID=5 的用户。
二、 前端实现:按钮级权限控制
Vue 提供了强大的自定义指令 (Custom Directives) 功能,非常适合做这种 DOM 操作。
1. Store 改造:存储权限字符
首先,我们要确保登录或拉取用户信息时,把权限字符存到了 Pinia 里。
修改 store/modules/user.ts:
export const useUserStore = defineStore('user', {
// 1. State: 定义数据状态
state: () => ({
token: localStorage.getItem('token') || '',
// 用户信息初始为空
userInfo: {
username: '',
nickname: '',
roles: [] as string[],
permissions: [] as string[],
avatar: '',
},
// 标记用户信息是否已拉取
isInfoLoaded: false,
}),
actions:{
async getInfo() {
try {
const res: any = await getUserInfo()
this.userInfo = res.data
localStorage.setItem('userInfo', JSON.stringify(res.data))
this.isInfoLoaded = true
return res
} catch (error) {
return Promise.reject(error)
}
},
}
2. 创建指令 v-hasPermi
新建文件 src/directive/permission/index.ts。
核心逻辑:
- 获取指令传过来的值(如 ['system:user:add'])。
- 获取 Store 里的用户拥有的所有权限。
- 比对:如果有权限,什么都不做;如果没有权限,找到该元素的父节点,把该元素删掉
import { useUserStore } from '@/store/modules/user'
export default {
mounted(el: HTMLElement, binding: any) {
const { value } = binding
const all_permission = '*:*:*'
const userStore = useUserStore()
const permissions = userStore.userInfo.permissions
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value
const hasPermission = permissions.some((permission: string) => {
return (
permissionFlag.includes(permission) || all_permission === permission
)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
} else {
throw new Error(`需要权限标签,如 v-hasPermi="['sys:user:add']"`)
}
},
}
3. 全局注册指令
在 src/main.ts 中注册它,这样全项目都能用。
import { createApp } from 'vue'
import App from './App.vue'
import permission from './directive/permission' // 引入
const app = createApp(App)
// 注册自定义指令
app.directive('hasPermi', permission)
app.mount('#app')
4. 实战使用
回到 views/system/user.vue,给按钮加上“锁”。
<template>
<!-- 只有拥有新增权限的人,才能看到这个按钮 -->
<el-button
type="primary"
icon="Plus"
@click="handleAdd"
v-hasPermi="['system:user:add']"
>
新增用户
</el-button>
<el-table-column label="操作">
<template #default="scope">
<el-button
link
type="primary"
@click="handleEdit(scope.row)"
v-hasPermi="['system:user:edit']"
>
修改
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row)"
v-hasPermi="['system:user:remove']"
>
删除
</el-button>
</template>
</el-table-column>
</template>
三、 后端实现:AOP 切面日志
在 Java Spring 中我们通常用 @Log 注解来实现。在 Node.js Express 中,我们可以利用 中间件 (Middleware) 的洋葱模型特性来实现类似的效果
1. 数据库设计
我们需要一张表来存日志。
CREATE TABLE `sys_oper_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(50) DEFAULT '' COMMENT '模块标题',
`business_type` varchar(20) DEFAULT '' COMMENT '业务类型(0其它 1新增 2修改 3删除)',
`method` varchar(10) DEFAULT '' COMMENT '方法名称',
`oper_name` varchar(50) DEFAULT '' COMMENT '操作人员',
`oper_url` varchar(255) DEFAULT '' COMMENT '请求URL',
`oper_ip` varchar(128) DEFAULT '' COMMENT '主机地址',
`status` tinyint(1) DEFAULT 0 COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) DEFAULT '' COMMENT '错误消息',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志记录';
2. 封装 Log 中间件
新建 middleware/log.js。这是后端部分最精彩的代码。
它是一个高阶函数,接收模块名(title)和类型(businessType),返回一个标准的中间件函数。
import { pool } from '../db/mysql.js'
// 业务类型枚举(给其他文件引用)
export const BusinessType = {
OTHER: 0,
INSERT: 1,
UPDATE: 2,
DELETE: 3,
GRANT: 4, // 授权
EXPORT: 5,
IMPORT: 6,
}
/**
* 日志中间件工厂函数
* @param {string} title 模块标题 (如 "用户管理")
* @param {number} businessType 业务类型 (如 BusinessType.INSERT)
*/
export const log = (title, businessType) => {
return async (req, res, next) => {
// 1. 准备日志基础数据
const startTime = Date.now()
let status = 0 // 0=成功, 1=失败
let errorMsg = ''
let jsonResult = ''
// 获取当前操作人 (auth中间件已经把 user 挂载在 req 上了)
const operName = req.user ? req.user.username : '未知/未登录'
const operIp =
req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress
const operUrl = req.originalUrl
const requestMethod = req.method
console.log('【操作日志】 ip', operIp)
console.log('【操作日志】 url', operUrl)
// 2. 劫持 res.json 方法,以便捕获后端返回给前端的数据
const originalJson = res.json
res.json = function (data) {
// 记录返回结果
try {
jsonResult = JSON.stringify(data).substring(0, 2000) // 截断防止过长
// 约定:如果 code 不为 200,则视为操作失败
if (data.code !== 200) {
status = 1
errorMsg = data.message || '系统错误'
}
} catch (e) {
// 忽略序列化错误
}
// 执行原有的 json 方法返回数据给前端
return originalJson.apply(this, arguments)
}
// 3. 监听请求完成事件 (finish),异步写入数据库
res.on('finish', async () => {
try {
// 拼装请求参数 (Body, Query, Params 混在一起存)
const paramsObj = { ...req.query, ...req.params, ...req.body }
// 敏感数据过滤:密码不存
if (paramsObj.password) paramsObj.password = '******'
const operParam = JSON.stringify(paramsObj).substring(0, 2000)
const sql = `
INSERT INTO sys_oper_log
(title, business_type, request_method, oper_name, oper_url, oper_ip, oper_param, json_result, status, error_msg)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const values = [
title,
businessType,
requestMethod,
operName,
operUrl,
operIp,
operParam,
jsonResult,
status,
errorMsg,
]
await pool.query(sql, values)
} catch (err) {
console.error('【操作日志】写入失败:', err)
}
})
next()
}
}
3. 应用日志中间件
现在,我们把这个“记录仪”挂载到具体的路由上。
打开 routes/user.js:
import { log } from '../middleware/log.js' // 引入
// 新增用户
router.post('/',
authMiddleware,
log('用户管理', '新增'), // <--- 插入这里!这就是 AOP
async (req, res, next) => {
// ... 原有的业务逻辑 ...
}
)
// 删除用户
router.delete('/:id',
authMiddleware,
log('用户管理', '删除'), // <--- 插入这里
async (req, res, next) => {
// ...
}
)
同理,你可以给 routes/role.js, routes/menu.js 的增删改接口都加上这个中间件。
四、 测试与验证
1. 验证按钮权限
- 登录 Admin 账号,进入“菜单管理”。
- 找到“用户管理” -> “用户新增”,修改它的权限字符为 system:user:test (故意改错)。
- 刷新页面,进入“用户管理”页面。
- 你会发现**“新增用户”按钮不见了!**
- 改回 system:user:add,刷新页面,按钮回来了。
2. 验证操作日志
-
在“用户管理”里随便新增一个用户,再删除一个用户。
-
查看数据库表 sys_oper_logs。
-
你应该能看到两条新记录:
- Title: 用户管理, Type: 新增, User: admin, Status: 0
- Title: 用户管理, Type: 删除, User: admin, Status: 0
五、 总结与下篇预告
通过自定义指令和中间件,我们以最小的代码侵入性(Low Coupling),实现了强大的安全控制和审计功能。这在企业级开发中是必不可少的基石。
到目前为止,代码层面的核心功能已经开发完毕。我们的系统已经在本地跑得很欢了,但如何把它搬到云服务器上,让全世界都能访问呢?
下一篇(终章):《全栈 RBAC 实战 (13):阿里云 Ubuntu + Nginx + PM2 + HTTPS 完整部署指南》 。
我将手把手教你购买服务器(或者用虚拟机模拟)、安装 Node 环境、配置 MySQL、打包前端 Vue、使用 PM2 守护后端进程,并配置 Nginx 反向代理和 SSL 证书,完成最后的“上线仪式”!
具备完整功能后台管理系统的代码仓库:
感谢大家的star
前端:前端
后端:后端