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

345 阅读7分钟

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

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

本章节内容: 1. 获取权限列表接口;2. 获取权限树接口;3. 创建权限接口;4. 获取权限详情接口;5. 编辑权限接口;6. 删除权限接口。

1. 获取权限列表接口

1.1 定义 DTO 对象

首先我们需要先定义可以根据哪些参数查询,一般来说会有分页、排序、时间等参数。

新建 /src/common/dto/base-query.dto.ts 文件并添加以下内容:

import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional, Matches, Min } from 'class-validator';
import { Transform } from 'class-transformer';

export class BaseQueryDto {
  @Min(1)
  @Transform(({ value }) => parseInt(value))
  @IsOptional()
  @ApiProperty({ required: false, default: 1 })
  page?: number;

  @Min(10)
  @Transform(({ value }) => parseInt(value))
  @IsOptional()
  @ApiProperty({ required: false, default: 10 })
  pageSize?: number;

  @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{3})?Z$/, {
    message: '开始时间格式错误',
  })
  @IsOptional()
  @ApiProperty({
    required: false,
    description: '开始时间',
  })
  beginTime?: string;

  @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{3})?Z$/, {
    message: '结束时间格式错误',
  })
  @IsOptional()
  @ApiProperty({
    required: false,
    description: '结束时间',
  })
  endTime?: string;

  @Matches(/^(asc|desc)$/, { message: '排序格式错误' })
  @IsOptional()
  @ApiProperty({
    required: false,
    default: 'desc',
    enum: ['asc', 'desc'],
    description: '排序方式,默认按创建时间倒序排列',
  })
  sort?: 'asc' | 'desc';
}

@Min() 装饰器用来设置该字段的最小值。

@Matches 装饰器用来对字段值进行正则校验。

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

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

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

该 DTO 将用作对后续查询接口的基础校验类。为什么有些参数使用了 @Transform() 方法转换类型?因为是 query 类型的参数(拼接在 url 上),格式都是字符串,所以有些其他类型的参数需要转换一下。

使用 DTO 文件的好处:

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

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

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

export class QueryPermissionDto extends BaseQueryDto {
  @IsBoolean()
  @Transform(({ value }) => value === '1')
  @IsOptional()
  @ApiProperty({ required: false })
  disabled?: boolean;

  @Matches(/^(DIRECTORY|MENU|BUTTON)$/, { message: '类型格式错误' })
  @IsOptional()
  @ApiProperty({
    required: false,
    enum: ['DIRECTORY', 'MENU', 'BUTTON'],
  })
  type?: Permission['type'];

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

通过继承基础的查询对象类,扩展一些专属于权限列表的查询参数。该类将用作 Swagger API 文档中接口所需参数数据的类型说明。

1.2 定义返回实体类

接下来,我们还需要确定要返回哪些字段。

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

import { ApiProperty, OmitType } from '@nestjs/swagger';
import { PermissionEntity } from './permission.entity';

export class PermissionList extends OmitType(PermissionEntity, [
  'redirect',
  'hidden',
  'cache',
  'props',
  'component',
  'createdAt',
  'updatedAt',
] as const) {
  @ApiProperty({ description: '创建时间(UTC)' })
  createdAt: string;

  @ApiProperty({
    description: '子菜单',
    required: false,
    default: [],
    type: [PermissionList],
  })
  children?: PermissionList[];
}

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

  @ApiProperty({ description: '权限列表', type: [PermissionList] })
  list: PermissionList[];
}

OmitType 用来排除不需要的属性。type: [PermissionList], 为什么要这样指定类型,因为 Swagger 库无法直接识别复杂数据类型,需要手动指定。

该实体将用作 Swagger API 文档中接口响应数据的类型说明。

1.3 添加方法

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

