Vue3+NestJS实现权限管理系统(五):基于RBAC 权限控制实现

RBAC 简介

RBAC 全称为 Role Based Access Control(基于角色的权限控制)。顾名思义它是将权限分配给角色,然后将角色分配给用户,那么用户就拥有了这个角色的所有权限。

创建表

了解了什么是 RBAC,接下来我们需要做到是如何将其和我们的后台管理系统相结合。首先我们需要知道在我们的项目中权限指的是什么?

我们都知道,在后台管理系统中有很多菜单,而不同用户登录后展现的菜单可能并不一样,因为他们的角色不同,角色不同意味着权限不一样,所以说在我们项目中菜单就是权限。除此之外,还有一些按钮展示也不一样,所以它们也是权限。为了方便起见,我们只需要创建一张菜单(Menu)表,它包含目录,菜单,按钮三种类型,我们需要什么就根据什么条件查询即可。接下来我们就生成一个菜单模块,并创建一张名为fs_menu的数据表。

这里直接使用nest g res menu生成一个菜单模块,新建entities/menu.entity.ts目录用于存放实体

js 复制代码
import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

@Entity("fs_menu")
export class Menu {
  @PrimaryGeneratedColumn()
  id: number;
  //标题
  @Column({
    length: 20,
  })
  title: string;
  //排序
  @Column()
  order_num: number;
  //父id
  @Column({ nullable: true })
  parent_id: number;

  //菜单类型 1:目录,2:菜单,3:按钮
  @Column()
  menu_type: number;
  //菜单图标
  @Column({
    length: 50,
    nullable: true,
  })
  icon: string;

  //组件路径
  @Column({
    length: 50,
    nullable: true,
  })
  component: string;

  //权限标识
  @Column({
    length: 50,
    nullable: true,
  })
  permission: string;
  //路由
  @Column({
    length: 50,
  })
  path: string;

  @Column({
    type: "bigint",
  })
  create_by: number;

  //状态 1:启用 0:禁用
  @Column({
    default: 1,
  })
  status: number;

  @CreateDateColumn()
  create_time: Date;

  @UpdateDateColumn()
  update_time: Date;
}

回到app.module.ts中,前面我们每创建一个表都需要导入一次实体,很麻烦。这里我们设置一下 entities: ['**/*.entity.js'],autoLoadEntities: true, 就可以帮我们自动寻找后缀为.entity.js文件自动加载创建表了

启动项目,你就会发现fs_menu表就已经自动创建好啦

同样的角色模块及角色表fs_role也一样创建

js 复制代码
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";
import { Menu } from "../../menu/entities/menu.entity";
@Entity("fs_role")
export class Role {
  @PrimaryGeneratedColumn({
    type: "bigint",
  })
  id: number;
  //角色名
  @Column({
    length: 20,
  })
  role_name: string;
  //排序
  @Column()
  role_sort: number;
  //角色状态 启用:1 关闭:0
  @Column({
    default: 1,
  })
  status: number;
  //备注
  @Column({ length: "100", nullable: true })
  remark: string;
  //创建人Id
  @Column({
    type: "bigint",
  })
  create_by: number;
  //更新人Id
  @Column({
    type: "bigint",
  })
  update_by: number;

  @CreateDateColumn()
  create_time: Date;

  @UpdateDateColumn()
  update_time: Date;

  @ManyToMany(() => Menu)
  @JoinTable({
    name: "fs_role_menu_relation",
  })
  menus: Menu[];
}

这里我们使用了@ManyToMany(() => Menu)来和菜单表实现了多对多的关系,因为一个角色可以有多个菜单,同样的,一个菜单也可以有多个角色。当然我们这里暂时只用到一个角色包含多个菜单的情况。启动项目后,nest 会为我们创建一个fs_role表,同时也创建了一个fs_role_menu_relation关系表,这个表的作用就是将角色和菜单关联起来。譬如:

角色 A(id 为 2)拥有菜单 1(id 为 1)、菜单 2(id 为 2)、和菜单 3(id 为 3),那么关系表就如下

除了角色和菜单,用户和角色也是多对多的关系,所以我们需要调整一下用户表的实体(user/entities/user.entity.ts)

