本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。
【在线预览地址】账号:test,密码:d.12345
本章节内容: 1. 获取用户个人信息接口;2. 修改用户个人信息接口;3. 修改密码接口。
1. 获取用户个人信息接口
1.1 定义返回实体类
首先,新建 /src/user/entities/profile.entity.ts 文件,并添加以下内容:
import { Profile } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
export class ProfileEntity
implements Omit<Profile, 'id' | 'createdAt' | 'updatedAt' | 'birthday'>
{
@ApiProperty({ description: '用户id' })
userId: string;
@ApiProperty({ description: '用户昵称' })
nickName: string;
@ApiProperty({ description: '用户头像' })
avatar: string;
@ApiProperty({ description: '用户邮箱' })
email: string;
@ApiProperty({ description: '用户手机号' })
phone: string;
@ApiProperty({ description: '用户性别', enum: ['MA', 'FE', 'OT'] })
gender: 'MA' | 'FE' | 'OT';
@ApiProperty({ description: '用户生日(UTC)' })
birthday: string;
@ApiProperty({ description: '用户描述' })
description: string;
}
class ProfileEntity implements Omit<Profile, 'id' | 'createdAt' | 'updatedAt' | 'birthday'> 表示定义一个 ProfileEntity 类,该类需要实现 Profile 类中除了使用 Omit 排除的四个属性以外的所有属性。
@ApiProperty() 装饰器用来定义 API 端点的响应参数的属性。
该实体将用作 Swagger API 文档中接口响应数据的类型说明。
最后,新建 /src/user/entities/user-profile.entity.ts 并添加以下内容:
import { ApiProperty, OmitType } from '@nestjs/swagger';
import { ProfileEntity } from './profile.entity';
import { UserEntity } from './user.entity';
export class UserProfileEntity extends OmitType(ProfileEntity, ['userId']) {
@ApiProperty({ description: '用户名', type: 'string' })
userName: UserEntity['userName'];
@ApiProperty({ description: '角色列表', type: [String] })
roles: string[];
}
OmitType 是 @nestjs/swagger 提供的用来排除实体类中某些属性的方法,使用 Omit 将无法正确生成文档。
该实体将用作 Swagger API 文档中获取用户个人信息接口的响应数据的类型说明。
1.2 添加查询方法
打开 /src/user/user.service.ts 文件,添加以下方法:
import { NotFoundException, Injectable } from '@nestjs/common';
import { UserProfileEntity } from './entities/user-profile.entity';
async findProfile(id: string) {
const profiles: (UserProfileEntity & {
user_name: string;
nick_name: string;
role_names: string;
})[] = await this.prismaService.$queryRaw`
WITH user_base AS (SELECT id, user_name FROM users WHERE id = ${id})
SELECT ub.user_name, p.avatar, p.nick_name, p.birthday, p.description, p.email, p.gender,
p.phone, COALESCE(string_agg(r.name, ','), '') AS role_names
FROM user_base ub
INNER JOIN profiles p ON ub.id = p.user_id
LEFT JOIN role_in_user ur ON ub.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
GROUP BY ub.id, ub.user_name, p.id, p.avatar, p.nick_name, p.birthday, p.description,
p.email, p.gender, p.phone;
`;
if (!profiles || profiles.length === 0) {
throw new NotFoundException('用户不存在');
}
const profile = profiles[0];
const userName = profile.user_name;
delete profile.user_name;
const nickName = profile.nick_name;
delete profile.nick_name;
const roles = profile.role_names ? profile.role_names.split(',') : [];
delete profile.role_names;
const userProfile: UserProfileEntity = {
...profile,
userName,
roles,
nickName,
};
return userProfile;
}
这里使用了原生 sql 语句查询,因为更方便一点。
1.3 添加接口
修改 /src/user/user.controller.ts 文件的内容为:
import { Controller, Get, Req } from '@nestjs/common';
import { UserService } from './user.service';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBaseResponse } from 'src/common/decorator/api-base-response.decorator';
import { UserProfileEntity } from './entities/user-profile.entity';
import { IPayload } from 'src/common/types';
@ApiTags('user')
@ApiBearerAuth()
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@ApiOperation({
summary: '获取当前个人用户信息',
})
@ApiBaseResponse(UserProfileEntity)
@Get('profile')
findProfile(@Req() req: { user: IPayload }): Promise<UserProfileEntity> {
return this.userService.findProfile(req.user.userId);
}
}
@ApiTags('user') 装饰器用来给整个控制器添加 user 标签,方便对 Swagger API 接口进行分组管理。
@ApiBearerAuth() 装饰器用来在 Swagger API 文档中标识需要 Bearer Token 认证的接口,加在控制器上,则该控制器里的所有接口都需要 Bearer Token 认证。
@ApiOperation() 装饰器用来为 Swagger API 文档中该接口添加说明信息。
@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来定义 API 端点的成功响应并为 Swagger API 文档提供接口响应数据的类型说明。
@Get('profile') 表示声明一个 GET 类型的接口并设置接口地址为 profile。
之前的章节中,我们已经通过 jwt 策略将用户信息添加到 req 对象上了,所以这里可以从 req 对象上直接取出。
1.4 单元测试
首先添加方法(service)的测试代码,在 /src/user/test/user.service.spec.ts 文件中添加以下代码:
describe('findProfile', () => {
it('should return user profile', async () => {
// Arrange
const mockQueryRaw = prismaService.$queryRaw as jest.Mock;
const userProfile = [
{
user_name: 'testUser',
nick_name: 'testNickName',
avatar: 'testAvatar',
role_names: 'admin,user',
id: 'testId',
gender: 'OT',
phone: 'testPhone',
email: 'testEmail',
birthday: '2022-02-02',
description: 'testDescription',
},
];
mockQueryRaw.mockResolvedValue(userProfile);
// Act
const result = await userService.findProfile('testId');
// Assert
expect(result).toEqual({
...userProfile[0],
userName: 'testUser',
roles: ['admin', 'user'],
nickName: 'testNickName',
});
});
it('should return user profile with empty roles', async () => {
// Arrange
const mockQueryRaw = prismaService.$queryRaw as jest.Mock;
const userProfile = [
{
user_name: 'testUser',
nick_name: 'testNickName',
avatar: 'testAvatar',
role_names: '',
id: 'testId',
gender: 'OT',
phone: 'testPhone',
email: 'testEmail',
birthday: '2022-02-02',
description: 'testDescription',
},
];
mockQueryRaw.mockResolvedValue(userProfile);
// Act
const result = await userService.findProfile('testId');
// Assert
expect(result).toEqual({
...userProfile[0],
userName: 'testUser',
roles: [],
nickName: 'testNickName',
});
});
it('should throw NotFoundException when user not found', async () => {
// Arrange
const mockQueryRaw = prismaService.$queryRaw as jest.Mock;
mockQueryRaw.mockResolvedValue([]);
// Act
try {
await userService.findProfile('testId');
} catch (e) {
// Assert
expect(e.message).toBe('用户不存在');
}
});
});
测试了该方法可能存在的3种情况:1. 查询到了用户信息且存在角色列表;2. 查询到了用户信息但没有角色信息;3. 无法查询到该用户。
该文件完整的测试代码请查看代码仓库。
然后添加接口的测试代码,打开 /src/user/test/user.controller.spec.ts 文件并改为以下内容:
import { TestBed } from '@automock/jest';
import { UserController } from '../user.controller';
import { UserService } from '../user.service';
import { NotFoundException } from '@nestjs/common';
describe('UserController', () => {
let userController: UserController;
let userService: jest.Mocked<UserService>;
beforeAll(() => {
const { unit, unitRef } = TestBed.create(UserController).compile();
userController = unit;
userService = unitRef.get(UserService);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(userController).toBeDefined();
});
describe('findProfile', () => {
const mockUserId = 'testUser';
const mockProfile = {
userName: 'testUser',
nickName: 'testNickName',
avatar: 'testAvatar',
roles: ['admin'],
gender: 'OT' as const,
phone: 'testPhone',
email: 'testEmail',
birthday: '2022-02-02',
description: 'testDescription',
};
it('should return user profile', async () => {
userService.findProfile.mockResolvedValue(mockProfile);
const result = await userController.findProfile({
user: { userId: mockUserId, userName: mockUserId },
});
expect(result).toEqual(mockProfile);
expect(userService.findProfile).toHaveBeenCalledWith(mockUserId);
expect(userService.findProfile).toHaveBeenCalledTimes(1);
});
it('should throw NotFoundException if user does not exist', async () => {
const errorMessage = '用户不存在';
userService.findProfile.mockRejectedValue(
new NotFoundException(errorMessage),
);
await expect(
userController.findProfile({
user: { userId: mockUserId, userName: mockUserId },
}),
).rejects.toThrow(errorMessage);
});
});
});
测试了该接口可能存在的两种情况:1. 正确查询到用户信息; 2. 无法查询到用户信息。
2. 修改用户个人信息接口
2.1 定义 DTO 对象
新建 /src/user/dto/update-profile.dto.ts 文件并添加以下内容:
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsOptional,
IsUrl,
Matches,
MaxLength,
ValidateIf,
} from 'class-validator';
export class UpdateProfileDto {
@Matches(/^[a-zA-Z0-9\u4e00-\u9fa5']{1,50}$/, { message: '昵称格式错误' })
@ValidateIf((o) => o.nickName !== '')
@IsOptional()
@ApiProperty({ description: '用户昵称', required: false })
nickName?: string;
@MaxLength(255, { message: '头像地址长度不能超过255个字符' })
@IsUrl(
{
host_whitelist: [
'localhost',
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
],
},
{ message: '头像地址格式错误' },
)
@IsOptional()
@ApiProperty({ description: '头像', required: false })
avatar?: string;
@Matches(/^(MA|FE|OT)$/, { message: '性别格式错误' })
@IsOptional()
@ApiProperty({
description: '性别',
enum: ['MA', 'FE', 'OT'],
required: false,
})
gender?: 'MA' | 'FE' | 'OT';
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
@ValidateIf((o) => o.phone !== '')
@IsOptional()
@ApiProperty({ description: '手机号', required: false })
phone?: string;
@IsEmail({}, { message: '邮箱格式错误' })
@ValidateIf((o) => o.email !== '')
@IsOptional()
@ApiProperty({ description: '邮箱', required: false })
email?: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: '生日格式错误',
})
@IsOptional()
@ApiProperty({ description: '生日(yyyy-MM-dd)', required: false })
birthday?: string;
@MaxLength(150, { message: '个性签名长度不能超过150个字符' })
@ValidateIf((o) => o.description !== '')
@IsOptional()
@ApiProperty({ description: '个性签名', required: false })
description?: string;
}
@IsOptional() 表示这个属性是可选的。
@ValidateIf((o) => o.nickName !== '') 表示仅在该属性不为空时才进入下一个校验 @Matches()。
校验头像格式时,使用 host_whitelist 放过了一些地址,主要是为了开发环境方便测试。
使用 DTO 文件的好处:
- 数据验证:通过使用类验证器(如
class-validator),可以确保传入的数据符合预期的格式和规则。 - 数据转换:可以使用类转换器(如
class-transformer)将普通的 JavaScript 对象转换为类实例,从而利用类的功能。 - 文档生成:结合
@nestjs/swagger等工具,可以自动生成 API 文档,方便前后端协作。 - 代码可读性和维护性:明确的数据结构定义使代码更易读、更易维护。
2.2 添加更新方法
打开 /src/user/user.service.ts 文件,添加以下方法:
import { UpdateProfileDto } from './dto/update-profile.dto';
async updateProfile(id: string, profile: UpdateProfileDto) {
if (profile.birthday) {
profile.birthday = profile.birthday + 'T00:00:00Z';
}
await this.prismaService.profile.update({
where: { userId: id },
data: profile,
});
}
这里对生日的格式进行了额外处理,是因为 prisma 只支持 GMT 格式。
2.3 添加更新接口
打开 /src/user/user.controll.ts 文件,修改为:
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
import { UserService } from './user.service';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBaseResponse } from 'src/common/decorator/api-base-response.decorator';
import { UserProfileEntity } from './entities/user-profile.entity';
import { IPayload } from 'src/common/types';
import { UpdateProfileDto } from './dto/update-profile.dto';
@ApiTags('user')
@ApiBearerAuth()
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
...
@ApiOperation({
summary: '更新当前用户个人信息',
})
@ApiBaseResponse()
@Patch('profile')
updateProfile(
@Req() req: { user: IPayload },
@Body() updateProfileDto: UpdateProfileDto,
) {
return this.userService.updateProfile(req.user.userId, updateProfileDto);
}
}
在这里使用 Patch 的原因是,在 RESTful API 设计中,Patch 用于部分更新资源。客户端只需要发送需要更新的字段,服务器会更新这些字段而不是替换整个资源。
2.4 单元测试
打开 /src/user/test/user.service.spec.ts 文件,新增以下内容:
import { TestBed } from '@automock/jest';
import { PrismaService } from 'nestjs-prisma';
import { UserService } from '../user.service';
import { User } from '@prisma/client';
describe('UserService', () => {
let userService: UserService;
let prismaService: jest.Mocked<PrismaService>;
beforeEach(() => {
const { unit, unitRef } = TestBed.create(UserService)
.mock(PrismaService)
.using({
user: {
findUnique: jest.fn(),
},
profile: { // 新增
update: jest.fn(),
},
})
.compile();
userService = unit;
prismaService = unitRef.get(PrismaService);
});
...
// 新增
describe('updateProfile', () => {
it('should update user profile', async () => {
// Arrange
const mockUpdate = prismaService.profile.update as jest.Mock;
const profile = {
nickName: 'testNickName',
avatar: 'https://localhost/test.png',
phone: '18001187109',
email: '2192415523@qq.com',
gender: 'FE' as const,
birthday: '2023-02-03',
description: 'test',
};
// Act
await userService.updateProfile('testId', profile);
// Assert
expect(mockUpdate).toHaveBeenCalledWith({
where: { userId: 'testId' },
data: profile,
});
expect(profile.birthday).toBe('2023-02-03T00:00:00Z');
});
});
});
打开 /src/user/test/user.controller.spec.ts 文件,添加以下测试方法:
describe('updateProfile', () => {
const mockUserId = 'testUser';
const mockProfileDto = {
nickName: 'newNickName',
avatar: 'newAvatar',
gender: 'FE' as const,
phone: 'newPhone',
email: 'newEmail',
birthday: '2023-01-01',
description: 'newDescription',
};
it('should update user profile successfully', async () => {
userService.updateProfile.mockResolvedValue(undefined);
await userController.updateProfile(
{ user: { userId: mockUserId, userName: mockUserId } },
mockProfileDto,
);
expect(userService.updateProfile).toHaveBeenCalledWith(
mockUserId,
mockProfileDto,
);
expect(userService.updateProfile).toHaveBeenCalledTimes(1);
});
it('should throw NotFoundException if user does not exist', async () => {
const errorMessage = '用户不存在';
userService.updateProfile.mockRejectedValue(
new NotFoundException(errorMessage),
);
await expect(
userController.updateProfile(
{ user: { userId: mockUserId, userName: mockUserId } },
mockProfileDto,
),
).rejects.toThrow(errorMessage);
});
});
打开 postman 试试吧。
3. 修改密码接口
3.1 定义 DTO 对象
新建 /src/user/dto/update-password.dto.ts 文件并添加以下内容:
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';
export class UpdatePasswordDto {
@Matches(/^[a-zA-Z](?=.*[.?!&_])(?=.*\d)[a-zA-Z\d.?!&_]{5,15}$/, {
message: '密码格式错误',
})
@IsNotEmpty({ message: '原密码不能为空' })
@ApiProperty({ description: '原密码' })
oldPassword: string;
@Matches(/^[a-zA-Z](?=.*[.?!&_])(?=.*\d)[a-zA-Z\d.?!&_]{5,15}$/, {
message: '密码格式错误',
})
@IsNotEmpty({ message: '新密码不能为空' })
@ApiProperty({ description: '新密码' })
newPassword: string;
}
该 DTO 类将用来校验修改密码接口的请求体参数。
3.2 添加更新方法
打开 /src/user/user.service.ts 文件,添加以下内容:
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import bcrypt from 'bcrypt'; // 新增
import { ConfigService } from '@nestjs/config'; // 新增
import { getBaseConfig } from 'src/common/config'; // 新增
import { UserPermissionInfoEntity } from './entities/user-permission-info.entity';
import { UserProfileEntity } from './entities/user-profile.entity';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpdatePasswordDto } from './dto/update-password.dto'; // 新增
@Injectable()
export class UserService {
constructor(
private readonly prismaService: PrismaService,
private readonly configService: ConfigService, // 新增
) {}
...
async updatePassword(userId: string, updatePasswordDto: UpdatePasswordDto) {
if (updatePasswordDto.oldPassword === updatePasswordDto.newPassword) {
throw new BadRequestException('新密码和原密码不能相同');
}
const user = await this.prismaService.user.findUnique({
where: { id: userId },
select: { password: true },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
const isPasswordValid = await bcrypt.compare(
updatePasswordDto.oldPassword,
user.password,
);
if (!isPasswordValid) {
throw new BadRequestException('原密码错误');
}
const hashPassword = await bcrypt.hash(
updatePasswordDto.newPassword,
getBaseConfig(this.configService).bcryptSaltRounds,
);
await this.prismaService.user.update({
where: { id: userId },
data: { password: hashPassword },
});
}
}
为什么需要使用 bcrypt.compare 方法校验原密码?因为数据库里存储的密码是使用 bcrypt.hash 方法加密后的哈希字符串。
3.3 添加更新接口
打开 /src/user/user.controller.ts 文件,添加以下接口:
@ApiOperation({
summary: '修改密码',
})
@ApiBaseResponse()
@Patch('password')
updatePassword(
@Req() req: { user: IPayload },
@Body() updatePasswordDto: UpdatePasswordDto,
) {
return this.userService.updatePassword(req.user.userId, updatePasswordDto);
}
不使用 Post 而使用 Patch,是因为这样更符合 RESTful API 的设计原则,表示对资源的部分更新,这里刚好只是更新了 password 字段。
3.4 单元测试
打开 /src/user/test/user.service.spec.ts 文件,添加以下测试方法:
import bcrypt from 'bcrypt';
describe('updatePassword', () => {
it('should throw BadRequestException when new password is same as old password', async () => {
// Arrange
const updatePasswordDto = {
oldPassword: 'password123',
newPassword: 'password123',
};
// Act & Assert
await expect(
userService.updatePassword('testId', updatePasswordDto),
).rejects.toThrow('新密码和原密码不能相同');
});
it('should throw NotFoundException when user not found', async () => {
// Arrange
const mockFindUnique = prismaService.user.findUnique as jest.Mock;
mockFindUnique.mockResolvedValue(null);
const updatePasswordDto = {
oldPassword: 'password123',
newPassword: 'newPassword123',
};
// Act & Assert
await expect(
userService.updatePassword('testId', updatePasswordDto),
).rejects.toThrow('用户不存在');
});
it('should throw BadRequestException when old password is incorrect', async () => {
// Arrange
const mockFindUnique = prismaService.user.findUnique as jest.Mock;
mockFindUnique.mockResolvedValue({
password: 'hashedOldPassword',
});
const updatePasswordDto = {
oldPassword: 'wrongPassword',
newPassword: 'newPassword123',
};
// Act & Assert
await expect(
userService.updatePassword('testId', updatePasswordDto),
).rejects.toThrow('原密码错误');
});
it('should update password when all inputs are valid', async () => {
// Arrange
const mockFindUnique = prismaService.user.findUnique as jest.Mock;
const mockUpdate = prismaService.user.update as jest.Mock;
mockFindUnique.mockResolvedValue({
password: 'hashedOldPassword',
});
mockUpdate.mockResolvedValue({});
const updatePasswordDto = {
oldPassword: 'correctPassword',
newPassword: 'newPassword123',
};
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(true));
jest
.spyOn(bcrypt, 'hash')
.mockImplementation(() => Promise.resolve('newHashedPassword'));
// Act
await userService.updatePassword('testId', updatePasswordDto);
// Assert
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'testId' },
data: { password: 'newHashedPassword' },
});
});
});
打开 /src/user/test/user.controller.spec.ts 文件并添加以下测试方法:
describe('updatePassword', () => {
const mockUserId = 'testUser';
const mockPasswordDto = {
oldPassword: 'oldPassword123',
newPassword: 'newPassword123',
};
it('should update password successfully', async () => {
userService.updatePassword.mockResolvedValue(undefined);
await userController.updatePassword(
{ user: { userId: mockUserId, userName: mockUserId } },
mockPasswordDto,
);
expect(userService.updatePassword).toHaveBeenCalledWith(
mockUserId,
mockPasswordDto,
);
expect(userService.updatePassword).toHaveBeenCalledTimes(1);
});
it('should throw NotFoundException if user does not exist', async () => {
const errorMessage = '用户不存在';
userService.updatePassword.mockRejectedValue(
new NotFoundException(errorMessage),
);
await expect(
userController.updatePassword(
{ user: { userId: mockUserId, userName: mockUserId } },
mockPasswordDto,
),
).rejects.toThrow(errorMessage);
});
it('should throw error if old password is incorrect', async () => {
const errorMessage = '原密码错误';
userService.updatePassword.mockRejectedValue(new Error(errorMessage));
await expect(
userController.updatePassword(
{ user: { userId: mockUserId, userName: mockUserId } },
mockPasswordDto,
),
).rejects.toThrow(errorMessage);
});
});
本文中的所有测试代码都是使用 Github.copilot(Claude 3.5 Sonnet) 生成。
下一章节见~