import { QueryPermissionDto } from './dto/query-permission.dto';
import { generateMenus } from 'src/common/utils';

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

    const permissions = await this.prismaService.permission.findMany({
      where: {
        deleted: false,
        name: {
          contains: keyword,
          mode: 'insensitive',
        },
        disabled,
        type,
        createdAt: {
          gte: beginTime,
          lte: endTime,
        },
      },
      select: {
        id: true,
        pid: true,
        name: true,
        type: true,
        permission: true,
        icon: true,
        path: true,
        sort: true,
        disabled: true,
        createdAt: true,
      },
      orderBy: [
        {
          sort: 'desc',
        },
        {
          createdAt: sort,
        },
      ],
    });

    const offset = (page - 1) * pageSize;
    if (permissions.length < offset) {
      return { list: [], total: 0 };
    }

    const permissionTree = generateMenus(
      permissions.map((item) => {
        return {
          ...item,
          createdAt: item.createdAt.toISOString(),
        };
      }),
    );
    if (permissionTree.length < offset) {
      return { list: [], total: 0 };
    }
    const total = permissionTree.length;
    const list = permissionTree.slice(offset, offset + pageSize);
    return { list, total };
  }

以下是用在 Prisma ORM 的查询中的一些特殊条件:

  1. name: 根据关键字模糊查询权限名
    • contains: keyword: 包含指定关键字
    • mode: 'insensitive': 大小写不敏感
  2. createdAt: 根据创建时间范围筛选
    • gte: beginTime: 大于等于开始时间
    • lte: endTime: 小于等于结束时间
  3. orderBy: 指定数据排序条件
    • createdAt: sort: 根据传入参数决定是按创建时间升序还是降序

因为权限列表是一个多级列表,所以我们可以先查询出所有符合条件的项,然后转换为树后再分页。

1.4 添加接口

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

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

@ApiTags('permission')
@ApiBearerAuth()
@Controller('permission')
export class PermissionController {
  constructor(private readonly permissionService: PermissionService) {}

  @ApiOperation({ summary: '获取权限列表' })
  @ApiBaseResponse(PermissionListEntity)
  @Get()
  findAll(
    @Query() queryDto: QueryPermissionDto,
  ): Promise<PermissionListEntity> {
    return this.permissionService.findAll(queryDto);
  }
}

@ApiTags('permission') 装饰器用来给整个控制器添加 permission 标签,方便对 Swagger API 文档中的接口进行分组管理。

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

@ApiOperation() 用来为 Swagger 文档中该接口添加说明信息。

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

@Get() 装饰器用来声明一个 GET 类型的接口。

@Query() queryDto: QueryPermissionDto 表示获取接口地址上的 query 参数并指定使用 QueryPermissionDto 类对参数数据进行校验与格式转换。

但现在还无法根据 dto 中的定义自动转换 query 的参数,还需在 main.ts 中开启自动转换。

打开 /src/main.ts 文件并在 new ValidationPipe() 的配置对象中添加一个 transform: true 属性,如下:

app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

至此,这个接口就开发完成了,赶紧去试试吧。

2. 获取权限树接口

添加子权限时,需要关联父权限,所以需要先开发获取权限树接口。

2.1 定义 DTO 对象

因为本系统的设计是,按钮级权限不能作为父权限,所以这个接口返回的权限需要过滤掉按钮级别。

但是添加角色时,又是需要按钮级权限的,所以我们需要一个参数来控制该接口是否返回按钮级权限。

新建 /src/permission/query-permission-tree.dto.ts 文件并添加以下内容:

import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';

export class QueryPermissionTreeDto {
  @Transform(({ value }) => value === 'true')
  @IsOptional()
  @ApiProperty({ required: false, description: '是否包含按钮级' })
  containButton?: boolean;
}

将用作校验接口参数与 Swagger API 文档中的接口参数说明。

2.2 定义返回实体类

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

import { ApiProperty, PickType } from '@nestjs/swagger';
import { PermissionEntity } from './permission.entity';

