个人博客nestjs用户管理模块

200 阅读8分钟

用户管理

生成user模块

nest res g user

image-20231013142757867.png 然后我们在 src/user/entities 目录,新建 3 个实体 User、Role、Permission。

用户表:

字段名数据描述描述
idINT用户ID
usernameVARCHAR(50)用户名
passwordVARCHAR(50)密码
nick_nameVARCHAR(50)昵称
emailVARCHAR(50)邮箱
head_picVARCHAR(100)头像
phone_numberVARCHAR(20)手机号
is_frozenBOOLEAN是否被冻结
is_adminBOOLEAN是否是管理员
create_timeDATETIME创建时间
update_timeDATETIME更新时间
ipVARCHA(50)ip地址

角色表 roles

字段名数据描述描述
idINTID
nameVARCHAR(20)角色名

角色表 permissions

字段名数据类型描述
idINTID
codeVARCHAR(20)权限代码
descriptionVARCHAR(100)权限描述

用户-角色中间表 user_roles

字段名数据类型描述
idINTID
user_idINT用户 ID
role_idINT角色 ID

角色-权限中间表 role_permissions

字段名数据类型描述
idINTID
role_idINT角色 ID
permission_idINT权限 ID

代码实现;

import { Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Role } from "./role.entity";
​
@Entity({
    name: 'users'
})
export class User {
​
    @PrimaryGeneratedColumn()
    id: number;
    @Column({
        length: 20,
        comment: '用户ip',
        nullable: true
    })
    ip: string;
    @Column({
        length: 50,
        comment: '用户名'
    })
    username: string;
    @Column({
        length: 50,
        comment: '盐'
    })
    salt: string;
    @Column({
        length: 60,
        comment: '密码'
    })
    password: string;
​
    @Column({
        name: 'nick_name',
        length: 50,
        comment: '昵称'
    })
    nickName: string;
​
​
    @Column({
        comment: '邮箱',
        length: 50
    })
    email: string;
​
​
    @Column({
        comment: '头像',
        length: 100,
        nullable: true
    })
    headPic: string;
​
    @Column({
        comment: '手机号',
        length: 20,
        nullable: true
    })
    phoneNumber: string;
​
    @Column({
        comment: '是否冻结',
        default: false
    })
    isFrozen: boolean;
​
    @Column({
        comment: '是否是管理员',
        default: false
    })
    isAdmin: boolean;
​
    @CreateDateColumn()
    createTime: Date;
​
    @UpdateDateColumn()
    updateTime: Date;
​
    @ManyToMany(() => Role)
    @JoinTable({
        name: 'user_roles'
    })
    roles: Role[] 
}
​
​

roles:

import { Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Permission } from "./permission.entity";
​
@Entity({
    name: 'roles'
})
export class Role {
    @PrimaryGeneratedColumn()
    id: number;
​
    @Column({
        length: 20,
        comment: '角色名'
    })
    name: string;
​
    @ManyToMany(() => Permission)
    @JoinTable({
        name: 'role_permissions'
    })
    permissions: Permission[] 
}
​

permission:

import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
​
@Entity({
    name: 'permissions'
})
export class Permission {
    @PrimaryGeneratedColumn()
    id: number;
​
    @Column({
        length: 20,
        comment: '权限代码'
    })
    code: string;
​
    @Column({
        length: 100,
        comment: '权限描述'
    })
    description: string;
}
​

image-20231013211614515.png

注册接口

在 base.controller 增加一个 post 接口:

 @Post('register')
  async create(@Body() user: RegisterUserDto, @Request() headers) {
      console.log(registerUser);
      return "success"
  }

dto 是封装 body 里的请求参数的,根据前端界面上要填的信息,创建 RegisterUserDto:

export class RegisterUserDto {
​
    username: string;
    
    nickName: string;
    
    password: string;
    
    email: string;
    
    captcha: string;
}
​

