本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。
【在线预览地址】账号:test,密码:d.12345
本章节内容: 1. 分页查询用户接口;2. 获取角色树接口;3. 创建用户接口;4. 获取用户详情接口;5. 更新用户信息接口;6. 删除用户接口;7. 重置用户密码接口。
1. 分页查询用户接口
1.1 定义 DTO 对象
新建 /src/user/dto/query-user.dto.ts
文件并添加以下代码:
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 QueryUserDto extends BaseQueryDto {
@Transform(({ value }) => value === '1')
@IsOptional()
@ApiProperty({ required: false, description: '禁用状态' })
disabled?: boolean;
@MaxLength(50)
@Matches(/[a-zA-Z0-9\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 文档,方便前后端协作。 - 代码可读性和维护性:明确的数据结构定义使代码更易读、更易维护。
1.2 定义接口返回实体
新建 /src/user/entities/user-list.entity.ts
文件并添加以下代码:
ts
import { ApiProperty } from '@nestjs/swagger';
import { UserEntity } from './user.entity';
export class UserListItem extends UserEntity {
@ApiProperty({ description: '用户昵称' })
nickName: string;
@ApiProperty({ description: '用户头像' })
avatar: string;
@ApiProperty({ description: '角色列表', type: [String] })
roleNames: string[];
}
export class UserListEntity {
@ApiProperty({ description: '总数', type: 'number' })
total: number;
@ApiProperty({ description: '用户列表', type: [UserListItem] })
list: UserListItem[];
}
@ApiProperty()
装饰器用来定义 API 端点的响应参数的类型与描述性信息。
@ApiProperty({ description: '角色列表', type: [String] })
为什么在这里使用了 type: [String]
设置类型,因为复杂数据类型,该装饰器无法直接推断出来,需要我们直接指定。
该实体将用作分页查询用户接口的返回数据结构与 Swagger API 文档的自动生成。
1.3 分页查询用户方法实现
打开 /src/user/user.service.ts
文件并添加以下方法:
ts
import { QueryUserDto } from './dto/query-user.dto';
import { Prisma } from '@prisma/client';
async findAll(queryUserDto: QueryUserDto) {
const {
disabled,
keyword,
page = 1,
pageSize = 10,
beginTime,
endTime,
sort = 'desc',
} = queryUserDto;
const where: Prisma.UserWhereInput = {
deleted: false,
disabled,
createdAt: {
gte: beginTime, // 大于等于这个时间
lte: endTime, // 小于等于这个时间
},
OR: [ // 从用户名或用户昵称中根据关键字筛选并忽略大小写,只需任一满足即可
{ userName: { contains: keyword, mode: 'insensitive' } },
{ profile: { nickName: { contains: keyword, mode: 'insensitive' } } },
],
};
const users = await this.prismaService.$transaction([
this.prismaService.user.findMany({
where,
skip: (page - 1) * pageSize, // 跳过条数
take: pageSize, // 每页数量
orderBy: { createdAt: sort },
select: {
id: true,
userName: true,
disabled: true,
createdAt: true,
updatedAt: true,
profile: { // 关联用户信息表查询
select: {
nickName: true,
avatar: true,
},
},
roleInUser: { // 关联角色用户中间表查询
select: {
roles: {
select: {
name: true,
},
},
},
},
},
}),
this.prismaService.user.count({ where }),
]);
const userList = users[0].map((user) => {
return {
id: user.id,
userName: user.userName,
disabled: user.disabled,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
nickName: user.profile?.nickName,
avatar: user.profile?.avatar,
roleNames: user.roleInUser.map((roleInUser) => roleInUser.roles.name),
};
});
return { list: userList, total: users[1] };
}
$transaction
用来开启一个事务。通过将两个查询操作(查找用户列表和计算用户总数)包装在一个事务中,可以确保这两个操作看到的是同一个数据库状态。这避免了在高并发环境下可能出现的不一致情况。
Prisma
可以在一个事务中优化多个查询的执行,减少与数据库的往返通信。这种批处理操作通常比单独执行两个查询更高效,尤其是在网络延迟较高或数据库负载大的情况下。
关键字是根据用户名或用户昵称筛选,所以需要将它们放入 OR
条件中。
1.4 添加查询接口
打开 /src/user/user.controller.ts
文件并添加以下代码:
ts
@ApiOperation({
summary: '获取用户列表',
})
@ApiBaseResponse(UserListEntity)
@Get()
findAll(@Query() query: QueryUserDto): Promise<UserListEntity> {
return this.userService.findAll(query);
}
@ApiOperation({summary: '获取用户列表'})
用来定义 Swagger API 文档中该接口的描述性信息。
@ApiBaseResponse(UserListEntity)
这个装饰器是前面章节封装的,用来设置 Swagger API 文档中该接口的响应数据结构。
@Get()
用来声明一个 GET 类型的接口。
@Query()
装饰器用来获取接口的查询参数,然后指定使用了 QueryUserDto
类对查询参数进行校验与转换格式(需要 class-transformer 等库并在 main.ts 中开启,前面章节中有讲)。
2. 获取角色树接口
在添加用户时,需要关联角色,所以需要先开发一个获取所有有效角色列表的接口。
2.1 定义实体类
新建 /src/role/entities/role-tree.entity.ts
文件并添加以下内容:
ts
import { PickType } from '@nestjs/swagger';
import { RoleEntity } from './role.entity';
export class RoleTreeEntity extends PickType(RoleEntity, ['id', 'name']) {}
PickType
用来从 RoleEntity
实体类中选取需要的属性,这里只需要 id
与 name
信息。
该实体将用作 Swagger API 文档中接口响应数据的类型说明。
2.2 方法实现
打开 /src/role/role.service.ts
文件并添加以下代码:
ts
findRoleTree() {
return this.prismaService.role.findMany({
where: {
deleted: false,
disabled: false,
},
select: {
id: true,
name: true,
},
});
}
角色一般不会太多,直接调用 findMany
批量查询所有即可,需要注意,只能查询出未删除且未禁用的角色。
2.3 添加接口
打开 /src/role/role.controller.ts
文件并添加以下代码:
ts
@ApiOperation({ summary: '获取所有有效角色树' })
@ApiBaseResponse(RoleTreeEntity, 'array')
@Get('tree')
findRoleTree() {
return this.roleService.findRoleTree();
}
@Get('tree')
用来声明一个 GET 类型的接口并设置接口地址为 tree
。
注意:该接口要添加在 @Get(':id')
接口的前面,不然将无法匹配。
3. 创建用户接口
3.1 定义 DTO 对象
新建 /src/user/dto/create-user.dto.ts
文件并添加以下代码:
ts
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
Matches,
IsBoolean,
IsNumber,
IsOptional,
ArrayNotEmpty,
ValidateIf,
} from 'class-validator';
export class CreateUserDto {
@Matches(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/, { message: '用户名格式错误' })
@IsNotEmpty({ message: '用户名不能为空' })
@ApiProperty({ description: '用户名', type: 'string' })
userName: string;
@Matches(/^[a-zA-Z0-9\u4e00-\u9fa5']{1,50}$/, { message: '昵称格式错误' })
@ValidateIf((o) => o.nickName !== '')
@IsOptional()
@ApiProperty({ description: '用户昵称', type: 'string', required: false })
nickName?: string;
@IsBoolean({ message: '禁用状态必须为布尔值' })
@IsOptional()
@ApiProperty({ type: 'boolean', required: false, default: false })
disabled?: boolean;
@IsNumber({}, { message: '角色id列表必须为数字数组', each: true })
@ArrayNotEmpty({ message: '角色id列表不能为空' })
@ValidateIf((o) => o.roles?.length !== 0)
@IsOptional()
@ApiProperty({
description: '角色id列表',
type: [Number],
required: false,
})
roles?: number[];
}
@ValidateIf((o) => o.nickName !== '')
表示仅在 nickName 不为空时,进行后面的校验(如:Matches)。
该 DTO 将用来对创建用户接口的 body 参数进行格式校验。
3.2 添加创建用户方法
打开 /src/user/user.service.ts
文件并添加以下方法:
ts
import bcrypt, { hash } from 'bcrypt';
import { CreateUserDto } from './dto/create-user.dto';
private generateHashPassword(password: string) {
return hash(password, getBaseConfig(this.configService).bcryptSaltRounds);
}
private getDefaultPassword() {
return getBaseConfig(this.configService).defaultPassword;
}
async create(createUserDto: CreateUserDto) {
const user = await this.prismaService.user.findUnique({
where: { userName: createUserDto.userName },
select: { id: true },
});
if (user) {
throw new BadRequestException('用户已存在');
}
const password = this.getDefaultPassword();
const hashedPassword = await this.generateHashPassword(password);
await this.prismaService.user.create({
data: {
userName: createUserDto.userName,
password: hashedPassword,
disabled: createUserDto.disabled,
profile: {
create: {
nickName: createUserDto.nickName,
},
},
// 向用户角色关联表中插入相关信息
roleInUser: createUserDto.roles && {
createMany: {
data: createUserDto.roles.map((roleId) => ({
roleId,
})),
},
},
},
});
}
创建用户时,首先要判断该用户是否已经存在,如果存在则直接返回错误,如不存在,则生成默认登录密码后将该用户插入数据库,如果有设置关联角色,那么还需要向用户角色中间表中查询相应数据。
3.3 添加创建接口
打开 /src/user/user.controller.ts
文件并添加以下代码:
ts
@ApiOperation({
summary: '创建用户',
})
@ApiBaseResponse()
@Authority('system:user:add')
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@ApiBaseResponse()
是前面章节中封装的一个设置 Swagger API 文档接口响应数据结构的装饰器,不传任何参数,则使用默认响应结构。
@Authority()
是前面章节中封装的一个权限装饰器,用来设置接口需要的权限,如果访问用户没有所设置的权限,则无法调用该接口。
@Post()
用来声明一个 POST 类型的接口。@Body()
用来获取请求体参数,然后指定使用 CreateUserDto
类对获取的参数进行格式校验。
这样创建用户接口就开发完成了~
4. 获取用户详情接口
4.1 定义返回实体类
新建 /src/user/entities/user-detail.entity.ts
文件并添加以下代码:
ts
import { ApiProperty, PickType } from '@nestjs/swagger';
import { UserEntity } from './user.entity';
import { ProfileEntity } from './profile.entity';
export class UserDetailEntity extends PickType(UserEntity, [
'userName',
'disabled',
]) {
@ApiProperty({ description: '用户昵称', type: 'string' })
nickName: ProfileEntity['nickName'];
@ApiProperty({ description: '角色 id 列表', type: [Number] })
roles: number[];
}
该实体将用作 Swagger API 文档中获取详情接口响应数据的类型说明。
4.2 方法实现
打开 /src/user/user.service.ts
文件并添加以下方法:
ts
async findOne(id: string) {
const user = await this.prismaService.user.findUnique({
where: { id, deleted: false },
select: {
userName: true,
disabled: true,
profile: { // 关联查询用户信息表的昵称
select: { nickName: true },
},
roleInUser: { // 关联查询用户角色表的角色ID
select: {
roleId: true,
},
},
},
});
if (!user) {
throw new BadRequestException('用户不存在');
}
const profile = user.profile;
const roles = user.roleInUser.map((role) => role.roleId);
return {
userName: user.userName,
disabled: user.disabled,
...profile,
roles,
};
}
直接调用 findUnique
方法查询指定用户的信息即可,需要注意只能查询未删除用户的信息。
4.3 添加接口
打开 /src/user/user.controller.ts
文件并添加以下接口:
ts
@ApiOperation({
summary: '获取用户详情',
})
@ApiBaseResponse(UserDetailEntity)
@Get(':id')
findOne(@Param('id') id: string): Promise<UserDetailEntity> {
return this.userService.findOne(id);
}
@Get(':id')
定义了一个 Get
请求的动态路由,:id
是一个动态路由参数,例如:/user/10
,则 id 为 10。
@Param(id)
用于获取 URL
中的动态路由参数。
5. 更新用户信息接口
5.1 定义 DTO 对象
新建 /src/user/dto/update-user.dto.ts
文件并添加以下代码:
ts
import { PickType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PickType(CreateUserDto, [
'disabled',
'nickName',
'roles',
]) {}
只需继承 CreateUserDto
类并选择允许修改的属性即可。
5.2 更新方法实现
因为可以修改用户关联的角色,所以需要在关联角色变更时,删除该用户已缓存的权限信息。
那么首先,需要在 user.module.ts
中引入 redis 模块:
ts
...
import { RedisModule } from 'src/redis/redis.module';
@Module({
imports: [RedisModule],
...
})
export class UserModule {}
然后,在 user.service.ts
中注册 redis 服务:
ts
...
import { RedisService } from 'src/redis/redis.service';
@Injectable()
export class UserService {
constructor(
private readonly prismaService: PrismaService,
private readonly configService: ConfigService,
private readonly redisService: RedisService, // 注入
) {}
...
}
最后,在 /src/user/user.service.ts
中添加以下方法:
ts
private async removeUserToken(id: string) {
const key = this.redisService.generateSSOKey(id);
const token = await this.redisService.getSSO(key);
if (token) {
this.redisService.setBlackList(token);
}
this.redisService.delSSO(key);
}
async update(id: string, updateUserDto: UpdateUserDto) {
const user = await this.prismaService.user.findUnique({
where: { id, deleted: false },
select: { userName: true },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
if (
user.userName === getBaseConfig(this.configService).defaultAdmin.username
) {
if (updateUserDto.disabled) {
throw new BadRequestException('不能禁用超级管理员');
}
if (updateUserDto.roles) {
throw new BadRequestException('不能修改超级管理员角色');
}
}
const data: Prisma.UserUpdateInput = {
disabled: updateUserDto.disabled,
profile: {
update: {
nickName: updateUserDto.nickName,
},
},
};
// 禁用角色后,要清除权限缓存信息与移除 token
if (updateUserDto.disabled) {
this.redisService.delUserPermission(id);
this.removeUserToken(id);
}
if (updateUserDto.roles || updateUserDto.roles === null) {
// 清除用户权限缓存
this.redisService.delUserPermission(id);
// 删除所有关联角色
data.roleInUser = {
deleteMany: {},
};
if (updateUserDto.roles.length) {
// 关联新角色
data.roleInUser.createMany = {
data: updateUserDto.roles.map((roleId) => ({
roleId,
})),
};
}
}
await this.prismaService.user.update({
where: { id, deleted: false },
data,
});
}
要注意,超级管理员只允许修改昵称。
禁用用户后,如果该用户已经登录了,则需要将该用户登录的 token 加入黑名单中。
5.3 添加接口
打开 /src/user/user.controller.ts
文件并添加以下代码:
ts
@ApiOperation({
summary: '更新用户信息',
})
@ApiBaseResponse()
@Authority('system:user:edit')
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(id, updateUserDto);
}
@Patch(':id')
定义了一个 Patch 请求的动态路由,:id
是一个动态路由参数。
因为是部分更新,所以使用 Patch
更合适。
6. 删除用户接口
6.1 删除方法实现
打开 /src/user/user.service.ts
文件并添加以下方法:
ts
async remove(id: string) {
const user = await this.prismaService.user.findUnique({
where: { id, deleted: false },
select: { userName: true },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
if (
user.userName === getBaseConfig(this.configService).defaultAdmin.username
) {
throw new BadRequestException('不能删除超级管理员');
}
this.redisService.delUserPermission(id);
this.removeUserToken(id);
await this.prismaService.user.update({
where: { id },
data: { deleted: true },
});
}
要注意:1. 不能删除系统默认超级管理员;2. 用户缓存的权限信息与 token 也要清除。
6.3 添加删除接口
打开 /src/user/user.controller.ts
文件并添加以下代码:
ts
@ApiOperation({
summary: '删除用户',
})
@ApiBaseResponse()
@Authority('system:user:del')
@Patch(':id/delete')
remove(@Param('id') id: string) {
return this.userService.remove(id);
}
因为这里只是软删除,仅修改 deleted
字段的状态而已,所以使用 Patch
更合适。
7. 重置用户密码接口
7.1 重置方法实现
打开 /src/user/user.service.ts
文件并添加以下方法:
ts
async resetPassword(id: string) {
const user = await this.prismaService.user.findUnique({
where: { id, deleted: false },
select: { userName: true },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
if (
user.userName === getBaseConfig(this.configService).defaultAdmin.username
) {
throw new BadRequestException('不能重置超级管理员密码');
}
const defaultPassword = this.getDefaultPassword();
const password = await this.generateHashPassword(defaultPassword);
await this.prismaService.user.update({
where: { id },
data: { password },
});
return { message: `重置密码成功,默认密码为:${defaultPassword}` };
}
要注意:不能重置系统默认超级管理员的密码。
7.2 添加接口
打开 /src/user/user.controller.ts
文件并添加以下代码:
ts
@ApiOperation({
summary: '重置密码',
})
@ApiBaseResponse()
@Authority('system:user:reset')
@Patch(':id/reset')
resetPassword(@Param('id') id: string) {
return this.userService.resetPassword(id);
}
因为这里只是更新 password 字段的值,所以使用 Patch
更合适。
该模块的单元测试代码见代码仓库。