js 复制代码
import {
  Column,
  Entity,
  PrimaryGeneratedColumn,
  BeforeInsert,
  ManyToMany,
  JoinTable,
} from "typeorm";
import encry from "../../utils/crypto";
import * as crypto from "crypto";
import { Role } from "src/role/entities/role.entity";
@Entity("fs_user")
export class User {
  @PrimaryGeneratedColumn({
    type: "bigint",
  })
  id: number; // 标记为主键,值自动生成

  @Column({ length: 30 })
  username: string; //用户名
  @Column({ nullable: true })
  nickname: string; //昵称
  @Column()
  password: string; //密码
  @Column({ nullable: true })
  avatar: string; //头像
  @Column({ nullable: true })
  email: string; //邮箱

  @Column({ nullable: true })
  salt: string;

  @ManyToMany(() => Role)
  @JoinTable({
    name: "fs_user_role_relation",
  })
  roles: Role[];
  @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
  create_time: Date;

  @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
  update_time: Date;
  @BeforeInsert()
  beforeInsert() {
    this.salt = crypto.randomBytes(4).toString("base64");
    this.password = encry(this.password, this.salt);
  }
}

ok,到这里我们就将这三张表创建完毕,并将他们的关系通过关系表进行了关联。接下来我们就写几个新增接口添加一些数据拥有调试

新增菜单

下面我们来开发一个新增菜单的接口,然后将系统管理角色管理菜单管理用户管理新增到菜单表中。他们的关系结构如下

ini 复制代码
--系统管理;
  --用户管理;
  --菜单管理;
  --角色管理;

首先我们需要在 menu 模块新建dto/create-menu.dto.ts来规定前端传来的参数