然后加一下 ValidationPipe,来对请求体做校验。

安装用到的包:

npm install --save class-validator class-transformer

全局启用 ValidationPipe:

image-20231013212023577.png

然后加一下校验规则:

import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
​
export class RegisterUserDto {
​
    @IsNotEmpty({
        message: "用户名不能为空"
    })
    username: string;
    
    @IsNotEmpty({
        message: '昵称不能为空'
    })
    nickName: string;
    
    @IsNotEmpty({
        message: '密码不能为空'
    })
    @MinLength(6, {
        message: '密码不能少于 6 位'
    })
    password: string;
    
    @IsNotEmpty({
        message: '邮箱不能为空'
    })
    @IsEmail({}, {
        message: '不是合法的邮箱格式'
    })
    email: string;
    
    @IsNotEmpty({
        message: '验证码不能为空'
    })
    captcha: string;
}
​

实现验证码发送

@Get('register-captcha')
async captcha(@Query('address') address: string) {
    const code = Math.random().toString().slice(2,8);
​
    await this.redisService.set(`captcha_${address}`, code, 5 * 60);
​
    await this.emailService.sendMail({
      to: address,
      subject: '注册验证码',
      html: `<p>你的注册验证码是 ${code}</p>`
    });
    return '发送成功';
}

然后实现注册的逻辑。

在 userService 里添加 register 方法:

创建 logger 对象,注入 Repository。

这里注入 Repository 需要在 UserModule 里引入下 TypeOrm.forFeature

 private logger = new Logger();
​
  @InjectRepository(User)
  private userRepository: Repository<User>;
​
  @InjectRepository(Role)
  private roleRepository: Repository<Role>;
​
  @InjectRepository(Permission)
  private permissionRepository: Repository<Permission>;
​
  @Inject(RedisService)
  private redisService: RedisService;
  
  @Inject(EmailService)
  private emailService: EmailService;

image-20231013212151029.png

这里密码使用了bcryptjs加密方法

登录

在 UserController 添加两个接口:

@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    console.log(loginUser);
    return 'success';
}
​
@Post('admin/login')
async adminLogin(@Body() loginUser: LoginUserDto) {
    console.log(loginUser);
    return 'success';
}

添加 src/user/dto/login-user.dto.ts:

import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";
​
export class LoginUserDto {
​
    @IsNotEmpty({
        message: "用户名不能为空"
    })
    @ApiProperty()
    username: string;
    
    @IsNotEmpty({
        message: '密码不能为空'
    })
    @ApiProperty()
    password: string;    
}
​

然后在 UserService 实现 login 方法:

async login(loginUserDto: LoginUserDto, isAdmin: boolean) {
    const user = await this.userRepository.findOne({
      where: {
        username: loginUserDto.username,
        isAdmin
      },
      relations: ['roles', 'roles.permissions']
    });
    if(!user) {
      throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
    }
    if(user.isFrozen) {
      throw new HttpException('用户已被冻结,请联系管理员', HttpStatus.BAD_REQUEST);
    }
    const isPasswordValid = await compare(loginUserDto.password, user.password);
    if(!isPasswordValid) {
      throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
    }
​
    const VO = new LoginUserVo();
    VO.userInfo = {
      id: user.id,
      username: user.username,
      nickName: user.nickName,
      email: user.email,
      phoneNumber: user.phoneNumber,
      headPic: user.headPic,
      createTime: user.createTime.getTime(),
      isFrozen: user.isFrozen,
      isAdmin: user.isAdmin,
      roles: user.roles.map(item => item.name),
      permissions: user.roles.reduce((arr, item) => {
        item.permissions.forEach(permission => {
          if(arr.indexOf(permission) === -1) {
            arr.push(permission);
          }
        })
        return arr;
    }, [])
    }
    return VO;
    
  }

我们创建个 vo (view object)对象来封装返回的数据。

dto 是接收参数的,vo 是封装返回的数据的,entity 是和数据库表对应的。

