NestJS 项目实战-权限管理系统开发(九)

274 阅读8分钟

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 获取角色列表接口;2. 创建角色接口;3. 获取角色详情接口;4. 更新角色信息接口;5. 删除角色接口。

1. 创建 role 模块

首先在项目根目录打开一个终端窗口并输入 nest g res role 命令,如下图:

image.png

然后将生成的测试相关文件移入 /src/role/test 文件夹下。

然后新建 /src/role/entities/role.entity.ts 文件并添加以下内容:

import { Role } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';

export class RoleEntity
  implements Omit<Role, 'deleted' | 'createdAt' | 'updatedAt'>
{
  @ApiProperty({ description: '角色ID', type: 'number' })
  id: Role['id'];

  @ApiProperty({ description: '角色名称', type: 'string' })
  name: Role['name'];

  @ApiProperty({ description: '角色描述', type: 'string' })
  description: Role['description'];

  @ApiProperty({ description: '禁用状态', type: 'boolean' })
  disabled: Role['disabled'];

  @ApiProperty({ description: '创建时间(UTC)' })
  createdAt: string;

  @ApiProperty({ description: '更新时间(UTC)' })
  updatedAt: string;
}

class RoleEntity implements Omit<Role, 'deleted' | 'createdAt' | 'updatedAt'> 表示定义一个 RoleEntity 类,该类需要实现 Role 类中除了使用 Omit 排除的三个属性以外的所有属性。

为什么要这样做?

因为需要使用 @ApiProperty() 装饰器来定义 API 端点的响应参数的属性并生成 Swagger 文档。

2. 获取角色列表接口

2.1 定义 DTO 对象

新建 /src/role/dto/role.dto.ts 文件并添加以下代码:

import { BaseQueryDto } from '../../common/dto/base-query.dto';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, Matches, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class QueryRoleDto extends BaseQueryDto {
  @Transform(({ value }) => value === '1')
  @IsOptional()
  @ApiProperty({ required: false, description: '禁用状态' })
  disabled?: boolean;

  @MaxLength(50)
  @Matches(/^[a-zA-Z\u4e00-\u9fa5,_-]*$/, { message: '角色名称格式错误' })
  @IsOptional()
  @ApiProperty({
    required: false,
    description: '角色名称关键字, 不能超过50个字符',
  })
  keyword?: string;
}

@Transform() 装饰器用来将其他格式的数据转换为布尔值。

@IsOptional() 装饰器用来表示该字段是可选的,只有在存在时,才会执行上面的校验。

@ApiProperty() 装饰器用来定义 API 端点的请求参数的属性与描述性信息,以便自动生成 Swagger 文档。

该 DTO 将用来对查询接口的 query 参数进行格式校验与转换。

使用 DTO 文件的好处:

  1. 数据验证:通过使用类验证器(如 class-validator),可以确保传入的数据符合预期的格式和规则。
  2. 数据转换:可以使用类转换器(如 class-transformer)将普通的 JavaScript 对象转换为类实例,从而利用类的功能。
  3. 文档生成:结合 @nestjs/swagger 等工具,可以自动生成 API 文档,方便前后端协作。
  4. 代码可读性和维护性:明确的数据结构定义使代码更易读、更易维护。

2.2 定义返回实体类

新建 /src/role/entities/role-list.entity.ts 文件并添加以下内容:

import { ApiProperty } from '@nestjs/swagger';
import { RoleEntity } from './role.entity';

export class RoleListEntity {
  @ApiProperty({ description: '总数' })
  total: number;

  @ApiProperty({ description: '角色列表', type: [RoleEntity] })
  list: RoleEntity[];
}

因为 list 的类型为复杂数据类型,Swagger 无法直接识别,需要使用 type 属性指定类型才能正确生成文档。

该实体类将用来为 Swagger 文档提供该查询接口响应数据的类型说明。

2.3 查询方法实现

