likes
comments
collection
share

NestJS 项目实战(下)

作者站长头像
站长
· 阅读数 16

概述

这个项目在半年前就已经做完了,为了方便出个教程,近期抽出时间来重新做一遍,下面直接开始

创建项目

  • 上篇说过:有三种创建方式,这里使用 Cli 创建,执行下面命令
$ nest new nest-server

NestJS 项目实战(下)

  • 选择 npm

NestJS 项目实战(下)

  • 目录结构

NestJS 项目实战(下)

  • 请留意基础库的版本,然后安装启动(启动使用 --watch 修饰一下,可以监听代码改动)

NestJS 项目实战(下)

配置环境

默认启动的是3000端口,为了工程化,我们把环境相关的配置提出去

  • 根目录新建 .env.env.prod 两个文件,对应测试和线上的环境配置,提前把后面用到的配置都加好
    • .env
    # 测试环境配置
    
    # 数据库地址
    DB_HOST=localhost
    # 数据库端口
    DB_PORT=3306
    # 数据库登录名
    DB_USER=root
    # 数据库登录密码
    DB_PASSWORD=rootpass
    # 数据库名字
    DB_DATABASE=NestData
    # 认证标识
    SECRET=keycode
    
    # Redis 域名
    REDIS_HOST=127.0.0.1
    # Redis 端口号
    REDIS_PORT=6379
    # Redis 数据库
    REDIS_DB=0
    # Redis 设置的密码
    REDIS_PASSPORT=182
    
    • .env.prod
    # 生产环境配置
    
    # 数据库地址
    DB_HOST=192.168.xxx.xxx
    # 数据库端口
    DB_PORT=3306
    # 数据库登录名
    DB_USER=root
    # 数据库登录密码
    DB_PASSWORD=rootpass
    # 数据库名字
    DB_DATABASE=NestData
    # 认证标识
    SECRET=keycode
    
    # Redis 域名
    REDIS_HOST=127.0.0.1
    # Redis 端口号
    REDIS_PORT=6379
    # Redis 数据库
    REDIS_DB=0
    # Redis 设置的密码
    REDIS_PASSPORT=182
    
  • 新建目录文件 /config/env.ts
import * as fs from 'fs'
import * as path from 'path'

const isProd: boolean = process.env.NODE_ENV === 'production'

function parseEnv() {
  const localEnv = path.resolve('.env')
  const prodEnv = path.resolve('.env.prod')

  if (!fs.existsSync(localEnv) && !fs.existsSync(prodEnv)) {
    throw new Error('缺少环境配置文件')
  }

  const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv
  return { path:filePath }
}

export default parseEnv()
  • 新建目录文件 /src/constant/ServerListen.ts,端口声明提出去
/**
 * @desc server-listen 常量
 */

export const PORT = 3001; // 服务端口
export const IP = '172.16.1.xx'; // 自己的主机IP
  • main.ts 修改监听
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(PORT);
}
bootstrap();

安装 MySql

因为后面要做数据入库的操作,所以先装好

  • MySql 下载(没有Oracle账号的,用邮箱注册一下,然后就可以下载了)

    选择好自己对应的版本

NestJS 项目实战(下)

  • Mac 用户下载完之后,启动位置在 系统偏好设置 面板,点进去,start server就可以了

NestJS 项目实战(下)

NestJS 项目实战(下)

  • 数据库不能可视化查看,所以需要下载一个mysql-workbench,安装好之后打开,就可以查看和操作数据库表了,新建连接需要对应我们的项目配置。

NestJS 项目实战(下)

可以手动创建库表,或者代码中去用实体映射创建。

NestJS 项目实战(下)

安装 Redis

后面需要用 Redis 做 token 存储等的缓存方案

  • 下载 Redis

  • Redis 命令参考

    • 启动命令:
    $ /opt/homebrew/opt/redis/bin/redis-server /opt/homebrew/etc/redis.conf
    
    • 进入认证环境:redis-cli -h 127.0.0.1 -p 6379 -a password
    • 认证账户:auth 182
    • 查看当前存在的服务和 PIDps axu|grep redis
    • 强制退出 redis 服务:sudo kill -9 18505(18505 数字位对应上条命令输出的第二列字段)
    • 域名:127.0.0.1
    • 端口:6379
    • 密码:182
  • 启动完成后是这样的

NestJS 项目实战(下)

接口测试

  • 新建用户模块目录,先写个接口测试下流程 /src/feature/user/

    • 控制器文件 user.controller.ts
    import { UserService } from './user.service';
    import { Controller, Get, Post, Req } from '@nestjs/common';
    
    @Controller('user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Post('mytest')
      async testFn(@Req() req) {
        return {
          code: 200,
          msg: '测试GET接口返回成功',
        };
      }
    }
    
    • 模块注册 user.module.ts
    import { Module } from '@nestjs/common';
    import { UserController } from './user.controller';
    import { UserService } from './user.service';
    
    @Module({
      imports: [],
      controllers: [UserController],
      providers: [UserService],
      exports: [UserService],
    })
    export class UserModule {}
    
    • 服务文件 user.service.ts
    import { HttpException, Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {}
    
    • 以上内容创建完成之后接口是调用不到的,需要在 app.module.ts 中引入挂载
    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { UserModule } from './feature/user/user.module';
    
    @Module({
      imports: [UserModule], // 每个接口模块都需要在这里引入
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
    
    • 使用 ApiPost 测试返回值

    NestJS 项目实战(下)

    • 但是如果控制器的函数调用了service,而 service 拿数据时候出错,抛出了异常
    import { UserService } from './user.service';
    import { Controller, Get, Post, Req } from '@nestjs/common';
    
    @Controller('user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Post('mytest')
      async testFn(@Req() req) {
        return await this.userService.testService('');
      }
    }
    
    import { HttpException, Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {
      async testService(post) {
        if (!post) {
          throw new HttpException('缺少参数', 200);
        } else {
          return {
            msg: '成功了',
          };
        }
      }
    }
    

    NestJS 项目实战(下)

    此时再看返回值,其实就有些失控了,所以需要用到下面要说的的过滤器拦截器 👇🏻

过滤器

过滤器利用洋葱模型,在起点和终点之间对错误的返回值做过滤操作

  • 新建文件 /src/core/filter/transform.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp(); // 获取请求上下文
    const response = ctx.getResponse(); // 获取请求上下文中的 response对象
    let status = exception.getStatus(); // 获取异常状态码
    /**
     * @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
     * code === -1 :前端直接全局报message的错
     * code === [其它] 单独进行特殊场景判断
     */
    const message = exception.message
      ? exception.message
      : `${status >= 500 ? 'Service Error' : 'Client Error'}`;
    const errorResponse = {
      data: {},
      message: message,
      code: -1,
    };

    // 设置返回的状态码, 请求头,发送错误信息
    response.status(status);
    response.header('Content-Type', 'application/json; charset=utf-8');
    response.send(errorResponse);
  }
}
  • main.ts 注册
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';
import { HttpExceptionFilter } from './core/filter/transform.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 全局注册过滤器
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(PORT);
}
bootstrap();

拦截器

拦截器利用洋葱模型,在起点和终点之间对常规返回值做包装返回

  • 新建文件 /src/core/interceptor/transform.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>>
{
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        // console.log('===拦截器===', data);
        return {
          data,
          code: 0,
          msg: '请求成功',
        };
      }),
    );
  }
}
  • main.ts 注册
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';
import { HttpExceptionFilter } from './core/filter/transform.filter';
import { TransformInterceptor } from './core/interceptor/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 全局注册过滤器
  app.useGlobalFilters(new HttpExceptionFilter());
  // 全局注册拦截器
  app.useGlobalInterceptors(new TransformInterceptor());
  await app.listen(PORT);
}
bootstrap();

经过 过滤器拦截器 的处理,返回结构基本已经得到规范,前端按照 code 去区分场景就可以了

中间件

这里新建一个日志中间件,跑一下中间件使用的流程

  • 新建文件 /src/core/middleware/logger.middleware.ts
/**
 * @file 日志中间件
 */

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
    // req:请求参数
    // res:响应参数
    // next:执行下一个中件间
    use(req: Request, res: Response, next: () => void) {
        const { method, path } = req;
        // console.log(`===日志中间件===, ${method}, ${path}`);
        next();
    }
}
  • app.module.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './feature/user/user.module';
import { LoggerMiddleware } from './core/middleware/logger.middleware';

@Module({
  imports: [UserModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware) // 应用中间件
      // .exclude({ path: 'user', method: RequestMethod.POST }) // 排除user的post方法
      .forRoutes('*'); // 监听路径  参数:路径名或*,*是匹配所以的路由
    // .forRoutes({ path: 'user', method: RequestMethod.POST }, { path: 'album', method: RequestMethod.ALL }); //多个
    // .apply(UserMiddleware) // 支持多个中间件
    // .forRoutes('user')
  }
}

这里测试下效果,我们请求接口后,会执行中间件的打印

NestJS 项目实战(下)

路由前缀

当前路由:/user/mytest

目标路由:/api/user/mytest

格式:/顶级路由前缀/模块路由/单个接口路由