创建 src/user/vo/login-user.vo.ts

interface UserInfo {
    id: number;
​
    username: string;
​
    nickName: string;
​
    email: string;
​
    headPic: string;
​
    phoneNumber: string;
​
    isFrozen: boolean;
​
    isAdmin: boolean;
​
    createTime: number;
​
    roles: string[];
​
    permissions: string[]
}
export class LoginUserVo {
​
    userInfo: UserInfo;
​
    accessToken: string;
​
    refreshToken: string;
}
​

在BaseController里返回 vo:

@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    const vo = await this.userService.login(loginUser, false);
​
    return vo;
}
​
@Post('admin/login')
async adminLogin(@Body() loginUser: LoginUserDto) {
    const vo = await this.userService.login(loginUser, true);
​
    return vo;
}

然后引入 jwt 模块:

npm install --save @nestjs/jwt

image-20231013213518203.png

这里的密钥放到 .env 里配置:

image-20231013213556788.png

然后登录认证通过之后返回 access_token 和 refresh_token:

@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    const vo = await this.userService.login(loginUser, false);
​
    vo.accessToken = this.jwtService.sign({
      userId: vo.userInfo.id,
      username: vo.userInfo.username,
      roles: vo.userInfo.roles,
      permissions: vo.userInfo.permissions
    }, {
      expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
    });
​
    vo.refreshToken = this.jwtService.sign({
      userId: vo.userInfo.id
    }, {
      expiresIn: this.configService.get('jwt_refresh_token_expres_time') || '7d'
    });
​
    return vo;
}

另外一个接口也一样

然后再增加一个 refresh_token 的接口用来刷新 token:

 @Get('refresh')
  async refresh(@Query('refreshToken') refreshToken: string) {
    try {
      const data = this.jwtService.verify(refreshToken);
​
      const user = await this.userService.findUserById(data.userId, false);
​
      const access_token = this.jwtService.sign(
        {
          userId: user.id,
          username: user.username,
          roles: user.roles,
          permissions: user.permissions,
        },
        {
          expiresIn:
            this.configService.get('jwt_access_token_expires_time') || '30m',
        },
      );
​
      const refresh_token = this.jwtService.sign(
        {
          userId: user.id,
        },
        {
          expiresIn:
            this.configService.get('jwt_refresh_token_expres_time') || '7d',
        },
      );
      const vo = new RefreshTokenVo();
      vo.accessToken = access_token;
      vo.refreshToken = refresh_token;
      return vo;
    } catch (e) {
      throw new UnauthorizedException('token 已失效,请重新登录');
    }
  }

在 UserService 实现这个 findUserById 方法:

async findUserById(userId: number, isAdmin: boolean) {
    const user =  await this.userRepository.findOne({
        where: {
            id: userId,
            isAdmin
        },
        relations: [ 'roles', 'roles.permissions']
    });
​
    return {
        id: user.id,
        username: user.username,
        isAdmin: user.isAdmin,
        roles: user.roles.map(item => item.name),
        permissions: user.roles.reduce((arr, item) => {
            item.permissions.forEach(permission => {
                if(arr.indexOf(permission) === -1) {
                    arr.push(permission);
                }
            })
            return arr;
        }, [])
    }
}
​

同样的方式再实现一个后台管理的 refresh 接口

然后我们加上 LoginGuard 和 PermissionGuard 来做鉴权:

nest g guard login --flat --no-spec
nest g guard permission --flat --no-spec

LoginGuard 的实现代码如下:

import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { UnLoginException } from './unlogin.filter';
import { Permission } from './user/entities/permission.entity';
​
interface JwtUserData {
  userId: number;
  username: string;
  roles: string[];
  permissions: Permission[]
}
declare module 'express' {
  interface Request {
    user: JwtUserData
  }
}
​
​
@Injectable()
export class LoginGuard implements CanActivate {
  @Inject()
  private reflector: Reflector;
​
  @Inject(JwtService)
  private jwtService: JwtService;
​
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
       const request: Request = context.switchToHttp().getRequest();
    