打开 /src/role/role.service.ts 文件并修改为以下内容:

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { QueryRoleDto } from './dto/query-role.dto';
import { Prisma } from '@prisma/client';

@Injectable()
export class RoleService {
  constructor(private readonly prismaService: PrismaService) {}

  async findAll(queryRoleDto: QueryRoleDto) {
    const {
      keyword,
      disabled,
      page = 1,
      pageSize = 10,
      sort = 'desc',
      beginTime,
      endTime,
    } = queryRoleDto;

    const whereCondition: Prisma.RoleWhereInput = {
      disabled: disabled,
      deleted: false,
      name: {
        contains: keyword,
        mode: 'insensitive',
      },
      createdAt: {
        gte: beginTime,
        lte: endTime,
      },
    };

    const results = await this.prismaService.$transaction([
      this.prismaService.role.findMany({
        where: whereCondition,
        select: {
          id: true,
          name: true,
          description: true,
          disabled: true,
          createdAt: true,
          updatedAt: true,
        },
        orderBy: {
          createdAt: sort,
        },
        take: pageSize,
        skip: (page - 1) * pageSize,
      }),
      this.prismaService.role.count({ where: whereCondition }),
    ]);

    return {
      list: results[0].map((item) => ({
        ...item,
        createdAt: item.createdAt.toISOString(),
        updatedAt: item.updatedAt.toISOString(),
      })),
      total: results[1],
    };
  }
}

以下条件会被用在 Prisma ORM 的查询中,用于过滤数据库中的角色记录:

  1. disabled: 过滤是否禁用的角色
  2. deleted: false: 只查询未删除的角色
  3. name: 根据关键字模糊查询角色名称
    • contains: keyword: 包含指定关键字
    • mode: 'insensitive': 大小写不敏感
  4. createdAt: 根据创建时间范围筛选
    • gte: beginTime: 大于等于开始时间
    • lte: endTime: 小于等于结束时间
  5. orderBy: 指定数据排序条件
    • createdAt: sort: 根据传入参数决定是按创建时间升序还是降序
  6. take: 设置要获取的数据条数,相当于 SQL 中的 LIMIT ,用于实现数据的分页查询
  7. skip: 设置要跳过的数据条数,相当于 SQL 中的 OFFSET ,用于实现数据的分页查询

$transaction 用来开启一个事务。通过将两个查询操作(查找角色列表和计算角色总数)包装在一个事务中,可以确保这两个操作看到的是同一个数据库状态。这避免了在高并发环境下可能出现的不一致情况。

Prisma 可以在一个事务中优化多个查询的执行,减少与数据库的往返通信。这种批处理操作通常比单独执行两个查询更高效,尤其是在网络延迟较高或数据库负载大的情况下。

2.4 添加接口

修改 /src/role/role.controller.ts 文件的内容为:

import { Controller, Get, Query } from '@nestjs/common';
import { RoleService } from './role.service';
import { QueryRoleDto } from './dto/query-role.dto';
import { RoleListEntity } from './entities/role-list.entity';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBaseResponse } from 'src/common/decorator/api-base-response.decorator';

@ApiTags('role')
@ApiBearerAuth()
@Controller('role')
export class RoleController {
  constructor(private readonly roleService: RoleService) {}

  @ApiOperation({ summary: '获取角色列表' })
  @ApiBaseResponse(RoleListEntity)
  @Get()
  findAll(@Query() query: QueryRoleDto): Promise<RoleListEntity> {
    return this.roleService.findAll(query);
  }
}

@ApiTags('role') 装饰器用来给整个控制器添加 role 标签,方便对 API 接口进行分组管理,后续该控制器中所有接口都将在该分组下。效果如下图: image.png

@ApiBearerAuth() 装饰器用来在 Swagger 文档中标识需要 Bearer Token 认证的接口,加在控制器上,则该控制器里的所有接口都需要 Bearer Token 认证。如下图: image.png

@ApiOperation() 装饰器是用来为 Swagger 文档添加接口说明的,如下图: image.png