export class PermissionTreeEntity extends PickType(PermissionEntity, [
  'id',
  'pid',
  'name',
  'type',
  'disabled',
]) {
  @ApiProperty({
    description: '子权限',
    required: false,
    default: [],
    type: [PermissionTreeEntity],
  })
  children?: PermissionTreeEntity[];
}

该实体将用作 Swagger API 文档中接口响应数据的类型说明。

2.3 添加方法

只需查询出 permission 表中所有的未删除数据,然后再转换为树列表即可。

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

import { Prisma } from '@prisma/client';

  async findTree(containButton = false) {
    const whereCondition: Prisma.PermissionWhereInput = {
      deleted: false,
    };
    if (!containButton) {
      // 排除该类型的权限
      whereCondition.type = {
        not: 'BUTTON',
      };
    }

    const permissions = await this.prismaService.permission.findMany({
      where: whereCondition,
      select: { id: true, pid: true, name: true, type: true, disabled: true },
      orderBy: { sort: 'desc' },
    });

    const tree = generateMenus(permissions);
    return tree;
  }

该方法接收一个 containButton 参数,默认值为 false。当值为 false 时,则从表中过滤掉类型为 BUTTON 的权限,反之则不过滤。

2.4 添加接口

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

  @ApiOperation({ summary: '获取权限树' })
  @ApiBaseResponse(PermissionTreeEntity, 'array')
  @Get('tree')
  findTree(
    @Query() queryDto: QueryPermissionTreeDto,
  ): Promise<PermissionTreeEntity[]> {
    return this.permissionService.findTree(queryDto.containButton);
  }

@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来定义 API 端点的成功响应并为 Swagger API 文档提供接口响应数据的类型说明,具体实现请查看前面的章节。

那么这个接口就开发完成了,打开 Postman 试试吧!

3. 创建权限接口

3.1 定义 DTO 对象

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

import { ApiProperty } from '@nestjs/swagger';
import {
  IsBoolean,
  IsEnum,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsString,
  Matches,
  Max,
  MaxLength,
  Min,
  MinLength,
} from 'class-validator';

export class CreatePermissionDto {
  @IsNumber({}, { message: '父权限 ID 类型错误' })
  @IsOptional()
  @ApiProperty({
    description: '父级权限ID',
    default: null,
    required: false,
  })
  pid?: number;

  @Matches(/^[a-zA-Z\u4e00-\u9fa5]{1,50}$/, { message: '权限名称格式错误' })
  @IsNotEmpty({ message: '权限名称不能为空' })
  @ApiProperty({ description: '权限名称' })
  name: string;

  @IsEnum(['DIRECTORY', 'MENU', 'BUTTON'], { message: '权限类型错误' })
  @ApiProperty({
    description: '权限类型',
    enum: ['DIRECTORY', 'MENU', 'BUTTON'],
  })
  type: 'DIRECTORY' | 'MENU' | 'BUTTON';

  @Matches(/^[a-z:]{1,50}$/, { message: '权限标识格式错误' })
  @IsOptional()
  @ApiProperty({ description: '权限标识', required: false })
  permission?: string;

  @MaxLength(50, { message: '图标名称长度不能超过50' })
  @IsString({ message: '图标名称必须为字符串类型' })
  @IsOptional()
  @ApiProperty({ description: '图标名称', required: false })
  icon?: string;

  @MinLength(2, { message: '路径长度不能小于2' })
  @MaxLength(50, { message: '路径长度不能超过50' })
  @Matches(/^\/?([a-zA-Z]+)(\/[a-zA-Z]+|\/:[a-zA-Z]+)*$/, {
    message: '路径格式错误',
  })
  @IsOptional()
  @ApiProperty({ description: '权限路径', required: false })
  path?: string;

  @MinLength(6, { message: '组件地址长度不能小于6' })
  @MaxLength(100, { message: '组件地址长度不能超过100' })
  @Matches(/^(\/[a-zA-Z]+[-_]?[a-zA-Z]+)+(.vue|.tsx|.jsx)$/, {
    message: '组件地址格式错误',
  })
  @IsOptional()
  @ApiProperty({ description: '组件地址', required: false })
  component?: string;