      const requireLogin = this.reflector.getAllAndOverride('require-login', [
        context.getClass(),
        context.getHandler()
      ]);
      if(!requireLogin) {
        return true;
      }
      const authorization = request.headers.authorization;
      if(!authorization) {
        throw new UnLoginException();
      }
​
      try{
        const token = authorization.split(' ')[1];
        const data = this.jwtService.verify<JwtUserData>(token);
        request.user = {
          userId: data.userId,
          username: data.username,
          roles: data.roles,
          permissions: data.permissions
        }
        return true;
      }catch(e) {
        throw new UnauthorizedException('token 失效,请重新登录');
      }
  }
}
​

用 reflector 从目标 controller 和 handler 上拿到 require-login 的 metadata。

如果没有 metadata,就是不需要登录,返回 true 放行。

否则从 authorization 的 header 取出 jwt 来,把用户信息设置到 request,然后放行。

如果 jwt 无效,返回 401 响应,提示 token 失效,请重新登录。

然后全局启用这个 Guard,在 AppModule 里添加这个 provider:

image-20231013213911217.png

然后我们继续实现 PermissionGuard:

import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
​
@Injectable()
export class PermissionGuard implements CanActivate {
​
  @Inject(Reflector)
  private reflector: Reflector;
​
  async canActivate(
    context: ExecutionContext,
  ): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
​
    if(!request.user) {
      return true;
    }
​
    const permissions = request.user.permissions;
​
    const requiredPermissions = this.reflector.getAllAndOverride<string[]>('require-permission', [
      context.getClass(),
      context.getHandler()
    ])
    
    if(!requiredPermissions) {
      return true;
    }
    
    for(let i = 0; i < requiredPermissions.length; i++) {
      const curPermission = requiredPermissions[i];
      const found = permissions.find(item => item.code === curPermission);
      if(!found) {
        throw new UnauthorizedException('您没有访问该接口的权限');
      }
    }
​
    return true;
  }
}
​

同样是用 reflector 取出 handler 或者 controller 上的 require-permission 的 metadata。

如果没有,就是不需要权限,直接放行,返回 true。

对于需要的每个权限,检查下用户是否拥有,没有的话就返回 401,提示没权限。

否则就放行,返回 true。

同样是全局启用这个 PermissionGuard

image-20231013214009655.png

这样,接口鉴权就完成了

最后我们把这两个 @SetMetadata 封装成自定义装饰器

新建 src/custom.decorator.ts

// 自定义装饰器
import { SetMetadata } from "@nestjs/common";
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from "express";
​
​
export const  RequireLogin = () => SetMetadata('require-login', true);
​
export const  RequirePermission = (...permissions: string[]) => SetMetadata('require-permission', permissions);
​
export const UserInfo = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<Request>();
​
    if(!request.user) {
        return null;
    }
    return data ? request.user[data] : request.user;
  },
)

UserInfo 装饰器是用来取 user 信息传入 handler 的。

传入属性名的时候,返回对应的属性值,否则返回全部的 user 信息。

响应内容的拦截器和自定义Exception Filter

nest g interceptor format-response --flat
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { map, Observable } from 'rxjs';
// 响应内容拦截器
@Injectable()
export class FormatResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const response = context.switchToHttp().getResponse<Response>();
​
    return next.handle().pipe(map((data) => {
      return {
        code: response.statusCode,
        message: 'success',
        data
      }
    }));
  }
}
​

全局启用它:

image-20231013214537037.png

然后再加一个接口访问记录的 interceptor:记录下访问的 ip、user agent、请求的 controller、method,接口耗时、响应内容,当前登录用户等信息。

