本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。
【在线预览地址】账号:test,密码:d.12345
本章节内容: 1. 获取角色列表接口;2. 创建角色接口;3. 获取角色详情接口;4. 更新角色信息接口;5. 删除角色接口。
1. 创建 role 模块
首先在项目根目录打开一个终端窗口并输入 nest g res role 命令,如下图:
然后将生成的测试相关文件移入 /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 文件的好处:
- 数据验证:通过使用类验证器(如
class-validator),可以确保传入的数据符合预期的格式和规则。 - 数据转换:可以使用类转换器(如
class-transformer)将普通的 JavaScript 对象转换为类实例,从而利用类的功能。 - 文档生成:结合
@nestjs/swagger等工具,可以自动生成 API 文档,方便前后端协作。 - 代码可读性和维护性:明确的数据结构定义使代码更易读、更易维护。
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 的查询中,用于过滤数据库中的角色记录:
disabled: 过滤是否禁用的角色deleted: false: 只查询未删除的角色name: 根据关键字模糊查询角色名称contains: keyword: 包含指定关键字mode: 'insensitive': 大小写不敏感
createdAt: 根据创建时间范围筛选gte: beginTime: 大于等于开始时间lte: endTime: 小于等于结束时间
orderBy: 指定数据排序条件createdAt: sort: 根据传入参数决定是按创建时间升序还是降序
take: 设置要获取的数据条数,相当于 SQL 中的 LIMIT ,用于实现数据的分页查询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 接口进行分组管理,后续该控制器中所有接口都将在该分组下。效果如下图:
@ApiBearerAuth() 装饰器用来在 Swagger 文档中标识需要 Bearer Token 认证的接口,加在控制器上,则该控制器里的所有接口都需要 Bearer Token 认证。如下图:
@ApiOperation() 装饰器是用来为 Swagger 文档添加接口说明的,如下图:
@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来定义 API 端点的成功响应与为 Swagger 文档提供接口响应数据的类型说明。
@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;
}
可以发现 description 与 permissions 字段添加了一个特殊的条件校验装饰器 @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 网址即可看到该接口的文档了。
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 更合适。
那么删除接口也开发完成了,打开测试工具试试吧~
相关单元测试代码请查看代码仓库哦。