  @Max(255, { message: '最大255' })
  @Min(0, { message: '最小0' })
  @IsNumber({}, { message: '排序权重必须为数字类型' })
  @IsOptional()
  @ApiProperty({
    description: '排序权重',
    required: false,
    default: 0,
  })
  sort?: number;

  @MinLength(2, { message: '重定向地址长度不能小于2' })
  @MaxLength(50, { message: '重定向地址长度不能超过50' })
  @Matches(/^(\/?[a-zA-Z0-9]+)+$/, { message: '重定向地址格式错误' })
  @IsOptional()
  @ApiProperty({
    description: '重定向地址',
    required: false,
  })
  redirect?: string;

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

  @IsBoolean({ message: '隐藏状态必须为布尔值' })
  @IsOptional()
  @ApiProperty({
    description: '隐藏状态',
    required: false,
    default: false,
  })
  hidden?: boolean;

  @IsBoolean({ message: '是否缓存必须为布尔值' })
  @IsOptional()
  @ApiProperty({
    description: '是否缓存',
    required: false,
    default: false,
  })
  cache?: boolean;

  @IsBoolean({ message: 'vue-router 的 props 属性必须为布尔值' })
  @IsOptional()
  @ApiProperty({
    description: 'vue-router 的 props 属性',
    required: false,
    default: false,
  })
  props?: boolean;
}

该 DTO 类将用来校验创建权限接口接收参数的格式是否正确,并生成 Swagger API 文档。

3.2 添加方法

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

  async create(createDto: CreatePermissionDto) {
    if (createDto.type !== 'BUTTON' && !createDto.path) {
      throw new BadRequestException('目录/菜单的路径不能为空');
    }

    if (createDto.type === 'MENU' && !createDto.component) {
      throw new BadRequestException('菜单的组件地址不能为空');
    }

    if (createDto.type === 'BUTTON' && !createDto.permission) {
      throw new BadRequestException('按钮的权限标识不能为空');
    }

    const permission = await this.prismaService.permission.findFirst({
      where: {
        OR: [
          {
            id: createDto.pid,
          },
          {
            name: createDto.name,
          },
          {
            permission: createDto.permission,
          },
        ],
        deleted: false,
      },
      select: {
        type: true,
        name: true,
        permission: true,
      },
    });

    if (!permission && createDto.pid) {
      throw new BadRequestException('父权限不存在');
    }

    if (permission) {
      if (permission.name === createDto.name) {
        throw new BadRequestException('权限名称已存在');
      }

      if (
        createDto.permission &&
        permission.permission === createDto.permission
      ) {
        throw new BadRequestException('权限标识已存在');
      }

      if (permission.type === 'BUTTON') {
        throw new BadRequestException('按钮权限不能添加子权限');
      }
    }

    await this.prismaService.permission.create({
      data: createDto,
    });
  }

在这个创建方法中,我们对数据进行了一些必要的校验。findFirst 方法用来查询第一个符合条件的数据。

3.3 添加接口

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

import { CreatePermissionDto } from './dto/create-permission.dto';
import { Authority } from 'src/common/decorator/authority.decorator';

  @ApiOperation({ summary: '创建权限' })
  @ApiBaseResponse()
  @Authority('system:menu:add')
  @Post()
  create(@Body() createPermissionDto: CreatePermissionDto) {
    return this.permissionService.create(createPermissionDto);
  }

这里引入了一个特殊的装饰器 @Authority 用来做接口的权限控制,只有用户拥有 system:menu:add 权限时才能创建权限。该装饰器的实现见前面的章节。

@Body() createPermissionDto: CreatePermissionDto 表示获取接口请求体中的参数数据并使用 CreatePermissionDto 类对其进行校验。

4. 获取权限详情接口

