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

379 阅读8分钟

本系列教程将教你使用 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 文件的好处:

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

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) 生成。

下一章节见~