解释一下:为了规范项目开发流程,我们给定一个前缀作为该项目的服务接口的标识,为的是项目多了之后只看前缀就知道是哪个项目,这里给定的是/api,你也可以用其它字符串。

  • main.ts 入口函数添加
// 设置全局路由前缀
app.setGlobalPrefix('api');
  • 测试返回值

NestJS 项目实战(下)

DTO校验

我们这步会写一个简单的注册接口来跑一下流程

此校验通过它的规则让我们的 编码效率可维护性 大大提高

  • 安装校验依赖 class-validator & class-transformer
cnpm i class-validator class-transformer --save
  • user.controller.ts 添加控制器
@Post('register')
async register(@Body() post: UserDOT.RegisterUserDto) {
    return await this.userService.Register(post);
}
  • user.service.ts 添加接口服务
import { HttpException, Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  async testService(post) {
    return {
      msg: '成功了',
    };
  }

  // 用户注册
  async Register(post) {
    return {
      msg: '注册接口',
    };
  }
}
  • 同级创建dto校验文件:user.dto.ts

这里声明了该注册接口的入参和类型,以及为空时的返回值,在编写的时候注意规范,同一个DTO类只能服务一个接口,但不影响他们继承于其它公用类,同一个模块DTO文件可以写多个DTO类服务当前模块的多个接口,这样更方便工程化。

import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';

export class RegisterUserDto {
  @IsNotEmpty({ message: '账号必填' })
  readonly account: string;

  @IsNotEmpty({ message: '密码必填' })
  readonly password: string;
}
  • DTO采用管道的概念,需要全局注册管道才可以生效:main.ts
import { ValidationPipe } from '@nestjs/common';

// 全局注册一下管道ValidationPipe
app.useGlobalPipes(new ValidationPipe());
  • 最后还需要修改过滤器,因为规则发生了变化:transform.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp(); // 获取请求上下文
    const response = ctx.getResponse(); // 获取请求上下文中的 response对象
    let status = exception.getStatus(); // 获取异常状态码
    /**
     * @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
     * code === -1 :前端直接全局报message的错
     * code === [其它] 单独进行特殊场景判断
     */
    const exceptionResponse: any = exception.getResponse();
    let validMessage: string = '';
    for (let key in exception) {
      console.log('===过滤器===', key, exception[key]);
    }
    if (typeof exceptionResponse === 'object') {
      validMessage =
        typeof exceptionResponse.message === 'string'
          ? exceptionResponse.message
          : exceptionResponse.message[0];
    }
    const message = exception.message
      ? exception.message
      : `${status >= 500 ? 'Service Error' : 'Client Error'}`;
    const errorResponse = {
      data: {},
      message: validMessage || message,
      code: -1,
    };

    // 设置返回的状态码, 请求头,发送错误信息
    response.status(status);
    response.header('Content-Type', 'application/json; charset=utf-8');
    response.send(errorResponse);
  }
}
  • 此时再看请求返回(后面再做数据入库,这里只是解释 DTO 概念)

NestJS 项目实战(下)

NestJS 项目实战(下)

NestJS 项目实战(下)

MySql

  • 安装所需依赖:mysql & typeorm & @nestjs/typeorm & @nestjs/config
cnpm i mysql typeorm @nestjs/typeorm @nestjs/config --save
  • 在 user 模块下新建库表映射文件:user.entity.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';

@Entity('user') // 库表名称
export class UserEntity {
  // 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。 Uuid 是一个独特的字符串
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 20 })
  account: string;

  @Exclude()
  @Column({
    length: 100,
    // select: false // 表示隐藏此列 和@Exclude二选一
  })
  password: string;

  @Column({ length: 20, default: 'User' })
  nickname: string;

  @Column({ default: 'http://localhost:3001/static/default.png' })
  avatar: string;

  @CreateDateColumn({
    type: 'timestamp',
    comment: '创建时间',
  })
  create_time: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    comment: '更新时间',
  })
  update_time: Date;

  @DeleteDateColumn({
    type: 'timestamp',
    comment: '删除时间',
  })
  delete_time: Date;
}
  • user.module.ts 引入实体
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([UserEntity])],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}
  • user.service.ts 接口服务
import { HttpException, Injectable } from '@nestjs/common';
import { UserEntity } from './user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
  ) {}

  // 用户注册
  async Register(post: Partial<UserEntity>): Promise<object> {
    const { account } = post;
    const user = await this.userRepository.findOne({ where: { account } });
    if (user) {
      throw new HttpException('账号已存在', 200);
    }
    const newUser = await this.userRepository.create(post);
    await this.userRepository.save(newUser);
    return {};
  }
}
  • 改造 app.module.ts 注册引入 TypeOrmModule
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './feature/user/user.module';
import { LoggerMiddleware } from './core/middleware/logger.middleware';
import { UserEntity } from './feature/user/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService, ConfigModule } from '@nestjs/config';

// 数据表集合
const Entities: Array<any> = [UserEntity];

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        type: 'mysql', // 数据库类型
        entities: Entities, // 数据表实体
        // autoLoadEntities: true, // 可以打开此配置项,表示<entities>配置自动引入,避免忘记
        host: configService.get('DB_HOST', 'localhost'), // 主机,默认为localhost
        port: configService.get<number>('DB_PORT', 3306), // 端口号
        username: configService.get('DB_USER', 'root'), // 用户名
        password: configService.get('DB_PASSWORD', 'rootpass'), // 密码
        database: configService.get('DB_DATABASE', 'NestData'), //数据库名
        timezone: '+08:00', // 服务器上配置的时区
        synchronize: true, // 根据实体自动创建数据库表, 生产环境建议关闭
      }),
    }),
    UserModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware) // 应用中间件
      // .exclude({ path: 'user', method: RequestMethod.POST }) // 排除user的post方法
      .forRoutes('*'); // 监听路径  参数:路径名或*,*是匹配所以的路由
    // .forRoutes({ path: 'user', method: RequestMethod.POST }, { path: 'album', method: RequestMethod.ALL }); //多个
    // .apply(UserMiddleware) // 支持多个中间件
    // .forRoutes('user')
  }
}
  • 测试注册接口

刚才库里是没有表的,接收到请求后实体帮我们创建了一张表,并且将入参入库

NestJS 项目实战(下)

NestJS 项目实战(下)

使用相同的参数再调用注册

NestJS 项目实战(下)

看返回值,说明写的逻辑是没有问题的,具体如何操作库TypeORM的规则如何?慢慢往后看。

接口文档

后端往往需要输出一个接口文档给前端作为 调用参考 或者 后期追溯

这里我们用 NestJS 内置集成的 Swgger

  • 安装依赖:@nestjs/swagger
cnpm i @nestjs/swagger --save
  • 新建文件独立配置:src/common/swagger.ts
/**
 * @file 配置 Swagger文档
 * @desc 此配置在main主入口调用Init即可
 */
import { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as CONST from '../constant/SwaggerConfig';

export class SwaggerConfig {
    app: NestExpressApplication;
    constructor(app: NestExpressApplication) {
        this.app = app;
    }
    public Init(): void {
        const config = new DocumentBuilder()
            .setTitle(CONST.TITLE)
            .setDescription(CONST.DESC)
            .addBearerAuth() // 为了可以顺畅的使用Swagger来测试传递bearer token接口
            .setVersion(CONST.VER)
            .addBearerAuth()
            .build();
        const document = SwaggerModule.createDocument(this.app, config);
        SwaggerModule.setup('docs', this.app, document);
    }
}
  • 新建独立声明文件:src/constant/SwaggerConfig.ts
/**
 * @desc swagger 常量
 */

export const TITLE = 'NestJS提供服务';
export const DESC = '管理后台接口文档';
export const VER = '1.0.0';
  • main.ts 新增内容
import { SwaggerConfig } from './common/swagger';

// 设置swagger文档
new SwaggerConfig(app).Init();
  • 访问文档:http://localhost:3001/docs#/

NestJS 项目实战(下)

不难发现,文档有了,接口有了,但是没有字段和标注,这是因为我们在 DTO 的位置没有加注释,在 Controller 的位置没有加描述。

下面改造补全 👇🏻

  • user.dto.ts
export class RegisterUserDto {
    @ApiProperty({ description: '账号' })
    @IsNotEmpty({ message: '账号必填' })
    readonly account: string;

    @ApiProperty({ description: '密码' })
    @IsNotEmpty({ message: '密码必填' })
    readonly password: string;
}
  • user.controller.ts
import { UserService } from './user.service';
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import * as UserDOT from './user.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@ApiTags('用户系列接口:/api/user')
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @ApiOperation({ summary: '用户注册' })
  @Post('register')
  async register(@Body() post: UserDOT.RegisterUserDto) {
    return await this.userService.Register(post);
  }
}
  • app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiTags } from '@nestjs/swagger';

@ApiTags('顶级路由:/api')
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}
  • 再看改完之后的效果(分层和注释在其它接口中也是如此)

NestJS 项目实战(下)

前面我们在 mysql 中创建了一张 user 表,这张表作为我们的用户表,也就是注册之后的用户信息放在这里。