4.1 添加方法

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

  findOne(id: number) {
    return this.prismaService.permission.findUnique({
      where: {
        id,
        deleted: false,
      },
      select: {
        pid: true,
        name: true,
        type: true,
        path: true,
        permission: true,
        icon: true,
        cache: true,
        props: true,
        hidden: true,
        component: true,
        disabled: true,
        redirect: true,
        sort: true,
      },
    });
  }

该方法接收一个 ID 参数,并根据 ID 从数据库中查询符合条件的数据。

4.2 添加接口

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

  @ApiOperation({ summary: '获取单个权限详情' })
  @ApiBaseResponse(PermissionEntity)
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.permissionService.findOne(id);
  }

@Get(':id') 表示定义一个 Get 请求的动态路由,:id 是一个动态路由参数。

@Param(id) 用于获取 URL 中的 id 值,ParseIntPipe 用于将字符串转换为数字。

image.png

5. 编辑权限接口

5.1 定义 DTO 对象

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

import { PartialType } from '@nestjs/swagger';
import { CreatePermissionDto } from './create-permission.dto';

export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {}

只需将 CreatePermissionDto 的属性都改为可选即可。

5.2 添加方法

首先我们先要引入 redis 模块,后续要用到。

打开 /src/permission/permission.module.ts 文件添加以下代码:

import { Module } from '@nestjs/common';
import { PermissionService } from './permission.service';
import { PermissionController } from './permission.controller';
import { RedisModule } from 'src/redis/redis.module'; // 新增

@Module({
  imports: [RedisModule], // 新增
  controllers: [PermissionController],
  providers: [PermissionService],
  exports: [PermissionService],
})
export class PermissionModule {}

然后在 /src/permission/permission.service.ts 文件中添加以下内容:

...
import { UpdatePermissionDto } from './dto/update-permission.dto';
import { RedisService } from 'src/redis/redis.service';

@Injectable()
export class PermissionService {
  constructor(
    private readonly prismaService: PrismaService,
    private readonly redisService: RedisService,
  ) {}
  
  ...