js 复制代码
import { IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateMenuDto {
  @IsNotEmpty({ message: '菜单名不可为空' })
  @ApiProperty({
    example: '菜单1',
  })
  title: string;

  @ApiProperty({
    example: 1,
  })
  order_num: number;

  @ApiProperty({
    example: 1,
    required: false,
  })
  @IsOptional()
  parent_id?: number;

  @ApiProperty({
    example: 1,
  })
  menu_type: number;
  @ApiProperty({
    example: 'menu',
  })
  icon: string;

  @IsOptional()
  @ApiProperty({
    example: 'AA/BB',
    required: false,
  })
  component?: string;

  @IsNotEmpty({ message: '路由不可为空' })
  @ApiProperty({
    example: 'BB',
  })
  path: string;
  @ApiProperty({
    example: 11,
  })
  create_by: number;

  @IsOptional()
  @ApiProperty({
    example: 'sys:post:list',
    required: false,
  })
  permission?: string;
}

里面用到很多装饰器比如@IsOptional:规定参数可选,@IsNotEmpty:参数不能为空等等。

定义完后在menu.module.ts引入menu的实体并注入

然后在menu.service.ts通过@InjectRepository装饰器中注入到参数中就可以操作这个表了

最后写一个创建菜单的函数

js 复制代码
async createMenu(createMenuDto: CreateMenuDto) {
    try {
      await this.menuRepository.save(createMenuDto);
      return '菜单新增成功';
    } catch (error) {
      throw new ApiException('菜单新增失败', 20000);
    }
  }

menu.controller.ts中定义一个路由调用

js 复制代码
import { Body, Controller, Post } from '@nestjs/common';
import { MenuService } from './menu.service';
import {
  ApiOperation,
  ApiTags,
  ApiOkResponse,
  ApiParam,
} from '@nestjs/swagger';
import { CreateMenuDto } from './dto/create-menu.dto';
import { Public } from 'src/public/public.decorator';
import { Request } from '@nestjs/common';
@Controller('menu')
@ApiTags('菜单权限模块')
export class MenuController {
  constructor(private readonly menuService: MenuService) {}
  @Post('/createMenu')
  @Public()
  @ApiParam({ name: 'createMenuDto', type: CreateMenuDto })
  @ApiOperation({ summary: '新增菜单' })
  async createMenu(
    @Body()
    createMenuDto: CreateMenuDto,
  ) {
    return await this.menuService.createMenu(createMenuDto);
  }
}

为了方便测试,我们暂时加了@Public装饰器,这样就不会进行 token 验证了 ,同时像@ApiParam、 @ApiOperation等装饰器都是swagger文档相关装饰器,用于接口文档的展示,当然如果你不需要也可以不写

最后我们通过工具(这里用的是apifox)直接调用http://localhost:3000/menu/createMenu并传入相关参数就能新增菜单了,比如新增用户管理菜单

其中parent_id是父级目录(系统管理的 id),最终我们操作完成后表是这样的

我们需要注意的是最上层的目录的parent_id是为null的,因为它没有父级比如系统管理

新增角色、用户

接下来我们再写一个新增角色的接口,并为这些角色赋予一些菜单权限。这里需要注意一个特别的角色超级管理员,我们默认这个角色拥有所有的权限

和上面一样,先创建dto规定前端传的参数

js 复制代码
import { IsArray, IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateRoleDto {
  @IsNotEmpty({ message: '角色名不可为空' })
  @ApiProperty({
    example: '技术人员',
  })
  role_name: string;

  @ApiProperty({
    example: '备注',
    required: false,
  })
  @IsOptional()
  remark?: string;

  @IsNotEmpty({ message: '角色状态不可为空' })
  @ApiProperty({
    example: 1,
    description: '角色状态,1表示启用,0表示禁用',
  })
  status: number;

  @ApiProperty({
    example: [1],
    required: false,
  })
  @IsOptional()
  @IsArray({
    message: 'role_ids必须是数组',
  })
  @IsNumber({}, { each: true, message: 'role_ids必须是数字数组' })
  menu_ids?: number[];

  @IsNotEmpty({ message: '排序不可为空' })
  @ApiProperty({
    example: 1,
  })
  role_sort: number;
  @IsNotEmpty({ message: '创建人id不可为空' })
  @ApiProperty({
    example: 1,
  })
  create_by: number;
  @IsNotEmpty({ message: '更新人id不可为空' })
  @ApiProperty({
    example: 1,
  })
  update_by: number;
}

然后在role.module.ts导入menu和role表的实体

role.service.ts中写创建角色逻辑

js 复制代码
async create(createRoleDto: CreateRoleDto): Promise<string> {
    const row = await this.roleRepository.findOne({
      where: { role_name: createRoleDto.role_name },
    });
    if (row) {
      throw new ApiException('角色已存在', ApiErrorCode.COMMON_CODE);
    }
    const newRole = new Role();
    if (createRoleDto.menu_ids?.length) {
      //查询包含menu_ids的菜单列表
      const menuList = await this.menuRepository.find({
        where: {
          id: In(createRoleDto.menu_ids),
        },
      });
      //赋值给newRole(插入表中之后就会在关系表中生成对应关系)
      newRole.menus = menuList;
    }
    try {
      await this.roleRepository.save({ ...createRoleDto, ...newRole });
      return 'success';
    } catch (error) {
      throw new ApiException('系统异常', ApiErrorCode.FAIL);
    }
  }

role.controller.ts中调用createRole函数即可

js 复制代码
//...

@Controller('role')
@ApiTags('角色模块')
export class RoleController {
  constructor(private readonly roleService: RoleService) {}

  @Public()
  @Post('createRole')
  @ApiParam({
    name: 'CreateRoleDto',
    type: CreateRoleDto,
  })
  createRole(@Body() createRoleDto: CreateRoleDto) {
    return this.roleService.create(createRoleDto);
  }
}

最后调用role/createRole新增一个超级管理员角色和一个用于系统管理,菜单管理,角色管理的普通角色用于测试

最终表如下

到这里我们便完成了角色的创建。同样的逻辑,我们再在 user 模块写一个创建用户的接口

js 复制代码
async create(createUserDto: CreateUserDto): Promise<string> {
    const userExists = await this.userRepository.findOne({
      where: { username: createUserDto.username },
    });

    if (userExists)
      throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
    try {
      const newUser = new User();
      if (createUserDto.role_ids?.length) {
        //查询需要绑定的角色列表(自动在关联表生成关联关系)
        const roleList = await this.roleRepository.find({
          where: {
            id: In(createUserDto.role_ids),
          },
        });
        console.log(roleList);
        newUser.roles = roleList;
      }

      newUser.username = createUserDto.username;
      newUser.password = createUserDto.password;
      await this.userRepository.save(newUser);
      return 'success';
    } catch (error) {
      console.log(error);

      throw new ApiException('系统异常', ApiErrorCode.FAIL);
    }
  }

同时新增两个用户并分别赋予管理员角色1角色

管理员用户

角色 1 用户

获取菜单路由接口

接下来我们来实现不同用户获取不同的菜单列表,并将菜单列表转换成树形结构返回给前端使用。根据上面的 RBAC 权限我们大致画一个实现流程

来到我们菜单模块service中,编写一个获取路由的函数

js 复制代码
 async getRouters(req): Promise<Menu[]> {
    //user.guard中注入的解析后的JWTtoken的user
    const { user } = req;
    //根据关联关系通过user查询user下的菜单和角色
    const userList: User = await this.userRepository
      .createQueryBuilder('fs_user')
      .leftJoinAndSelect('fs_user.roles', 'fs_role')
      .leftJoinAndSelect('fs_role.menus', 'fs_menu')
      .where({ id: user.sub })
      .orderBy('fs_menu.order_num', 'ASC')
      .getOne();

    //是否为超级管理员,是的话查询所有菜单
    const isAdmin = userList.roles?.find((item) => item.role_name === 'admin');
    let routers: Menu[] = [];

    if (isAdmin) {
      routers = await this.menuRepository.find({
        order: {
          order_num: 'ASC',
        },
        where: {
          status: 1,
        },
      });
      return convertToTree(routers);
    }
    interface MenuMap {
      [key: string]: Menu;
    }
    // console.log(userList.roles[0].menus);

    //根据id去重
    const menus: MenuMap = userList?.roles.reduce(
      (mergedMenus: MenuMap, role: Role) => {
        role.menus.forEach((menu: Menu) => {
          mergedMenus[menu.id] = menu;
        });
        return mergedMenus;
      },
      {},
    );

    routers = Object.values(menus);

    return convertToTree(routers);
  }

其中convertToTree就是将扁平菜单列表转成树形结构菜单列表的函数

js 复制代码
export const convertToTree = (menuList, parentId: number | null = null) => {
  const tree = [];

  for (let i = 0; i < menuList.length; i++) {
    if (menuList[i].parent_id === parentId) {
      const children = convertToTree(menuList, menuList[i].id);
      if (children.length) {
        menuList[i].children = children;
      }
      tree.push(menuList[i]);
    }
  }

  return tree;
};

它接受两个参数:menuList 是原始的扁平菜单列表,parentId 是当前节点的父节点 ID,初始值为 null

函数首先创建了一个空数组 tree 用于存储树形结构。然后使用循环遍历 menuList,对每个菜单项进行判断,如果当前菜单项的 parent_id 等于传入的 parentId,则将其视为当前节点的子节点,继续递归调用 convertToTree 函数来构建子树。如果子节点存在,则将子节点数组赋值给当前菜单项的 children 属性,最后将当前菜单项添加到 tree 数组中。

最终,函数返回构建好的树形结构数组 tree

最后我们在controller中定义一个接口就完成了

js 复制代码
@Post('/getRouters')
  @ApiOperation({ summary: '获取路由' })
  async getRouters(@Request() req) {
    return await this.menuService.getRouters(req);
  }

我们尝试一下,首先我们先用user1用户调用登录接口拿到 token,复制到我们的请求工具apifox请求头中(记的加上前缀Bearer),然后请求一下就可以拿到属于这个用户的菜单啦

到这里我们基本已经处理完菜单获取相关的权限了,但是像是一些接口调用的权限,如列表的获取,前端按钮点击调用等等的权限还没处理。所以下一篇文章将介绍如何处理这些权限。后端我们将通过 Guard 守卫配合自定义装饰器实现,而前端方面则通过 Vue3 自定义指令进行实现,敬请期待~

相关代码地址

相关推荐
HUMHSX4 分钟前
Vue 项目启动全流程解析:从入口文件到全局指令注册与页面渲染
前端·javascript·vue.js
有颜有货15 分钟前
PMC生产排产的4种算法,一次讲清
java·服务器·前端
小虎牙00717 分钟前
Android kotlin图片库Coil源码详解
android·前端
随风一样自由27 分钟前
【前端领域】前端开发核心应用场景与落地实践
前端·前端框架
an317421 小时前
弹窗数据流设计的两种高阶架构实践
前端·vue.js·架构
utmhikari1 小时前
【日常随笔】深入回答纯Vibe Coding写后端项目的几个问题
后端·ai编程·vibecoding
尚早立志1 小时前
Spring Boot 源码研读之ConfigurableEnvironment 环境准备
java·spring boot·后端
谢尔登1 小时前
【React】 状态管理方案
前端·react.js·前端框架
用户2136610035721 小时前
Vue商品详情与放大镜组件
前端·javascript
半个落月1 小时前
从Tapas小Demo理清localStorage、事件与this
前端·javascript