下面我们还要做登录,登录涉及到用户 Token 的持久化,这个持久化 Mysql 显然不合理,由于 Redis 可以设置过期时间,刚好符合这个场景,所以使用 Redis,前面已经教过它如何下载了,这里直接使用。

安装各类依赖

cnpm i xxx --save

  • 认证相关:@nestjs/passport & @nestjs/jwt & passport-jwt & passport
  • 编码规则:bcryptjs & passport-local & @types/passport-local
  • 图片验证码:svg-captcha
  • Redis:cache-manager-redis-store@2.0.0 & cache-manager@4.0.0 & @types/cache-manager@4.0.2
  • 导出:exceljs
  • 限制接口频率:@nestjs/throttler
  • 定时任务:@nestjs/schedule

新建目录

  • src/feature 下新建一个 auth 模块 用来写认证相关(也就是登录流程)

  • src/feature 下新建一个 tasks 模块 用来写定时任务

  • src/feature 下新建一个 upload 模块 用来写上传

  • src 下新建一个 redis 模块 用来存放 Redis 相关内容

完善剩余功能

  • feature/auth/auth.controller.ts
    • 获取验证码
    • 验证验证码
    • 账号密码登录
import {
  Body,
  ClassSerializerInterceptor,
  Controller,
  Post,
  Req,
  UseGuards,
  UseInterceptors,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RedisCacheService } from '../../redis/redis-cache.service';
import * as AuthDOT from './auth.dto';

@ApiTags('AUTH 认证')
@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private redisCacheService: RedisCacheService,
  ) { }

  /**
   * 获取图片验证码
   */
  @ApiOperation({ summary: '获取图片验证码' })
  @Post('authcode') // 当请求该接口时,返回一张随机图片验证码
  async getCode(@Body() post) {
    // req.session.code = svgCaptcha.text; // 使用session保存验证,用于登陆时验证
    // res.type('image/svg+xml'); // 指定返回的类型
    // res.send(svgCaptcha.data); // 给页面返回一张图片
    return await this.authService.GetCode(post);
  }

  /**
   * 验证图片验证码
   */
  @ApiOperation({ summary: '验证图片验证码' })
  @Post('comparecode')
  async compareCode(@Body() post: AuthDOT.CompareCodeDto) {
    return await this.authService.CompareCode(post);
  }

  /**
   * 账号密码登录
   */
  @ApiOperation({ summary: '认证登录' })
  // @UseGuards:使用守卫  @AuthGuard:认证守卫
  @UseGuards(AuthGuard('local'))
  @UseInterceptors(ClassSerializerInterceptor)
  @Post('login')
  async login(@Body() post: AuthDOT.LoginDto, @Req() req) {
    // console.log('===', user, req);
    // const userInfo = req.user;
    return await this.authService.login(post, req.user);
  }

  /**
   * 微信扫码登录
   */
}
  • feature/auth/auth.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';

export class CompareCodeDto {
  @ApiProperty({ description: '验证码Key' })
  @IsNotEmpty({ message: '验证码必填' })
  readonly VerificationKey: string;

  @ApiProperty({ description: '验证码Value' })
  @IsNotEmpty({ message: '验证码必填' })
  readonly VerificationCode: string;
}

export class LoginDto {
  @ApiProperty({ description: '账号' })
  @IsNotEmpty({ message: '账号必填' })
  readonly account: string;

  @ApiProperty({ description: '密码' })
  @IsNotEmpty({ message: '密码必填' })
  readonly password: string;
}
  • feature/auth/auth.entity.ts 登录实体映射
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

/**
 * @desc 此表配合redis完成一个合理的token存取过程
 * redis 用于<查>,然后验证
 * 登录表 用于记录用户登录状态,方便统计
 */
@Entity('user_token')
export class UserTokenEntity {
  @PrimaryGeneratedColumn()
  id: string;

  @Column({ length: 200 })
  uuid: string; // 此<uuid>使用<user>表中的主键

  @Column({ length: 200 })
  token: string;

  @Column({ length: 20 })
  account: string;

  @Column({ length: 20, default: '' })
  nickname: string;

  @CreateDateColumn({
    type: 'timestamp',
    comment: '创建时间',
  })
  create_time: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    comment: '更新时间',
  })
  update_time: Date;

  @DeleteDateColumn({
    type: 'timestamp',
    comment: '删除时间',
  })
  delete_time: Date;
}
  • feature/auth/auth.module.ts
    • JWT 注册
    • JWT 挂载
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/user.entity';
import { LocalStorage } from './local.strategy';
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
import { JwtStorage } from './jwt.strategy';
import { JwtModule } from '@nestjs/jwt';
import { UserModule } from '../user/user.module';
import { UserTokenEntity } from './auth.entity';
import { RedisCacheModule } from '../../redis/redis-cache.module';
import { ToolsCaptcha } from '../../common/captcha';

/**
 * 这里不建议将秘钥写死在代码也, 它应该和数据库配置的数据一样,从环境变量中来
 */
const jwtModule = JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: async (configService: ConfigService) => {
    return {
      secret: configService.get('SECRET', 'key'),
      // 先不设置有效期 配合其他代码和<redis>完成<token自动续期>
      // signOptions: { expiresIn: '4h' },
    };
  },
});