  async update(id: number, updateDto: UpdatePermissionDto) {
    const permission = await this.prismaService.permission.findUnique({
      where: { id, deleted: false },
      include: {
        permissionInRole: { // 关联权限角色表查询
          where: {
            roles: { // 只查询未删除的角色关联的权限
              deleted: false,
            },
          },
          include: {
            roles: {
              include: {
                roleInUser: { // 关联角色用户表查询用户ID
                  select: {
                    userId: true,
                  },
                },
              },
            },
          },
        },
      },
    });

    if (!permission) {
      throw new BadRequestException('权限不存在');
    }

    if (permission.permissionInRole.length > 0) {
      const sensitiveFields = ['type', 'permission', 'pid'];
      const hasChangeSensitiveField = sensitiveFields.some(
        (field) => updateDto[field] && updateDto[field] !== permission[field],
      );

      if (hasChangeSensitiveField) {
        throw new BadRequestException(
          '该权限已被角色使用,不能修改类型、权限标识、父权限',
        );
      }
    }

    const safeFields = [
      'name',
      'icon',
      'sort',
      'path',
      'component',
      'hidden',
      'disabled',
      'cache',
      'redirect',
      'props',
    ];
    const updateData: UpdatePermissionDto = Object.keys(updateDto)
      .filter((key) => safeFields.includes(key))
      .reduce(
        (acc, key) => ({
          ...acc,
          [key]: updateDto[key],
        }),
        {},
      );

    // 如果禁用了权限,删除用户的权限缓存
    if (updateData.disabled === false) {
      const userIds = permission.permissionInRole.flatMap((item) => {
        return item.roles.roleInUser.map((item) => item.userId);
      });

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

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

在该方法中,首先我们查询了该权限是否存在,如果存在,则将关联的用户与角色信息也查询出来。如果关联了未删除的角色,则不允许修改一些敏感信息。如果禁用了该权限,则清空相应用户的权限缓存。

你可能会说,为什么启用权限不用清空用户的权限缓存呢?

这是因为,我们在权限守卫中,如果缓存里没有这个权限,会去数据库里再查询一次用户的完整权限。

5.3 添加接口

打开 /src/permission/permission.controller.ts 文件并新增以下接口:

  @ApiOperation({ summary: '更新权限' })
  @ApiBaseResponse()
  @Authority('system:menu:edit')
  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updatePermissionDto: UpdatePermissionDto,
  ) {
    return this.permissionService.update(id, updatePermissionDto);
  }

Patch@nestjs/common 包中导入。

@Patch(':id') 表示定义一个 Patch 请求的动态路由,:id 是一个动态路由参数,格式例如:/permission/10,id 为 10。

6. 删除权限接口

6.1 删除单个权限接口

首先,在 /src/permission/permission.service.ts 中添加以下方法:

  async remove(id: number) {
    const permission = await this.prismaService.permission.findUnique({
      where: { id, deleted: false },
      include: {
        permissionInRole: { // 关联权限角色表查询
          where: {
            roles: { // 关联角色表查询
              deleted: false,
            },
          },
        },
        children: { // 查询该权限的子权限
          where: {
            deleted: false,
          },
        },
      },
    });

    if (!permission) {
      throw new BadRequestException('权限不存在');
    }

    if (permission.permissionInRole.length > 0) {
      throw new BadRequestException('该权限已被角色使用,不能删除');
    }

    if (permission.children.length > 0) {
      throw new BadRequestException('该权限下存在子权限,不能删除');
    }

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

然后,在 /src/permission/permission.controller.ts 中添加以下代码:

  @ApiOperation({ summary: '删除单个权限' })
  @ApiBaseResponse()
  @Authority('system:menu:del')
  @Patch(':id/delete')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.permissionService.remove(id);
  }

因为这里只是软删除,仅修改 deleted 字段的状态而已,所以使用 Patch 更合适。

6.2 批量删除权限

首先新建 /src/permission/dto/remove-permission.dto.ts 文件并添加以下代码:

import { ArrayNotEmpty, IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class RemovePermissionDto {
  @IsNumber({}, { message: 'id 必须为数字', each: true })
  @ArrayNotEmpty({ message: 'id 列表不能为空' })
  @ApiProperty({ description: 'id 列表', type: [Number] })
  ids: number[];
}

然后在 /src/permission/permission.service.ts 中添加以下代码:

  async batchRemove(ids: number[]) {
    const permissions = await this.prismaService.permission.findMany({
      where: {
        id: {
          in: ids,
        },
        deleted: false,
      },
      select: {
        id: true,
        permissionInRole: {
          where: {
            roles: {
              deleted: false,
            },
          },
        },
        children: {
          where: {
            deleted: false,
          },
        },
      },
    });
    
    if (!permissions?.length) {
      throw new BadRequestException('权限不存在');
    }

    const canRemovePermissions = permissions.filter((item) => {
      return item.permissionInRole.length === 0 && item.children.length === 0;
    });

    if (canRemovePermissions.length < ids.length) {
      throw new BadRequestException(
        '部分权限已被角色使用或存在子权限,不能删除',
      );
    }

    await this.prismaService.permission.updateMany({
      where: {
        id: {
          in: ids,
        },
      },
      data: {
        deleted: true,
      },
    });
  }

最后在 /src/permission/permission.controller.ts 中添加以下代码:

  @ApiOperation({ summary: '批量删除权限' })
  @ApiBaseResponse()
  @Authority('system:menu:del')
  @Patch('batch/delete')
  batchRemove(@Body() data: RemovePermissionDto) {
    return this.permissionService.batchRemove(data.ids);
  }

注意:需要将该接口添加在“删除单个权限”接口之前,不然将无法匹配。 image.png

这里我没有专门创建一个批量删除的权限,而是使用与删除权限一样的标识,你可以根据实际情况考虑。

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

下一章节见~