全栈 RBAC 实战 (12):AOP 切面日志与自定义指令 v-hasPermi

55 阅读6分钟

到目前为止,我们的系统已经具备了完整的功能(菜单、路由、页面)。但是,作为一个安全可追溯的企业级系统,我们还缺两样东西:

  1. 细粒度的权限控制:不能只防“进页面”,还要防“点按钮”。
  2. 操作日志:谁在什么时候删除了哪条数据?这是出了事故甩锅(划掉)定责的关键证据。

摘要:权限控制不仅要控制“能不能看”,还要控制“能不能动”。本文将深入 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。

核心逻辑

  1. 获取指令传过来的值(如 ['system:user:add'])。
  2. 获取 Store 里的用户拥有的所有权限。
  3. 比对:如果有权限,什么都不做;如果没有权限,找到该元素的父节点,把该元素删掉
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. 验证按钮权限

  1. 登录 Admin 账号,进入“菜单管理”。
  2. 找到“用户管理” -> “用户新增”,修改它的权限字符为 system:user:test (故意改错)。
  3. 刷新页面,进入“用户管理”页面。
  4. 你会发现**“新增用户”按钮不见了!**
  5. 改回 system:user:add,刷新页面,按钮回来了。

2. 验证操作日志

  1. 在“用户管理”里随便新增一个用户,再删除一个用户。

  2. 查看数据库表 sys_oper_logs。

  3. 你应该能看到两条新记录:

    • 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

前端:前端

后端:后端