@Module({
  imports: [
    TypeOrmModule.forFeature([UserEntity]),
    TypeOrmModule.forFeature([UserTokenEntity]),
    PassportModule,
    jwtModule,
    UserModule,
    RedisCacheModule,
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStorage, JwtStorage, ToolsCaptcha],
  exports: [jwtModule],
})
export class AuthModule { }
  • feature/auth/auth.service.ts
    • 生成 Token
    • Token 处理
    • 生成验证码
    • 验证验证码
    • 清空验证码
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/user.entity';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from './auth.entity';
import * as CONST from '../../constant/LengthOfTime';
import * as REDIS from '../../constant/RedisKeyPrefix';
import { ToolsCaptcha } from '../../common/captcha';
import { CreateEncrypt } from '../../common/encrypt';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private userService: UserService,
    private readonly toolsCaptcha: ToolsCaptcha,
    private redisCacheService: RedisCacheService,
    @InjectRepository(UserTokenEntity)
    private readonly UserTokenRepository: Repository<UserTokenEntity>,
  ) { }

  // 生成token
  async createToken(user: Partial<UserEntity>) {
    return await this.jwtService.sign(user);
  }

  async login(post, user: Partial<UserEntity>) {
    // 传入 id 和 account 序列化一个token
    const token = await this.createToken({
      id: user.id,
      account: user.account,
    });

    /**
     * @desc token 过期处理
     * 在登录时,将jwt生成的token,存入redis,并设置有效期为30分钟。存入redis的key由用户信息组成, value是token值
     */
    await this.redisCacheService.cacheSet(
      `${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
      token,
      CONST.TOKEN_FIRST_SET_TIME,
    );
    // 组合数据入库
    const row: Partial<UserTokenEntity> = {
      uuid: user.id,
      nickname: user.nickname || '',
      account: user.account || '',
      token: token,
    };
    const findRow = await this.UserTokenRepository.findOne({
      where: { uuid: user.id },
    });
    if (findRow) {
      await this.UserTokenRepository.remove(findRow);
    }
    const newUser = await this.UserTokenRepository.create(row);
    await this.UserTokenRepository.save(newUser);

    return {
      token: token,
      userInfo: {
        id: user.id,
        account: user.account,
        avatar: user.avatar,
        nickname: user.nickname,
      },
    };
  }

  async getUser(user) {
    return await this.userService.findOne(user.id);
  }

  async GetCode(post) {
    const svgCaptcha = await this.toolsCaptcha.captche(); // 创建验证码
    const createID = new CreateEncrypt().nanoid(); // 编码一个id
    this.redisCacheService.cacheSet(
      `${REDIS.RedisPrefixCaptcha}${createID}`,
      `${svgCaptcha.text}`,
      CONST.CAPCODE_FIRST_SET_TIME,
    );
    return {
      key: createID,
      svg: svgCaptcha.data,
    };
  }

  async CompareCode(post) {
    // 验证码校验判断
    if (!post.VerificationCode || !post.VerificationKey) {
      throw new HttpException('缺少验证码', 200);
    } else {
      const key = `${REDIS.RedisPrefixCaptcha}${post.VerificationKey}`;
      const value = await this.redisCacheService.cacheGet(key);
      if (!value) {
        throw new HttpException('验证码不正确', 200);
      } else if (value !== post.VerificationCode) {
        this.redisCacheService.cacheDel(key);
        throw new HttpException('验证码不正确', 200);
      } else {
        // 验证完之后,删掉此验证码
        this.redisCacheService.cacheDel(key);
      }
    }
    return {};
  }
}
  • feature/auth/jwt.strategy.ts
    • JWT Token 校验方案
    • 提取 Token
    • 校验 Token(是否存在、是否过期)
/**
 * @file token校验相关
 * @desc JSON Web Token(JWT)
 * @doc 此方案无需维护登录表,简化了前后端交互方案,同时传递给客户端的token使用私钥进行了加密,无需担心安全问题
 *
 * 标记了 @UseGuards(AuthGuard('jwt')) 的控制器会经过此文件的检测
 * 检测请求的header中<Authorization>传递的token是否正确
 */

import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StrategyOptions, Strategy, ExtractJwt } from 'passport-jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/user.entity';
import { AuthService } from './auth.service';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from './auth.entity';
import * as CONST from '../../constant/ApiWriteList';
import * as Duration from '../../constant/LengthOfTime';
import * as REDIS from '../../constant/RedisKeyPrefix';

export class JwtStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
    private readonly redisCacheService: RedisCacheService,
    @InjectRepository(UserTokenEntity)
    private readonly UserTokenRepository: Repository<UserTokenEntity>,
  ) {
    super({
      /**
   * 策略中的ExtractJwt提供多种方式从请求中提取JWT,常见的方式有以下几种:
        fromHeader: 在Http 请求头中查找JWT
        fromBodyField: 在请求的Body字段中查找JWT
        fromAuthHeaderAsBearerToken:在授权标头带有Bearer方案中查找JWT
  */
      /**
       * @desc 这里采用<fromAuthHeaderAsBearerToken>方案
       * @todo 触发方式(header内key:value形式传递):Authorization: `Bearer ${token}`
       */

      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('SECRET'),
      passReqToCallback: true,
    } as StrategyOptions);
  }

  async validate(req, user: UserEntity) {
    console.log('检测', req, user);
    /**
     * @desc 取出token并验证
     * 在验证token时, 从redis中取token,如果取不到token,可能是token已过期。
     */
    const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
    const cacheToken = await this.redisCacheService.cacheGet(
      `${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
    );
    if (!cacheToken) {
      throw new UnauthorizedException('token 已过期');
    }

    /**
     * @desc 用户唯一登录
     * 当用户登录时,每次签发的新的token,会覆盖之前的token,
     * 判断redis中的token与请求传入的token是否相同, 不相同时, 可能是其他地方已登录, 提示token错误
     */
    if (token != cacheToken) {
      throw new UnauthorizedException('token不正确');
    }

    const existUser = await this.authService.getUser(user);
    if (!existUser) {
      throw new UnauthorizedException('token不正确');
    }

    /**
     * 在token认证通过后,重新设置过期时间
     * 因为使用的cache-manager没有通过直接更新有效期方法,通过重新设置来实现
     */
    // 忽略无需续签的接口队列
    if (!CONST.TOKEN_AUTOMATIC_RENEWAL_IGNORE_LIST.includes(req.url)) {
      this.redisCacheService.cacheSet(
        `${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
        token,
        Duration.TOKEN_AUTOMATIC_RENEWAL_TIME,
      );
      const findRow = await this.UserTokenRepository.findOne({
        where: { uuid: user.id },
      });
      /**
       * 使用<dayjs>的时间方法生成时间字符串替换库中的<update_time>
       */
      findRow.update_time = new Date();
      console.log('时间转时间戳方法==>', findRow.update_time.getTime());
      const updatePost = this.UserTokenRepository.merge(findRow, {});
      await this.UserTokenRepository.save(updatePost);
    }
    return existUser;
  }
}
  • feature/auth/local.strategy.ts 账号密码本地认证
/**
 * @file 账号密码本地认证
 * 标记了@UseGuards(AuthGuard('local'))的控制器会执行
 */

import { compareSync } from 'bcryptjs';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { UserEntity } from '../user/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, HttpException } from '@nestjs/common';

export class LocalStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
  ) {
    // 如果不是username、password, 在constructor中配置
    super({
      // key 必须是 usernameField 和 passwordField
      usernameField: 'account',
      passwordField: 'password',
    } as IStrategyOptions);
  }

  async validate(account: string, password: string) {
    // 因为密码是加密后的,没办法直接对比用户名密码,只能先根据用户名查出用户,再比对密码
    const user = await this.userRepository
      .createQueryBuilder('user')
      .addSelect('user.password')
      .where('user.account=:account', { account })
      .getOne();

    if (!user) {
      throw new HttpException('用户名不正确!', 200);
      // throw new BadRequestException('用户名不正确!');
    }

    if (!compareSync(password, user.password)) {
      throw new HttpException('密码错误!', 200);
      // throw new BadRequestException('密码错误!');
    }

    return user;
  }
}
  • feature/tasks/tasks.module.ts 定时任务模块
import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service';

import { UserModule } from '../user/user.module';
@Module({
  imports: [UserModule],
  providers: [TasksService],
})
export class TasksModule {}
  • feature/tasks/tasks.service.ts 定时任务服务
import { UserService } from '../user/user.service';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, Interval, Timeout } from '@nestjs/schedule';

@Injectable()
export class TasksService {
  constructor(private readonly userService: UserService) { }
  private readonly logger = new Logger(TasksService.name);

  /**
   * @desc 下方装饰器详解
   * 
      * * * * * * 分别对应的意思:
      第1个星:秒
      第2个星:分钟
      第3个星:小时
      第4个星:一个月中的第几天
      第5个星:月
      第6个星:一个星期中的第几天

      如:
      45 * * * * *:每隔45秒执行一次
 */
  @Cron('45 * * * * *') // 每隔45秒执行一次
  handleCron() {
    this.logger.debug('定时任务--日志--45秒');
  }

  // @Interval(10) // 每秒10次的定时任务
  // handleInterval() {
  //     this.userService.mockData();
  //     this.logger.debug('定时任务--日志--0.1秒');
  // }

  @Timeout(5000) // 5秒后,只执行一次
  handleTimeout() {
    this.logger.debug('定时任务--单次--日志--5秒');
    // this.userService.mockData();
  }
}
  • feature/upload/upload.controller.ts 图片上传路由
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UseInterceptors,
  UploadedFile,
  HttpException,
} from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@ApiTags('上传相关接口:/api/upload')
@Controller('upload')
export class UploadController {
  constructor(private readonly uploadService: UploadService) { }
  @ApiOperation({ summary: '头像上传接口' })
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file'))
  upload(@UploadedFile() file) {
    console.log('file', file);
    if (file.filename && file.destination) {
      return {
        filename: file.filename,
        url: 'http://localhost:3001/' + 'static/upload/' + file.filename,
      };
    } else {
      return new HttpException('上传图片失败', 200);
    }
  }
}
  • feature/upload/upload.module.ts 图片上传模块
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import RootDirPath from '../../common/RootDirpath';

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        /**
         * 访问地址:http://localhost/:3001/static/upload/1676272943743.png
         * 由于根目录配置了虚拟路径,所以<public>需要改成<static>才可以访问
         */
        destination: join(RootDirPath, '/public/upload'),
        filename: (_, file, callback) => {
          const fileName = `${new Date().getTime() + extname(file.originalname)
          }`;
          return callback(null, fileName);
        },
      }),
      limits: {
        fileSize: 1024 * 1024, // 设置最大上传 1MB
      },
    }),
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule { }
  • feature/upload/upload.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UploadService {}
  • feature/user/user.controller.ts 用户模块
    • 用户注册
    • 修改密码
    • 退出登录
    • 获取用户列表(分页)
    • 拉黑单个用户
    • 恢复单个用户
    • 获取用户数据(mysql)
    • 获取登录数据(redis)
    • 获取验证码列表
    • 清空验证码列表
    • 下线单个用户
    • 下线所以用户
    • 修改昵称
    • 上传头像
    • 导出表格
import { UserService, UserRo } from './user.service';
import {
  Body,
  ClassSerializerInterceptor,
  Controller,
  Get,
  Post,
  Query,
  Req,
  UseGuards,
  UseInterceptors,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import * as UserDOT from './user.dto';
import { AuthGuard } from '@nestjs/passport';

@ApiTags('用户系列接口:/api/user')
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) { }

  @ApiOperation({ summary: '获取用户信息' })
  @ApiBearerAuth()
  @UseGuards(AuthGuard('jwt')) // 此项表示该接口需要Auth认证
  @Get()
  async getUserInfo(@Req() req) {
    return req.user;
  }

  /**
   * 用户注册
   * @param post
   */
  @UseInterceptors(ClassSerializerInterceptor) // 接口返回数据时过滤掉使用 @Exclude 标记的列
  @ApiOperation({ summary: '用户注册' })
  @Post('register')
  async register(@Body() post: UserDOT.RegisterUserDto) {
    return await this.userService.Register(post);
  }

  /**
   * 修改密码
   * @param post
   */
  @ApiOperation({ summary: '修改密码' })
  @Post('updatePassword')
  async updatePassword(
    @Body() post: UserDOT.UpdatePasswordUserDto,
    @Req() req,
  ) {
    const userInfo = req.user; // 拿到请求的用户信息
    return await this.userService.UpdatePassword(post, userInfo);
  }

  /**
   * 退出登录
   * @param post
   */
  @ApiOperation({ summary: '退出登录' })
  @Post('loginOut')
  // 这里不接收参数,无需dot
  async loginOut(@Body() post: {}, @Req() req) {
    const userInfo = req.user; // 拿到请求的用户信息
    return await this.userService.LoginOut(post, userInfo);
  }

  /**
   * 获取用户列表
   * @param post
   */
  @ApiOperation({ summary: '获取用户列表' })
  @Post('getUserList')
  async getUserList(@Body() post: UserDOT.GetUserListUserDto, @Req() req) {
    return await this.userService.GetUserList(post);
  }

  /**
   * 删除单个用户(拉黑)
   * @param post
   */
  @ApiOperation({ summary: '删除单个用户(拉黑)' })
  @Post('deleteUser')
  async deleteUser(@Body() post: UserDOT.DeleteUserDto, @Req() req) {
    return await this.userService.DeleteUser(post);
  }

  /**
   * 恢复单个用户
   * @param post
   */
  @ApiOperation({ summary: '恢复单个用户' })
  @Post('recoverUser')
  async recoverUser(@Body() post: UserDOT.RecoverUserDto, @Req() req) {
    return await this.userService.RecoverUser(post);
  }

  /**
   * 查询登录表用户(分页) Mysql
   * @param post
   */
  @ApiOperation({ summary: '查询登录表用户(分页) Mysql' })
  @Post('getLoginUser')
  async getLoginUser(@Body() post: UserDOT.GetLoginUserDto, @Req() req) {
    return await this.userService.GetLoginUser(post);
  }

  /**
   * 查询登录表用户 Redis
   * @param post
   */
  @ApiOperation({ summary: '查询登录表用户 Redis' })
  @Post('getCatchLoginUser')
  async getCatchLoginUser(@Body() post: {}, @Req() req) {
    return await this.userService.GetCatchLoginUser(post);
  }

  /**
   * 查询验证码列表 Redis
   * @param post
   */
  @ApiOperation({ summary: '查询验证码列表 Redis' })
  @Post('getCapcodeList')
  async getCapcodeList(@Body() post: {}, @Req() req) {
    return await this.userService.GetCapcodeList(post);
  }

  /**
   * 清空验证码列表 Redis
   * @param post
   */
  @ApiOperation({ summary: '清空验证码列表 Redis' })
  @Post('clearCapcodeList')
  async clearCapcodeList(@Body() post: {}, @Req() req) {
    return await this.userService.ClearCapcodeList(post);
  }

  /**
   * 下线单个用户
   * @param post
   */
  @ApiOperation({ summary: '下线单个用户' })
  @Post('offlineUser')
  async offlineUser(@Body() post: UserDOT.OfflineUserDto, @Req() req) {
    return await this.userService.OfflineUser(post);
  }

  /**
   * 下线所有用户
   * @param post
   */
  @ApiOperation({ summary: '下线所有用户' })
  @Post('offlineAllUser')
  async offlineAllUser(@Body() post: {}, @Req() req) {
    return await this.userService.OfflineAllUser(post);
  }

  /**
   * 用户设置昵称
   * @param post
   */
  @ApiOperation({ summary: '用户设置昵称' })
  @Post('updateNickname')
  async updateNickname(@Body() post: UserDOT.SetNicknameDto, @Req() req) {
    const userInfo = req.user; // 拿到请求的用户信息
    return await this.userService.UpdateNickname(post, userInfo);
  }

  /**
   * 用户设置头像
   * @param post
   */
  @ApiOperation({ summary: '用户设置头像' })
  @Post('updateAvatar')
  async updateAvatar(@Body() post: UserDOT.SetAvatarDto, @Req() req) {
    const userInfo = req.user; // 拿到请求的用户信息
    return await this.userService.UpdateAvatar(post, userInfo);
  }

  /**
   * 导出 用户表当前页数据
   * @param post
   */
  @ApiOperation({ summary: '导出 用户表当前页数据' })
  @Post('exportExcel')
  async exportExcel(@Body() post: UserDOT.GetUserListUserDto, @Req() req) {
    const userInfo = req.user; // 拿到请求的用户信息
    return await this.userService.ExportExcel(post, userInfo);
  }
}
  • feature/user/user.dto.ts
import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { DataListCommonDto } from '../../common/CommonDto';

export class RegisterUserDto {
  @ApiProperty({ description: '账号' })
  @IsNotEmpty({ message: '账号必填' })
  readonly account: string;

  @ApiProperty({ description: '密码' })
  @IsNotEmpty({ message: '密码必填' })
  readonly password: string;
}

export class LoginUserDto {
  @ApiProperty({ description: '账号' })
  @IsNotEmpty({ message: '账号必填' })
  readonly account: string;

  @ApiProperty({ description: '密码' })
  @IsNotEmpty({ message: '密码必填' })
  readonly password: string;
}

export class UpdatePasswordUserDto {
  @ApiProperty({ description: '旧密码' })
  @IsNotEmpty({ message: '原密码必填' })
  readonly old_password: string;

  @ApiProperty({ description: '新密码' })
  @IsNotEmpty({ message: '新密码必填' })
  readonly new_password: string;
}

export class DeleteUserDto {
  @ApiProperty({ description: '用户id' })
  @IsNotEmpty({ message: '缺少用户id' })
  readonly id: string;
}

export class RecoverUserDto {
  @ApiProperty({ description: '用户id' })
  @IsNotEmpty({ message: '缺少用户id' })
  readonly id: string;
}

export class GetLoginUserDto {
  @ApiProperty({ description: 'pagesize' })
  @IsNotEmpty({ message: '缺少pagesize' })
  readonly pagesize: string;

  @ApiProperty({ description: 'pagenum' })
  @IsNotEmpty({ message: '缺少pagenum' })
  readonly pagenum: string;
}

export class OfflineUserDto {
  @ApiProperty({ description: '用户id' })
  @IsNotEmpty({ message: '缺少用户id' })
  readonly id: string;
}

export class SetNicknameDto {
  @ApiProperty({ description: '用户昵称' })
  @IsNotEmpty({ message: '缺少用户昵称' })
  @MaxLength(10, { message: '昵称最大长度10位' })
  @MinLength(2, { message: '昵称最小长度2位' })
  readonly nickname: string;
}

export class SetAvatarDto {
  @ApiProperty({ description: '头像地址' })
  @IsNotEmpty({ message: '缺少图片地址' })
  readonly url: string;
}

export class GetUserListUserDto extends DataListCommonDto { }
  • feature/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserEntity } from './user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserTokenEntity } from '../auth/auth.entity';
import { RedisCacheModule } from '../../redis/redis-cache.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([UserEntity]),
    TypeOrmModule.forFeature([UserTokenEntity]),
    RedisCacheModule,
  ],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}
  • feature/user/user.service.ts
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as ExcelJS from 'exceljs';
import { Like, Repository } from 'typeorm';
import { UserEntity } from './user.entity';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from '../auth/auth.entity';
var bcrypt = require('bcryptjs');
import * as REDIS from '../../constant/RedisKeyPrefix';
import fs from 'fs';
import path from 'path';
import RootDirpath from '../../common/RootDirpath';
export interface UserRo {
  userInfo: UserEntity;
}

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
    private redisCacheService: RedisCacheService,
    @InjectRepository(UserTokenEntity)
    private readonly UserTokenRepository: Repository<UserTokenEntity>,
  ) { }

  async findOne(id: string) {
    return await this.userRepository.findOne({ where: { id: id } });
  }

  // 用户注册
  async Register(post: Partial<UserEntity>): Promise<{}> {
    const { account } = post;
    const user = await this.userRepository.findOne({ where: { account } });
    if (user) {
      throw new HttpException('账号已存在', 200);
    }
    /**
     * @desc 问题标记 
     * 
     * 直接Save,并不会触发@BeforeInsert@BeforeUpdate,
            async create(attributes: DeepPartial<T>) {
                return this.repository.save(attributes); // 不会触发BeforeInsert
            }
     * 解决办法
            方法一、利用plainToClass
            async create(attributes: DeepPartial<T>) {
                const entity = plainToClass(Admin, attributes);
                return this.repository.save(entity);
            }

            方法二、利用new Entiry()
            async create(attributes: DeepPartial<T>) {
                const entiry = Object.assign(new Admin(), attributes)
                return this.repository.save(entiry);
            }

            方法三、利用Entity.create(EntitySchema)
            async create(dataDto: DataDto) {
                const entityDto = this.respository.create(dataDto)
                return this.repository.save(entityDto);
            }
     */
    const newUser = await this.userRepository.create(post);
    await this.userRepository.save(newUser);
    return {};
  }

  // 修改密码
  async UpdatePassword(post, userInfo): Promise<{}> {
    const { id, password, account } = userInfo;
    const { old_password, new_password } = post;
    const user = await this.userRepository.findOne({ where: { id } });
    if (!user) {
      throw new HttpException('此账号不存在', 200);
    }
    // 判断新旧密码一致性
    // 使用<compareSync>方法进行新旧密码对比
    if (new_password === old_password) {
      throw new HttpException('新密码与原密码一致', 200);
    }
    if (!bcrypt.compareSync(old_password, password)) {
      throw new HttpException('原密码不正确', 200);
    }
    // 将新密码单独加密
    const salt = await bcrypt.genSaltSync(10);
    const endPWD = await bcrypt.hashSync(new_password, salt);
    // 合并数据并入库
    const updatePost = this.userRepository.merge(user, {
      password: endPWD,
    });
    // 清除redis存储的token,引导用户重新登录
    this.redisCacheService.cacheDel(
      `${REDIS.RedisPrefixToken}${id}&${account}`,
    );
    await this.userRepository.save(updatePost);
    return {};
  }

  // 用户登出
  async LoginOut(post, userInfo): Promise<{}> {
    const { id, account } = userInfo;
    // const user = await this.userRepository.findOne({ where: { id } });
    // 删除登录表中的此用户
    this.UserTokenRepository.delete({ uuid: id });
    // 清除redis存储的token
    this.redisCacheService.cacheDel(
      `${REDIS.RedisPrefixToken}${id}&${account}`,
    );
    return {};
  }

  // 创建用户假数据
  async mockData() {
    const len = 50000;
    const phoneNum = 18210249690;
    for (let i = 0; i < len; i++) {
      const new_user = {
        account: String(phoneNum * 1 + i * 1),
        password: '182',
      };
      const user = await this.userRepository.findOne({
        where: { account: new_user.account },
      });
      if (user) return;
      const newUser = await this.userRepository.create(new_user);
      await this.userRepository.save(newUser);
    }
  }

  // 获取用户列表(分页)
  async GetUserList(post) {
    const { pagenum = 1, pagesize = 10, keyword } = post;
    const qb = this.userRepository.createQueryBuilder('user'); // qb实体
    qb.withDeleted(); // 指示是否应在实体结果中包含软删除的行
    // 如果有关键字,进行账号模糊查询
    if (keyword) {
      qb.where({
        account: Like(`%${keyword}%`),
      });
    }
    // 定义要返回的字段
    qb.select([
      'user.id',
      'user.account',
      'user.nickname',
      'user.avatar',
      'user.create_time',
      'user.update_time',
      'user.delete_time',
    ]); // 需要的属性
    // qb.where("user.id = :id", { id: 1 }) // 条件语句
    qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
    const count = await qb.getCount(); // 查总数
    qb.offset(pagesize * (pagenum - 1)); // 偏移位置
    qb.limit(pagesize); // 条数
    // .skip(5)     // 可选:跳过多少条
    // .take(10)    // 可选:拿多少条
    const list = await qb.getMany(); // getMany() 获取所有用户
    return {
      list,
      count,
    };
  }

  // 拉黑单个用户
  async DeleteUser(post) {
    const { id } = post;
    const item = await this.userRepository.findOne({
      where: { id } as any,
      withDeleted: true, // 指示是否应在实体结果中包含软删除的行
    });
    console.log('item', item);
    if (item) {
      await this.userRepository.softRemove(item);
      return {};
    } else {
      throw new HttpException('用户不存在', 200);
    }
  }

  // 恢复单个用户
  async RecoverUser(post) {
    const { id } = post;
    const item = await this.userRepository.findOne({
      where: { id } as any,
      withDeleted: true,
    });
    if (item) {
      await this.userRepository.recover(item);
      return {};
    } else {
      throw new HttpException('用户不存在', 200);
    }
  }

  // 查询登录表用户(分页) Mysql
  async GetLoginUser(post) {
    const { pagenum = 1, pagesize = 10 } = post;
    const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
    qb.select([
      'user.id',
      'user.uuid',
      'user.token',
      'user.account',
      'user.nickname',
      'user.create_time',
      'user.update_time',
    ]); // 需要的属性
    qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
    const count = await qb.getCount(); // 查总数
    qb.offset(pagesize * (pagenum - 1)); // 偏移位置
    qb.limit(pagesize); // 条数
    const list = await qb.getMany(); // getMany() 获取所有用户
    return {
      list,
      count,
    };
  }

  // 查询登录表用户 Redis
  async GetCatchLoginUser(post) {
    const arr = await this.redisCacheService.cacheStoreKeys();
    const keys = arr.filter(
      (item) => item.indexOf(REDIS.RedisPrefixToken) > -1,
    );
    if (keys?.length) {
      let datas = [];
      for (let i = 0; i < keys.length; i++) {
        const val = await this.redisCacheService.cacheGet(keys[i]);
        if (val) {
          let noPrefix = keys[i].split(REDIS.RedisPrefixToken)[1]; // 不包含前缀的值
          datas.push({
            KEY: keys[i],
            VALUE: val,
            uuid: noPrefix.split('&')[0],
            account: noPrefix.split('&')[1],
            token: val,
          });
        }
      }
      return {
        list: datas,
        count: datas.length,
      };
    }
    return {};
  }

  // 查询图片验证码 Redis
  async GetCapcodeList(post) {
    const arr = await this.redisCacheService.cacheStoreKeys();
    const keys = arr.filter(
      (item) => item.indexOf(REDIS.RedisPrefixCaptcha) > -1,
    );
    if (keys?.length) {
      let datas = [];
      for (let i = 0; i < keys.length; i++) {
        const val = await this.redisCacheService.cacheGet(keys[i]);
        if (val) {
          let noPrefix = keys[i].split(REDIS.RedisPrefixToken)[1]; // 不包含前缀的值
          datas.push({
            KEY: keys[i],
            VALUE: val,
          });
        }
      }
      return {
        list: datas,
        count: datas.length,
      };
    }
    return {};
  }

  // 下线单个用户
  async OfflineUser(post) {
    const { id, account } = post;
    // 查找登录表里的用户
    const findRow = await this.UserTokenRepository.findOne({
      where: { uuid: id },
    });
    if (findRow) {
      // 删除登录表中的用户
      await this.UserTokenRepository.remove(findRow);
      // 清除redis缓存里的用户
      this.redisCacheService.cacheDel(
        `${REDIS.RedisPrefixToken}${id}&${account}`,
      );
      return {};
    } else {
      throw new HttpException('用户不存在', 200);
    }
  }

  // 下线所有用户
  async OfflineAllUser(post) {
    const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
    // 清空 登录表
    qb.delete().execute();
    // // 清空 redis
    // this.redisCacheService.cacheClear();
    // return {};
    const arr = await this.redisCacheService.cacheStoreKeys();
    const keys = arr.filter(
      (item) => item.indexOf(REDIS.RedisPrefixToken) > -1,
    );
    if (keys?.length) {
      for (let i = 0; i < keys.length; i++) {
        this.redisCacheService.cacheDel(keys[i]);
      }
    }
    return {};
  }

  // 清空图片验证码
  async ClearCapcodeList(post) {
    const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
    // // 清空 redis
    const arr = await this.redisCacheService.cacheStoreKeys();
    const keys = arr.filter(
      (item) => item.indexOf(REDIS.RedisPrefixCaptcha) > -1,
    );
    if (keys?.length) {
      for (let i = 0; i < keys.length; i++) {
        this.redisCacheService.cacheDel(keys[i]);
      }
    }
    return {};
  }

  // 用户设置昵称
  async UpdateNickname(post, userInfo) {
    const { id } = userInfo;
    const { nickname } = post;
    const user = await this.userRepository.findOne({ where: { id } });
    if (user && nickname) {
      const updatePost = this.userRepository.merge(user, {
        nickname: nickname,
      });
      await this.userRepository.save(updatePost);
      const findUser = await this.userRepository.findOne({
        where: { id },
      });
      return {
        userInfo: {
          account: findUser.account,
          avatar: findUser.avatar,
          nickname: findUser.nickname,
          id: findUser.id,
        },
      };
    } else {
      return {};
    }
  }

  // 用户设置头像
  async UpdateAvatar(post, userInfo) {
    const { id } = userInfo;
    const { url } = post;
    const user = await this.userRepository.findOne({ where: { id } });
    if (user && url) {
      const updatePost = this.userRepository.merge(user, {
        avatar: url,
      });
      await this.userRepository.save(updatePost);
      const findUser = await this.userRepository.findOne({
        where: { id },
      });
      return {
        userInfo: {
          account: findUser.account,
          avatar: findUser.avatar,
          nickname: findUser.nickname,
          id: findUser.id,
        },
      };
    } else {
      return {};
    }
  }

  async ExportExcel(post, userInfo) {
    // 解构前端入参
    const { pagenum = 1, pagesize = 10, keyword } = post;
    const qb = this.userRepository.createQueryBuilder('user'); // qb实体
    qb.withDeleted(); // 指示是否应在实体结果中包含软删除的行
    // 如果有关键字,进行账号模糊查询
    if (keyword) {
      qb.where({
        account: Like(`%${keyword}%`),
      });
    }
    // 定义要返回的字段
    qb.select([
      'user.id',
      'user.account',
      'user.nickname',
      'user.avatar',
      'user.create_time',
      'user.update_time',
    ]); // 需要的属性=
    qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
    qb.skip(pagesize * (pagenum - 1)); // 跳过多少条
    qb.take(pagesize); // 拿多少条
    // result是通过前端传递的参数从数据库获取需要导出的信息
    const result = await qb.getMany(); // getMany() 获取所有用户
    const workbook = new ExcelJS.Workbook();
    // 设置 Excel 的 sheet
    const worksheet = workbook.addWorksheet('【 用户注册表数据 】');
    // 定义表头名称和字段名
    worksheet.columns = [
      { header: 'ID', key: 'id', width: 42 },
      { header: '账号', key: 'account', width: 25 },
      { header: '昵称', key: 'nickname', width: 25 },
      { header: '头像', key: 'avatar', width: 50 },
      { header: '注册时间', key: 'create_time', width: 25 },
      { header: '更新时间', key: 'update_time', width: 25 },
    ];
    worksheet.addRows(result);
    // 针对头像列each循环 修改样式
    const AvatarColumn = worksheet.getColumn('avatar');
    AvatarColumn.eachCell((item) => {
      item.style.font = {
        italic: true, // 斜体
        underline: 'single', // 下划线 单条
        color: {
          // argb: 'be14807f', // 字体颜色
          theme: 3,
        },
      };
      item.value = {
        text: item.value as string, // 设置 value
        hyperlink: item.value as string, // 设置 link
      };
    });
    // 获取第一行
    const FirstRow = worksheet.getRow(1);
    FirstRow.height = 28; // 第一行高度
    FirstRow.font = {
      size: 18, // 字体大小
      bold: true, // 加粗
      // 字体颜色 argb和theme二选一即可
      color: {
        // argb: 'be14807f'
        theme: 5, // 0白 1黑 2灰 3蓝 5红
      },
    };
    const content = await workbook.xlsx.writeBuffer();
    return {
      filename: '用户注册表导出--' + new Date().getTime(),
      content, // 前端接受到的数据格式为{type: 'buffer', data: []}
    };
  }
}
  • feature/user/user.entity.ts 用户表实体
import {
  BeforeInsert,
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
const bcrypt = require('bcryptjs');

@Entity('user') // 库表名称
export class UserEntity {
  // 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。 Uuid 是一个独特的字符串
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 20 })
  account: string;

  @Exclude()
  @Column({
    length: 100,
    // select: false // 表示隐藏此列 和@Exclude二选一
  })
  password: string;

  @Column({ length: 20, default: 'User' })
  nickname: string;

  @Column({ default: 'http://localhost:3001/static/default.png' })
  avatar: string;

  @CreateDateColumn({
    type: 'timestamp',
    comment: '创建时间',
  })
  create_time: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    comment: '更新时间',
  })
  update_time: Date;

  @DeleteDateColumn({
    type: 'timestamp',
    comment: '删除时间',
  })
  delete_time: Date;

  /**
   * @func 在密码入库前加密替换
   * @desc <注册 修改密码> 会执行此逻辑
   */
  @BeforeInsert()
  async encryptPwd() {
    var salt = await bcrypt.genSaltSync(10);
    this.password = await bcrypt.hashSync(this.password, salt);
  }
}
  • src/redis/redis-cache.module.ts Redis 模块
/**
 * 为了启用缓存, 导入ConfigModule, 并调用register()或者registerAsync()传入响应的配置参数
 */

import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisCacheService } from './redis-cache.service';
import { CacheModule, Module, Global } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';

@Module({
  imports: [
    /**
     * <CacheModule>的<registerAsync>方法采用 Redis Store 配置进行通信
     * 由于Redis 信息写在配置文件中,所以采用<registerAsync()>方法来处理异步数据,如果是静态数据, 可以使用register
     */
    CacheModule.registerAsync({
      /**
       * isGlobal 属性设置为true 来将其声明为全局模块
       * 当我们将RedisCacheModule在AppModule中导入时, 其他模块就可以直接使用,不需要再次导入
       */
      isGlobal: true,
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        return {
          store: redisStore, // store 属性值redisStore ,表示'cache-manager-redis-store'
          host: configService.get('REDIS_HOST'),
          port: configService.get('REDIS_PORT'),
          db: 0, //目标库,
          auth_pass: configService.get('REDIS_PASSPORT'), // 密码,没有可以不写
        };
      },
    }),
  ],
  providers: [RedisCacheService],
  exports: [RedisCacheService],
})
export class RedisCacheModule { }
  • src/redis/redis-cache.service.ts Redis 服务方法
    • 存入
    • 获取
    • 清空
/**
 * @file 在service实现缓存的读写
 * 
 * @desc redis主要做了哪些功能?
 * 我们借助redis来实现token过期处理、token自动续期、以及用户唯一登录。
    - 过期处理:把用户信息及token放进redis,并设置过期时间
    - token自动续期:token的过期时间为30分钟,如果在这30分钟内没有操作,则重新登录,如果30分钟内有操作,就给token自动续一个新的时间,防止使用时掉线。
    - 户唯一登录:相同的账号,不同电脑登录,先登录的用户会被后登录的挤下线
 * @desc 为什么要使用redis来存token?
    1. 引入一个中间件管理token就避免了单点问题,对于分布式系统来说,不管你是哪一台服务处理的用户请求,我都是从redis获取的token。
    2. redis的响应速度非常快,如果不出现网络问题,基本上是毫秒级别相应。
    3. 对于token来说,是有时效性的,redis天然支持设置过期时间以及通过一些二方包提供的API到达自动续时效果。
 */

import { Injectable, Inject, CACHE_MANAGER } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class RedisCacheService {
  constructor(
    @Inject(CACHE_MANAGER)
    private cacheManager: Cache,
  ) { }

  // 存入指定缓存
  cacheSet(key: string, value: string, ttl: number) {
    this.cacheManager.set(key, value, { ttl }, (err) => {
      if (err) throw err;
    });
  }

  // 获取指定缓存
  async cacheGet(key: string): Promise<any> {
    return this.cacheManager.get(key);
  }

  // 清除指定缓存
  async cacheDel(key: string): Promise<any> {
    return this.cacheManager.del(key, (err) => {
      if (err) throw err;
    });
  }

  // 清空redis全部缓存
  async cacheClear(): Promise<any> {
    return this.cacheManager.reset();
  }

  // 查询redis全部Key
  async cacheStoreKeys(): Promise<any> {
    return this.cacheManager.store.keys();
  }
}
  • /src/common/captcha.ts 生成图片验证码
/**
 * @file 生成图片验证码
 */

import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';

@Injectable()
export class ToolsCaptcha {
  async captche(size = 4) {
    const captcha = svgCaptcha.create({
      // 可配置返回的图片信息
      size, // 生成几个验证码
      fontSize: 50, // 文字大小
      width: 100, // 宽度
      height: 34, // 高度
      background: '#cc9966', // 背景颜色
    });
    return captcha;
  }
}
  • /src/common/CommonDto.ts 业务公用的dot类
/**
 * @file 业务公用的dot类
 */

import { IsNotEmpty, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

// 分页实体类
export class DataListCommonDto {
  @ApiProperty({ description: '分页条数' })
  @IsNotEmpty({ message: '分页条数必填' })
  @Max(100, { message: '最多多分页100条数据' }) // 限制分页,避免数据被大量导出
  readonly pagesize: string;

  @ApiProperty({ description: '分页页数' })
  @IsNotEmpty({ message: '分页页数必填' })
  readonly pagenum: string;

  @ApiProperty({ description: '排序字段' })
  readonly order?: string;
}
  • src/common/encrypt.ts ID编码生成类
/**
 * ID编码生成类
 */

export class CreateEncrypt {
  guid(): string {
    // eg: "a1ca0f7b-51bd-4bf3-a5d5-6a74f6adc1c7"
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
      /[xy]/g,
      function (c) {
        var r = (Math.random() * 16) | 0,
          v = c == 'x' ? r : (r & 0x3) | 0x8;
        return v.toString(16);
      },
    );
  }
  uuid(): string {
    // eg: "ffb7cefd-02cb-4853-8238-c0292cf988d5"
    var s = [];
    var hexDigits = '0123456789abcdef';
    for (var i = 0; i < 36; i++) {
      s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
    }
    s[14] = '4'; // bits 12-15 of the time_hi_and_version field to 0010
    s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
    s[8] = s[13] = s[18] = s[23] = '-';

    var uuid = s.join('');
    return uuid;
  }
  nanoid(size: number = 21): string {
    // eg: "AfRTJv9hRo42vKKUDBQLX"
    let urlAlphabet =
      'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
    let id = '';
    // A compact alternative for `for (var i = 0; i < step; i++)`.
    let i = size;
    while (i--) {
      // `| 0` is more compact and faster than `Math.floor()`.
      id += urlAlphabet[(Math.random() * 64) | 0];
    }
    return id;
  }
}
  • src/common/RootDirpath.ts
import { join } from 'path';

/**
 * @des 跟路径拼接,避免嵌套太深的文件夹过度使用../
 * @return /Users/.../nestjs_server_template/
 */
const RootDirPath = join(__dirname, '../../../');

export default RootDirPath;
  • src/constant/ApiWriteList.ts
    • 接口白名单队列
    • 自动续期逻辑的忽略名单
    • JWT 鉴权忽略检测的接口名单
/**
 * @file 接口白名单队列
 */

// token 自动续期逻辑的忽略名单
export const TOKEN_AUTOMATIC_RENEWAL_IGNORE_LIST: Array<string> = [
  '/api/auth/authcode', // 获取图片验证码
  '/api/auth/comparecode', // 验证图片验证码
  '/api/auth/login', // 登录
  '/api/user/register', // 注册
  '/api/user/loginOut', // 退出登录
];

// JWT 鉴权忽略检测的接口名单
export const JWT_IGNORE_LIST: Array<string> = [
  '/api/auth/authcode', // 获取图片验证码
  '/api/auth/comparecode', // 验证图片验证码
  '/api/auth/login', // 登录
  '/api/user/register', // 注册
];
  • src/constant/LengthOfTime.ts
/**
 * @desc 时长 常量
 */

// 登录时生成token存入redis设置的过期时间(秒)
export const TOKEN_FIRST_SET_TIME = 1800;

// 验证码存入redis设置的过期时间(秒)
export const CAPCODE_FIRST_SET_TIME = 180;

// 有效期内接收请求token自动续期有效时长(秒)
export const TOKEN_AUTOMATIC_RENEWAL_TIME = 1800;
  • src/constant/RedisKeyPrefix.ts redis数据的key前缀
/**
 * @desc redis数据的key前缀 常量
 */

export const RedisPrefixToken = 'TOKEN_$$_'; // token 前缀

export const RedisPrefixCaptcha = 'CAPTCHA_$$_'; // 图片验证码 前缀

  • src/core/filter/transform.filter.ts 过滤器判断场景
import {
    ArgumentsHost,
    Catch,
    ExceptionFilter,
    HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
    catch(exception: HttpException, host: ArgumentsHost) {
        const ctx = host.switchToHttp(); // 获取请求上下文
        const response = ctx.getResponse(); // 获取请求上下文中的 response对象
        let status = exception.getStatus(); // 获取异常状态码
        /**
         * @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
         * code === -1 :前端直接全局报message的错
         * code === [其它] 单独进行特殊场景判断
         */
        const exceptionResponse: any = exception.getResponse();
        let validMessage: string = '';

        for (let key in exception) {
            // console.log('===过滤器===', key, exception[key]);
        }
        if (typeof exceptionResponse === 'object') {
            validMessage =
                typeof exceptionResponse.message === 'string'
                    ? exceptionResponse.message
                    : exceptionResponse.message[0];
        }
        const message = exception.message
            ? exception.message
            : `${status >= 500 ? 'Service Error' : 'Client Error'}`;
        // 单独拦截<Unauthorized> , 以中文形式返回给客户端 , 一般token不正确会进入
        if (validMessage === 'Unauthorized') {
            validMessage = '未经授权的请求';
        }
        if (
            validMessage === 'ThrottlerException: Too Many Requests' ||
            message === 'ThrottlerException: Too Many Requests'
        ) {
            validMessage = '操作频率过高';
        }
        const errorResponse = {
            data: {},
            message: validMessage || message,
            code: -1,
        };

        // 设置返回的状态码, 请求头,发送错误信息
        response.status(status);
        response.header('Content-Type', 'application/json; charset=utf-8');
        response.send(errorResponse);
    }
}
  • src/core/guard/jwt.guard.ts 鉴权守卫
/**
 * @desc 将AuthGuard('jwt')单个接口守卫升级为当前类
 * 实现全局注册鉴权守卫
 */

import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import * as ApiWriteList from '../../constant/ApiWriteList';

export class JwtAuthGuard extends AuthGuard('jwt') {
  public requestUrl = '';
  constructor() {
    super();
  }
  getRequest(context: ExecutionContext) {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest();
    this.requestUrl = request.url;
    return request;
  }

  handleRequest<User>(err, user: User): User {
    // 判断:当前接口是否需要跳过全局JWT鉴权守卫
    if (ApiWriteList.JWT_IGNORE_LIST.includes(this.requestUrl)) {
      return {} as User;
    }
    if (err || !user) {
      throw new UnauthorizedException('身份验证失败');
    }
    return user;
  }
}
  • app.controller.ts
    • 配置顶级路由
    • 配置模版引擎路由和返回值
import { Controller, Get, Redirect, Render } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@ApiTags('顶级路由:/api')
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @ApiOperation({ summary: '前台 访问:/api/default' })
  @Get('default') // 前台 访问:/api/default
  @Render('default/index') // 使用render渲染模板引擎,参数就是 /views 文件夹下的 index.ejs
  defaultIndex() {
    return {
      // 只有返回参数在模板才能获取,如果不传递参数,必须返回一个空对象
      msg: '欢迎访问<default>前台页面',
    };
  }

  @ApiOperation({ summary: '后台 访问:/api/admin' })
  @Get('admin') // 后台 访问:/api/admin
  @Render('admin/index') // 使用render渲染模板引擎,参数就是 /views 文件夹下的 index.ejs
  adminIndex() {
    return {
      // 只有返回参数在模板才能获取,如果不传递参数,必须返回一个空对象
      msg: '欢迎访问<admin>后台页面',
    };
  }
}
  • main.ts
    • 设置全局路由前缀
    • 全局注册过滤器
    • 全局注册拦截器
    • 全局注册鉴权守卫
    • 跨域资源共享
    • 允许跨站访问
    • 防止跨站脚本攻击
    • CSRF保护
    • 全局注册管道
    • 设置swagger文档
    • 配置静态资源目录
    • 设置虚拟路径
    • 配置模板渲染引擎
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { PORT, IP } from './constant/ServerListen';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './core/filter/transform.filter';
import { TransformInterceptor } from './core/interceptor/transform.interceptor';
import { SwaggerConfig } from './common/swagger';
import { JwtAuthGuard } from './core/guard/jwt.guard';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // 设置全局路由前缀
  app.setGlobalPrefix('api');
  // 全局注册过滤器
  app.useGlobalFilters(new HttpExceptionFilter());
  // 全局注册拦截器
  app.useGlobalInterceptors(new TransformInterceptor());
  // 全局注册鉴权守卫
  app.useGlobalGuards(new JwtAuthGuard());
  // 跨域资源共享
  app.enableCors(); // 允许跨站访问 或:const app = await NestFactory.create(AppModule, { cors: true });
  // 防止跨站脚本攻击
  // app.use(helmet()); // 打开配置可能会有跨域问题
  // CSRF保护:跨站点请求伪造
  // app.use(csurf({ cookie: true }));
  // 全局注册一下管道ValidationPipe
  app.useGlobalPipes(new ValidationPipe());
  // 设置swagger文档
  new SwaggerConfig(app).Init();
  // 配置静态资源目录
  app.useStaticAssets(join(__dirname, '../../public'), {
    // 配置虚拟目录,比如 http://localhost:3001/static/002.png 来访问public目录里面的文件
    prefix: '/static/', // 设置虚拟路径
  });
  // 配置模板渲染引擎
  app.setBaseViewsDir(join(__dirname, '../../views'));
  /**
   * @desc 配置模版引擎使用规则
   * @params ejs 和 hbs 二选一即可
   */
  app.setViewEngine('ejs');
  await app.listen(PORT);
}
bootstrap();
  • 新建 后台模版 /views/admin/index.ejs
  • 新建 前台模版 /views/default/index.ejs
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <h3>NestJS 服务</h3>
        <h3>ejs 模板引擎</h3>
        <h3><%=data.msg%></h3>
    </body>
</html>

最终结果

  • 接口文档

NestJS 项目实战(下)

  • 使用 vue3 写了个简单的前端项目,前端去调用后端服务
    • 前端:3000 端口
    • 后端:3001 端口
    • mysql:3306 端口
    • redis:6379 端口

NestJS 项目实战(下)

  • 调用接口注册了3个用户
    • 密码需要加密入库,也就是只有用户知道,或者修改密码,无法解码。
    • 对比过程仔细去看代码,跟md5的对比过程类似,都是对比加密后的串,只是编码规则不一样而已

NestJS 项目实战(下)

  • 前端多次调用查看验证码,然后我们去看Redis可视化

NestJS 项目实战(下)

VS Code 可以安装 DataBase,提供基础的可视化,过期时间也看得到,这就是缓存,再过一分钟去看,就什么都看不到了,因为已经过期,这就是我们代码里设置的过期时间。

NestJS 项目实战(下)

  • 登录进去页面之后,看下主页的布局

可以看到:红框标注的内容会调用前面写的那些接口,包括(CRUD、上传图片、模糊查询、分页、用户登录流程...等)

NestJS 项目实战(下)

注意事项

  • 每个模块的Entity 实体类记得在 app.module.ts 文件中挂载

  • DTO 规则记得全局挂载 管道,并且在控制器的参数处映射拦截

  • 项目许多地方需要在全局挂载引入,这种情况下我们应该想到尽量去轻量化入口,也就是配置能提取的就提出去,在入口一行引入实例化解决挂载问题

  • 还有较多 DTOTypeROM 的知识,后面会更新补上。

  • 文章涉及代码较多,尤其是 鉴权 部分,需要融汇贯通地理解下。

  • 注意不要忘记模块的引入、抛出、constructor 声明...等。

  • 鉴权部分前期涉及到自定义装饰器,中期涉及到每个接口都需要添加装饰器比较繁琐,所以后来把它们封装了管道统一调用,不需要的地方加入接口白名单就可以了,这些文中都做好了。

  • 列举出来的功能文中都已经实现了,具体细节和原因配合注释去理解就行。

有什么问题随时沟通交流