@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来定义 API 端点的成功响应与为 Swagger 文档提供接口响应数据的类型说明。 image.png

@Get() 装饰器用来声明一个 GET 类型的接口。@Query() 装饰器用来获取接口路径上的 query 参数,这里还指定使用了 QueryRoleDto 类对获取的参数进行格式校验与转换。

至此这个查询接口就开发完成啦,打开 API 测试工具试试吧~

3. 创建角色接口

3.1 定义 DTO 对象

新建 /src/role/dto/create-role.dto.ts 文件并添加以下内容:

import { ApiProperty } from '@nestjs/swagger';
import {
  IsBoolean,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  Matches,
  MaxLength,
  ValidateIf,
} from 'class-validator';

export class CreateRoleDto {
  @Matches(/^[a-zA-Z0-9._-]{1,50}$/, { message: '角色名称格式错误' })
  @IsNotEmpty({ message: '角色名称不能为空' })
  @ApiProperty({ description: '角色名称' })
  name: string;

  @MaxLength(150, { message: '角色描述不能超过150个字符' })
  @ValidateIf((o) => o.description !== '')
  @IsOptional()
  @ApiProperty({ description: '角色描述', required: false })
  description?: string;

  @IsNumber({}, { message: '权限 ID 必须为数字', each: true })
  @ValidateIf((o) => o.permissions?.length !== 0)
  @IsOptional()
  @ApiProperty({ description: '权限 ID 列表', type: [Number], required: false })
  permissions?: number[];

  @IsBoolean({ message: '禁用状态必须为布尔值' })
  @IsOptional()
  @ApiProperty({ description: '禁用状态', required: false })
  disabled?: boolean;
}

可以发现 descriptionpermissions 字段添加了一个特殊的条件校验装饰器 @ValidateIf,只有在该校验返回 true 时,才会执行下一个校验,如 @MaxLength(150, { message: '角色描述不能超过150个字符' })

为什么要添加这个装饰器?因为编辑角色时也会用到这个 DTO 对象,描述信息可能改为了空字符串,所以需要排除这种特殊情况。

3.2 创建方法实现

打开 /src/role/role.service.ts 文件并添加以下方法:

  async create(createRoleDto: CreateRoleDto) {
    const role = await this.prismaService.role.findUnique({
      where: {
        name: createRoleDto.name,
      },
      select: {
        id: true,
      },
    });
    if (role) {
      throw new BadRequestException('该角色已存在');
    }

    const data: Prisma.RoleCreateInput = {
      name: createRoleDto.name,
      description: createRoleDto.description,
      disabled: createRoleDto.disabled,
    };
    if (createRoleDto.permissions?.length) {
      data.permissionInRole = {
        create: createRoleDto.permissions.map((permissionId) => ({
          permissionId,
        })),
      };
    }

    await this.prismaService.role.create({
      data,
    });
  }

在该方法中,首先我们先查询了是否存在该名称的角色,如果存在则直接返回错误,如果不存在,则创建该角色。

3.3 添加接口

打开 /src/role/role.controller.ts 文件并添加以下代码:

  @ApiOperation({ summary: '创建角色' })
  @ApiBaseResponse()
  @Post()
  @Authority('system:role:add')
  create(@Body() createRoleDto: CreateRoleDto) {
    return this.roleService.create(createRoleDto);
  }

@Post 装饰器用来声明一个 POST 类型的接口,该装饰器从 @nestjs/common 包中引入。

@Authority 装饰器用来设置该接口需要的权限。

@Body 装饰器用来获取请求体中的参数,该装饰器从 @nestjs/common 包中引入,这里还指定了使用 CreateRoleDto 类对接口的请求体参数进行格式校验。

那么这个接口就开发完成了,打开 API 测试工具试试吧~

4. 获取角色详情接口

4.1 定义返回实体类

新建 /src/role/entities/role-detail.entity.ts 文件并添加以下内容:

import { ApiProperty, OmitType } from '@nestjs/swagger';
import { RoleEntity } from './role.entity';

export class RoleDetailEntity extends OmitType(RoleEntity, [
  'createdAt',
  'updatedAt',
]) {
  @ApiProperty({ description: '权限列表', type: 'number', isArray: true })
  permissions: number[];
}

将用作生成该接口在 Swagger 文档中响应数据的类型说明。也将用来约束接口的返回数据格式。

4.2 查询详情方法实现

打开 /src/role/role.service.ts 文件并添加以下方法:

  async findOne(id: number) {
    const role = await this.prismaService.role.findUnique({
      where: {
        id,
        deleted: false,
      },
      include: {
        permissionInRole: { // 关联角色权限中间表
          where: {
            permissions: { // 关联权限表
              deleted: false, // 过滤掉已删除的数据
            },
          },
          select: {
            permissionId: true,
          },
        },
      },
    });

    if (!role) {
      throw new BadRequestException('该角色不存在');
    }

    const permissions = role.permissionInRole.map(
      (roleInPermission) => roleInPermission.permissionId,
    );

    return {
      id: role.id,
      name: role.name,
      description: role.description,
      disabled: role.disabled,
      permissions,
    };
  }

该方法获取了单个角色的详细信息,包括其关联的权限。

在该方法的 Prisma ORM 查询中用到了 include 条件,该条件用于在查询主表数据的同时,加载关联表的数据。在该查询中,用来了关联角色权限中间表,并进一步关联了权限表,然后排除了已删除的权限数据。

4.3 添加查询详情接口

打开 /src/role/role.controller.ts 文件并添加以下接口:

  @ApiOperation({ summary: '获取角色详情' })
  @ApiBaseResponse(RoleDetailEntity)
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number): Promise<RoleDetailEntity> {
    return this.roleService.findOne(id);
  }

@Get(':id') 定义了一个包含动态参数的 get 类型接口。

@Param('id', ParseIntPipe) 装饰器用来获取动态参数并使用 ParseIntPipe 方法将其转换为 number 类型。这两个方法都是从 @nestjs/common 库导入。

运行项目并在浏览器打开 http://localhost:3000/api#/role/RoleController_findOne 网址即可看到该接口的文档了。 image.png

5. 更新角色信息接口

5.1 定义 DTO 对象

新建 /src/role/dto/update-role.dto.ts 文件并添加以下内容:

import { PartialType } from '@nestjs/swagger';
import { CreateRoleDto } from './create-role.dto';

export class UpdateRoleDto extends PartialType(CreateRoleDto) {}

仅需将创建接口支持的参数都改为可选属性即可。

5.2 更新方法实现

首先需要明确,如果角色正在被用户使用,是否允许禁用该角色?如果允许,则需要修改用户角色关联表的信息,并且删除所有相关用户的权限缓存信息。

其次还需要明确,如果角色正在被用户使用,是否允许修改角色的权限信息?如果允许,则需要修改角色权限关联表的信息,并删除相关用户的权限缓存信息。

首先在 RoleModule 中导入 RedisModule:

import { Module } from '@nestjs/common';
import { RoleService } from './role.service';
import { RoleController } from './role.controller';
import { RedisModule } from 'src/redis/redis.module';

@Module({
  imports: [RedisModule], // 导入 Redis 模块
  controllers: [RoleController],
  providers: [RoleService],
})
export class RoleModule {}

然后打开 /src/role/role.service.ts 文件并添加以下代码:

import { UpdateRoleDto } from './dto/update-role.dto'; // 新引入
import { RedisService } from 'src/redis/redis.service'; // 新引入

  constructor(
    private readonly prismaService: PrismaService,
    private readonly redisService: RedisService, // 新增
  ) {}

  async update(id: number, updateRoleDto: UpdateRoleDto) {
    if (updateRoleDto.name) {
      const roleInfo = await this.prismaService.role.findUnique({
        where: {
          name: updateRoleDto.name,
        },
      });

      if (roleInfo?.id) {
        throw new BadRequestException('该角色名称已存在');
      }
    }

    const roleAndUser = await this.prismaService.role.findUnique({
      where: {
        id,
        deleted: false,
      },
      include: { // 关联查询正在使用这个角色的正常用户
        roleInUser: {
          select: {
            userId: true,
          },
          where: {
            users: {
              disabled: false,
              deleted: false,
            },
          },
        },
      },
    });
    if (!roleAndUser) {
      throw new BadRequestException('角色不存在');
    }

    if (roleAndUser.roleInUser?.length) {
      if (updateRoleDto.disabled) {
        throw new BadRequestException('该角色已被用户使用,不能禁用');
      }

      // 清除 redis 缓存的权限信息
      if (updateRoleDto.permissions !== undefined) {
        const userIds = roleAndUser.roleInUser.map((item) => {
          return item.userId;
        });

        userIds.forEach((userId) => {
          this.redisService.delUserPermission(userId);
        });
      }
    }

    const data: Prisma.RoleUpdateInput = {
      name: updateRoleDto.name,
      description: updateRoleDto.description,
      disabled: updateRoleDto.disabled,
    };
    if (updateRoleDto.permissions !== undefined) {
      // 删除所有权限与角色的关联信息
      data.permissionInRole = {
        deleteMany: {},
      };

      // 添加角色与权限的关联信息
      if (updateRoleDto.permissions.length) {
        data.permissionInRole.create = updateRoleDto.permissions.map(
          (permissionId) => ({
            permissionId,
          }),
        );
      }
    }

    await this.prismaService.role.update({
      where: {
        id,
      },
      data,
    });
  }

在这个更新方法中,首先判断了修改后的名称是否已存在,然后判断了该角色是否存在,如果已被删除则不允许更新信息,最后判断了是否禁用角色,如果该角色被用户使用了,则不允许禁用。

5.3 添加更新接口

打开 /src/role/role.controller.ts 文件并添加以下代码:

  @ApiOperation({ summary: '更新角色信息' })
  @ApiBaseResponse()
  @Authority('system:role:edit')
  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateRoleDto: UpdateRoleDto,
  ) {
    return this.roleService.update(id, updateRoleDto);
  }

@Patch@nestjs/common 中导入。因为只是更新部分信息,所以使用 Patch 更符合。

运行项目,打开接口文档,已经自动生成该接口的文档了。至此,这个接口就开发完成了。

6. 删除角色接口

6.1 删除方法实现

首先需要明确,什么情况下角色不能被删除?角色被未删除的用户使用了,则不能删除。

打开 /src/role/role.service.ts 文件并添加以下方法:

  async remove(id: number) {
    const roleAndUser = await this.prismaService.role.findUnique({
      where: {
        id,
        deleted: false,
      },
      include: { // 关联查询用户信息
        roleInUser: {
          select: {
            userId: true,
          },
          where: {
            users: {
              deleted: false,
            },
          },
        },
      },
    });
    if (!roleAndUser) {
      throw new BadRequestException('角色不存在');
    }

    if (roleAndUser.roleInUser?.length) {
      throw new BadRequestException('该角色已被用户使用,不允许删除');
    }

    await this.prismaService.role.update({
      where: {
        id,
      },
      data: {
        deleted: true,
      },
    });
  }

可以看到,我们只是软删除,仅修改了 deleted 字段的状态为 true。

6.2 添加删除接口

打开 /src/role/role.controller.ts 文件并添加以下接口代码:

  @ApiOperation({ summary: '删除角色' })
  @ApiBaseResponse()
  @Authority('system:role:del')
  @Patch(':id/delete')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.roleService.remove(id);
  }

因为只是软删除,所以使用 Patch 更合适。

那么删除接口也开发完成了,打开测试工具试试吧~

相关单元测试代码请查看代码仓库哦。