nest g interceptor invoke-record --flat
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { Request } from 'express';
import { Observable, tap } from 'rxjs';
// 接口访问记录
@Injectable()
export class InvokeRecordInterceptor implements NestInterceptor {
  private readonly logger = new Logger(InvokeRecordInterceptor.name);
​
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const response = context.switchToHttp().getResponse<Response>();
​
    const userAgent = request.headers['user-agent'];
​
    const { ip, method, path } = request;
​
    this.logger.debug(
      `${method} ${path} ${ip} ${userAgent}: ${
        context.getClass().name
      } ${
        context.getHandler().name
      } invoked...`,
    );
  
    this.logger.debug(`user: ${request.user?.userId}, ${request.user?.username}`);
​
    const now = Date.now();
​
    return next.handle().pipe(
      tap((res) => {
        this.logger.debug(
          `${method} ${path} ${ip} ${userAgent}: ${response.statusCode}: ${Date.now() - now}ms`,
        );
        this.logger.debug(`Response: ${JSON.stringify(res)}`);
      }),
    );
  }
}
​

全局启用

image-20231013214657095.png

自定义 Exception Filter

我们新建个 exception filter:

nest g filter unlogin --flat

@Catch 的参数可以指定具体 catch 的异常类型:

import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
// 错误统一处理
export class UnLoginException{
  message: string;
​
  constructor(message?){
    this.message = message;
  }
}
​
@Catch(UnLoginException)
export class UnloginFilter implements ExceptionFilter {
  catch(exception: UnLoginException, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();
​
    response.json({
      code: HttpStatus.UNAUTHORIZED,
      message: 'fail',
      data: exception.message || '用户未登录'
    }).end();
  }
}
​

我们自定义了 UnLoginException 的异常,在 @Catch 指定捕获这个异常,返回对应的响应。

全局启用

image-20231013214835041.png

然后把 LoginGuard 里的异常改成 UnLoginException

image-20231013214907911.png

修改密码

管理员和用户修改密码的页面是一样的,我们就用一个接口就好了。

@Post(['update_password', 'admin/update_password'])
@RequireLogin()
async updatePassword(@UserInfo('userId') userId: number, @Body() passwordDto: UpdateUserPasswordDto) {
    console.log(passwordDto);
    return 'success';
}

这个接口同样是需要登录的,所以加上 @RequireLogin 的装饰器。

用 @UserInfo 从 request.user 取 userId,其余的通过 dto 传。

创建 src/user/dto/update-user-password.dto.ts

import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
​
export class UpdateUserPasswordDto {    
    @IsNotEmpty({
        message: '密码不能为空'
    })
    @MinLength(6, {
        message: '密码不能少于 6 位'
    })
    password: string;
    
    @IsNotEmpty({
        message: '邮箱不能为空'
    })
    @IsEmail({}, {
        message: '不是合法的邮箱格式'
    })
    email: string;
    
    @IsNotEmpty({
        message: '验证码不能为空'
    })
    captcha: string;
}
​

需要传的是邮箱、密码、验证码。

确认密码在前端和密码对比就行,不需要传到后端。

在 UserController 里调用 UserService 的方法:

@Post(['update_password', 'admin/update_password'])
@RequireLogin()
async updatePassword(@UserInfo('userId') userId: number, @Body() passwordDto: UpdateUserPasswordDto) {
    return await this.userService.updatePassword(userId, passwordDto);
}

UserService 实现具体的逻辑:

async updatePassword(userId: number, passwordDto: UpdateUserPasswordDto) {
    const captcha = await this.redisService.get(`update_password_captcha_${passwordDto.email}`);
​
    if(!captcha) {
        throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
    }
​
    if(passwordDto.captcha !== captcha) {
        throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
    }
​
    const foundUser = await this.userRepository.findOneBy({
      id: userId
    });
​
    foundUser.password = hashSync(passwordDto.password, foundUser.salt);
​
    try {
      await this.userRepository.save(foundUser);
      return '密码修改成功';
    } catch(e) {
      this.logger.error(e, UserService);
      return '密码修改失败';
    }
  }

先查询 redis 中有相对应的验证码,检查通过之后根据 id 查询用户信息,修改密码之后 save。

然后再加上这个发送邮箱验证码的接口:

 @RequireLogin()
  @Get('update_password/captcha')
  async updatePasswordCaptcha(@Query('address') address: string) {
    const code = Math.random().toString().slice(2, 8);
​
    await this.redisService.set(
      `update_password_captcha_${address}`,
      code,
      10 * 60,
    );
​
    await this.emailService.sendMail({
      to: address,
      subject: '更改密码验证码',
      html: `<p>你的更改密码验证码是 ${code}</p>`,
    });
    return '发送成功';
  }

修改个人信息

对应 /user/udpate 和 /user/admin/update 接口。

回显数据的接口就用 /user/info 这个。

实现流程和修改密码的差不多:

@Post(['update', 'admin/update'])
@RequireLogin()
async update(@UserInfo('userId') userId: number, @Body() updateUserDto: UpdateUserDto) {
    return await this.userService.update(userId, updateUserDto); 
}

在 UserController 定义两个 post 接口。

创建 src/user/dto/udpate-user.dto.ts

import { IsEmail, IsNotEmpty } from "class-validator";
​
export class UpdateUserDto {
​
    headPic: string;
​
    nickName: string;
    
    @IsNotEmpty({
        message: '邮箱不能为空'
    })
    @IsEmail({}, {
        message: '不是合法的邮箱格式'
    })
    email: string;
    
    @IsNotEmpty({
        message: '验证码不能为空'
    })
    captcha: string;
}
​

对应的 UserService 里的逻辑和修改密码的差不多:

 async update(userId: number, updateUserDto: UpdateUserDto) {
    const captcha = await this.redisService.get(`update_user_captcha_${updateUserDto.email}`);
​
    if(!captcha) {
        throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
    }
​
    if(updateUserDto.captcha !== captcha) {
        throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
    }
​
    const foundUser = await this.userRepository.findOneBy({
      id: userId
    });
​
    if(updateUserDto.nickName) {
        foundUser.nickName = updateUserDto.nickName;
    }
    if(updateUserDto.headPic) {
        foundUser.headPic = updateUserDto.headPic;
    }
​
    try {
      await this.userRepository.save(foundUser);
      return '用户信息修改成功';
    } catch(e) {
      this.logger.error(e, UserService);
      return '用户信息修改成功';
    }
  }

只不过现在是传了的属性才会修改,没传的不修改。

然后还要加一个发验证码的接口,这个和别的发验证码的逻辑一样:

@Get('update/captcha')
async updateCaptcha(@Query('address') address: string) {
    const code = Math.random().toString().slice(2,8);
​
    await this.redisService.set(`update_user_captcha_${address}`, code, 10 * 60);
​
    await this.emailService.sendMail({
      to: address,
      subject: '更改用户信息验证码',
      html: `<p>你的验证码是 ${code}</p>`
    });
    return '发送成功';
}

用户列表和分页查询

我们实现冻结用户的接口,冻结的用户不能登录。

@Get('freeze')
async freeze(@Query('id') userId: number) {
    await this.userService.freezeUserById(userId,true);
    return 'success';
}

在 UserService 定义这个 freezeUserById 方法:

async freezeUserById(id: number, isFreez: boolean) {
    const user = await this.userRepository.findOneBy({
        id
    });
​
    user.isFrozen = isFreez;
​
    await this.userRepository.save(user);
  }

解冻用户

@Get('unfreeze')
  @RequireLogin()
  @RequirePermission('admin')
  async unfreeze(@Query('id') userId: number) {
    await this.userService.freezeUserById(userId, false);
    return 'success';
  }

然后实现 /user/list 用户列表接口。

@Get('list')
async list(@Query('pageNo', ParseIntPipe) pageNo: number, @Query('pageSize', ParseIntPipe) pageSize: number) {
    return await this.userService.findUsersByPage(pageNo, pageSize);
}

这个接口支持分页查询,传入 pageNo、pageSize,返回对应页的数据。

我们在 UserService 里实现下:

async findUsersByPage(pageNo: number, pageSize: number) {
    const skipCount = (pageNo - 1) * pageSize;
​
    const [users, totalCount] = await this.userRepository.findAndCount({
        skip: skipCount,
        take: pageSize
    });
​
    return {
        users,
        totalCount
    }
}

没传 pageNo 的时候会返回 400 的错误。

这个报错信息不够友好,我们改一下:

@Get('list')
async list(
@Query('pageNo', new ParseIntPipe({
  exceptionFactory() {
    throw new BadRequestException('pageNo 应该传数字');
  } 
})) pageNo: number,
@Query('pageSize', new ParseIntPipe({
  exceptionFactory() {
    throw new BadRequestException('pageSize 应该传数字');
  } 
})) pageSize: number
) {
    return await this.userService.findUsersByPage(pageNo, pageSize);
}

我们重构下代码:

把这段代码抽离到 src/utils.ts 里:

export function generateParseIntPipe(name) {
    return new ParseIntPipe({
      exceptionFactory() {
        throw new BadRequestException(name + ' 应该传数字');
      } 
    })
}
​

那 controller 的代码就可以简化成这样了:

@Get('list')
async list(
    @Query('pageNo', generateParseIntPipe('pageNo')) pageNo: number,
    @Query('pageSize', generateParseIntPipe('pageSize')) pageSize: number,
) {
    return await this.userService.findUsersByPage(pageNo, pageSize);
}

那如果没有传 pageNo 和 pageSize 的时候要设置个默认值呢?

可以使用 DefaultValuePipe:

@Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo'))
    pageNo: number,
    @Query(
      'pageSize',
      new DefaultValuePipe(2),
      generateParseIntPipe('pageSize'),
    )

没有传 pageNo 的时候设置为 1,没有传 pageSize 的时候设置为 2。

一条是分页查询,指定了 limit 2 offset 2,这个和 limit 2, 2 是一样的

一条是 count 统计,统计了用户的总条数。

这里返回的信息同样也需要做一些修改。

我们指定下 select 的字段:

 select: ['id', 'username', 'nickName', 'email', 'phoneNumber', 'isFrozen', 'headPic', 'createTime'],

用户列表的需求除了分页外,还需要支持根据 username、nickName、email 的搜索:

@Get('list')
async list(
    @Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo')) pageNo: number,
    @Query('pageSize', new DefaultValuePipe(2), generateParseIntPipe('pageSize')) pageSize: number,
    @Query('username') username: string,
    @Query('nickName') nickName: string,
    @Query('email') email: string
) {
    return await this.userService.findUsers(username, nickName, email, pageNo, pageSize);
}

在 UserService 添加 findUsers 方法:

async findUsersByPage(username: string, nickName: string, email: string, pageNo: number, pageSize: number) {
    const skipCount = (pageNo - 1) * pageSize;
​
    const condition: Record<string, any> = {};
​
    if(username) {
      condition.username = Like(`%${username}%`);   
    }
    if(nickName) {
      condition.nickName = Like(`%${nickName}%`); 
    }
    if(email) {
      condition.email = Like(`%${email}%`); 
    }
  
    const [users, totalCount] = await this.userRepository.findAndCount({
      select: ['id', 'username', 'nickName', 'email', 'phoneNumber', 'isFrozen', 'headPic', 'createTime'],
      skip: skipCount,
      take: pageSize,
      where: condition
    });
​
    return {
        users,
        totalCount
    }
  }

和之前的区别就是多了个 where 条件。

根据 username、nickName、email 搜索的时候,使用模糊查询。