NestJS实战-后端开发-全局配置

2,251 阅读16分钟

NestJS实战-后端开发-全局配置

本文介绍 NestJS 实战后端开发的全局配置:基础栈介绍、项目基础搭建、Apifox接入、navicat数据库连接、Swagger API文档生成、数据响应全局封装、全局异常处理、全局拦截器、全局日志监听、服务监控、JWT权限配置、登录登出、公共服务等。

供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

目前,前端静态部分已经完成开发,后端也开始做了,写这篇文章做个记录。

本章主要介绍 NestJS 实战后端开发的全局配置:基础栈介绍、项目基础搭建、Apifox接入、navicat数据库连接、Swagger API文档生成、数据响应全局封装、全局异常处理、全局拦截器、全局日志监听、服务监控、JWT权限配置、登录登出、公共服务等。

对于Nest基础学习,请看NodeJS-NestJS基础。

技术栈介绍

  • 主框架:NestJS、TypeScript、Rxjs
  • 身份验证:JWT
  • API接口文档:Swagger
  • 接口调试工具:Apifox
  • 单元测试:jest
  • 数据库:MySQL、typeorm、mysql2
  • 数据库可视化:Navicat 或者 VScode 插件 Database Client

NestJS安装

npm i -g @nestjs/cli
nest new server-backend

选择你喜欢的包管理模块

? Which package manager would you ❤️  to use? 
❯ npm 
  yarn 
  pnpm 

项目运行

应用程序运行后,打开浏览器并访问 http://localhost:3000/ 地址,将看到类似 Hello World! 的信息。

我 3000 端口有其他项目占用,我就修改了下main.ts的代码,把端口改成 8004 了

npm run start

安装业务必要依赖包

日期时间处理包 moment:

npm install moment

密码加密包 bcrypt:

npm install bcrypt

excel 文件生成工具 xlxs:

npm install xlsx

新建Apifox项目

开发项目我们需要调试接口,那主流的就是 postman 和 apifox,我个人推荐 apifox进行接口联调,因为Apifox = Postman + Swagger + Mock + JMeter

首先新建项目:

apifox-project-create

然后我们看看项目 UI,支持导入 swagger api 文档的,后面会介绍下怎么导入。

apifox-ui

新建数据库链接

我们先进行项目数据库的安装,数据库使用大众最熟悉的 mysql,关系映射选择 typeorm。

安装依赖

npm install --save @nestjs/typeorm typeorm mysql2

新建数据库

新建一个数据库连接:

使用 navicat 新建一个数据库连接:

mysql-link

连接右键新建数据库:

mysql-database

新建数据库配置文件:

在src目录下新建一个 ormconfig.ts 文件:

import { TypeOrmModuleOptions } from '@nestjs/typeorm';

// 这里用户名密码请换成自己的,database也可以换成自己的
export const typeOrmConfig: TypeOrmModuleOptions = {
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: 'Initial0!',
  database: 'crm-database',
  entities: [__dirname + '/**/*.entity{.ts,.js}'],
  synchronize: true,
  retryDelay: 500, // 重试连接数据库间隔
  retryAttempts: 10, // 重试连接数据库的次数
  autoLoadEntities: true,
};

在app.module.ts添加数据库连接:

数据库建好后,需要在 app.module.ts 里建立连接

// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeOrmConfig } from './ormconfig';

@Module({
  imports: [
    TypeOrmModule.forRoot(typeOrmConfig),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

接入 Swagger API 文档

安装 swagger 相关的 npm 依赖包

npm install @nestjs/swagger swagger-ui-express --save

修改 main.ts 集成创建 swagger-ui 相关代码:

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 注册全局接口参数验证
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
      stopAtFirstError: true,
      skipMissingProperties: false, // 确保所有的属性都被验证
    }),
  );
  const swaggerOptions = new DocumentBuilder()
    .setTitle('cms-project api')
    .setDescription('内容管理系统描述')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, swaggerOptions);
  SwaggerModule.setup('api-docs', app, document);
  await app.listen(8004);
}
bootstrap();

启动服务后,我们访问http://localhost:8004/api-docs看看:

api-docs

上图只能看到没有分组的接口,并且没有接口描述,也没有参数定义,但 @nestjs/swagger 给我们定义了很多装饰器去做,下面介绍下常用的装饰器方法。

  • @ApiTags: 用于为控制器或路由指定一个或多个标签,这些标签在 Swagger UI 中用于组织和分类 API 操作。
  • @ApiOperation: 为控制器的方法提供一个简短的摘要和详细描述,这些信息将显示在 Swagger UI 中。
  • @ApiParam: 描述一个路径参数、查询参数或响应参数,包括名称、类型、描述等信息。
  • @ApiBody: 指定请求体的 DTO 类,用于描述请求体的结构。
  • @ApiResponse: 描述 API 响应的结构,包括状态码和描述。
  • @ApiBearerAuth: 指定请求需要携带 Bearer Token 进行身份验证。
  • @ApiProperty: 用于 DTO 类中的属性,为其添加元数据,如描述、是否必填、默认值等。
  • @ApiQuery: 描述查询参数,包括名称、类型、描述等。
  • @ApiHeader: 描述请求头信息,包括名称、类型、描述等。
  • @ApiExcludeEndpoint: 用于排除某个控制器方法,使其不显示在 Swagger UI 中。
  • @ApiImplicitBody: 用于隐式设置请求体的定义,可以避免在 DTO 类上使用多个 @ApiProperty 装饰器。
  • @ApiImplicitParam: 用于为请求隐式添加参数定义,类似于 @ApiParam,但是用于请求体内部的参数。
  • @ApiUseTags: 用于给控制器或路由指定标签,这些标签在 Swagger UI 中用于分类。

响应全局封装

上面的用户管理相关接口,我们都只是返回数据,但给到前端的时候一般还需要给到 code 去判断响应状态,返回 msg 抛错,返回 result 才是真正的结果。那我们需要进行全局拦截请求响应和错误处理。

创建全局拦截器

在 src 目录下新建 interceptors/transform.interceptor.ts:

import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  code: number;
  msg: string;
  result: T;
}

export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<Response<T>> {
    return next.handle().pipe(
      map((data) => ({
        code: 200,
        msg: 'success',
        result: data,
      })),
    );
  }
}

全局异常处理

在 src 目录下新建 exceptions/http-exception.filter.ts:

import {
  ArgumentsHost,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';

export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    let status: number;
    let errorResponse: string | object;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      errorResponse = exception.getResponse();
    } else {
      status = HttpStatus.INTERNAL_SERVER_ERROR;
      errorResponse = exception;
    }

    // 这里需要判断错误响应是否是数组,并进行相应处理
    let errorMessage: string | unknown = 'Internal Server Error';
    if (
      typeof errorResponse === 'object' &&
      errorResponse !== null &&
      'message' in errorResponse
    ) {
      if (Array.isArray(errorResponse.message)) {
        errorMessage = errorResponse.message[0];
      } else {
        errorMessage = errorResponse.message;
      }
    } else if (typeof errorResponse === 'string') {
      errorMessage = errorResponse;
    }

    return response.status(status).json({
      code: status,
      msg: errorMessage || '服务异常',
      result: null,
    });
  }
}

全局注册拦截器和过滤器

在 main.ts 使用 useGlobalFilters 和 useGlobalInterceptors 使用全局过滤器和拦截器:

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 注册全局接口参数验证
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
      stopAtFirstError: true,
      skipMissingProperties: false, // 确保所有的属性都被验证
    }),
  );
  // 注册全局过滤器
  app.useGlobalFilters(new HttpExceptionFilter());
  // 注册全局拦截器
  app.useGlobalInterceptors(new TransformInterceptor());

  const swaggerOptions = new DocumentBuilder()
    .setTitle('cms-project api')
    .setDescription('内容管理系统描述')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, swaggerOptions);
  SwaggerModule.setup('api-docs', app, document);
  await app.listen(8004);
}
bootstrap();

把 Swagger 导入 apifox

为了更好的进行单元测试和文档查看,我们使用 apifox 进行接口调试,那我们就要把 swagger 的 api 文档导入 apifox。

我们先把 api 导出到 json 文件中,在main.ts中新增:

import { writeFileSync } from 'fs';
// ...其他引入

async function bootstrap() {
  // ...其他全局配置

  // 将 Swagger 文档保存为 JSON 文件
  writeFileSync('./swagger.json', JSON.stringify(document, null, 2), 'utf8');

  await app.listen(8004);
}
bootstrap();

使用 apifox 的导入数据功能:

api-import

导入成功后,接口如下,发起请求试验下:

api-query

集成 nest-winston 进行日志记录

使用 nest-winston 集成 winston 来进行日志记录,并配置 winston 的传输方式,如控制台、文件、每日轮换文件等。

创建中间件来记录每个请求的信息,如请求方法、URL、IP 地址、状态码和响应时间。可以在请求结束时记录日志,并且可以根据响应状态码来决定日志级别。

安装 nest-winston 依赖

npm install nest-winston winston winston-daily-rotate-file chalk@4

配置 nest-winston

创建 config/winston.logger.ts 文件进行 winston 配置:

import * as chalk from 'chalk'; // 用于颜色化输出
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

// 定义日志级别颜色
const levelsColors = {
  error: 'red',
  warn: 'yellow',
  info: 'black',
  debug: 'blue',
  verbose: 'cyan',
};

const winstonLogger = createLogger({
  format: format.combine(
    format.timestamp(),
    format.errors({ stack: true }),
    format.splat(),
    format.json(),
  ),
  defaultMeta: { service: 'log-service' },
  transports: [
    new DailyRotateFile({
      filename: 'logs/errors/error-%DATE%.log', // 日志名称,占位符 %DATE% 取值为 datePattern 值。
      datePattern: 'YYYY-MM-DD', // 日志轮换的频率,此处表示每天。
      zippedArchive: true, // 是否通过压缩的方式归档被轮换的日志文件。
      maxSize: '20m', // 设置日志文件的最大大小,m 表示 mb 。
      maxFiles: '14d', // 保留日志文件的最大天数,此处表示自动删除超过 14 天的日志文件。
      level: 'error', // 日志类型,此处表示只记录错误日志。
    }),
    new DailyRotateFile({
      filename: 'logs/warnings/warning-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '14d',
      level: 'warn',
    }),
    new DailyRotateFile({
      filename: 'logs/app/app-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '14d',
    }),
    new transports.Console({
      format: format.combine(
        format.colorize({
          colors: levelsColors,
        }),
        format.simple(),
        format.printf((info) => {
          // 获取 Info Symbols key
          const symbols = Object.getOwnPropertySymbols(info);
          const color = levelsColors[info[symbols[0]]]; // 获取日志级别的颜色
          const chalkColor = chalk[color];
          const message = `${chalkColor(info.timestamp)} ${chalkColor(info[symbols[2]])}`;
          return message;
        }),
      ),
      level: 'debug',
    }),
  ],
});

export default winstonLogger;

主模块应用winstonLogger配置

在应用的主模块中导入WinstonModule,并进行配置。

import {  Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import winstonLogger from './config/winston.logger';

@Module({
  imports: [
    // 注册日志记录文件
    WinstonModule.forRoot({
      transports: winstonLogger.transports,
      format: winstonLogger.format,
      defaultMeta: winstonLogger.defaultMeta,
      exitOnError: false, // 防止意味退出
    }),
    // ...其他配置
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

创建全局日志中间件

为了监听整个系统的日志,需要建立全局中间件 middlewares/logger.middleware.ts:

import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import * as moment from 'moment';
import { NextFunction, Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  private logger = new Logger();
  use(req: Request, res: Response, next: NextFunction) {
    // 记录开始时间
    const start = Date.now();
    // 获取请求信息
    const { method, originalUrl, ip, httpVersion, headers } = req;

    // 获取响应信息
    const { statusCode } = res;

    res.on('finish', () => {
      // 记录结束时间
      const end = Date.now();
      // 计算时间差
      const duration = end - start;

      // 这里可以根据自己需要组装日志信息:[timestamp] [method] [url] HTTP/[httpVersion] [client IP] [status code] [response time]ms [user-agent]
      const logFormat = `${moment().valueOf()} ${method} ${originalUrl} HTTP/${httpVersion} ${ip} ${statusCode} ${duration}ms ${headers['user-agent']}`;

      // 根据状态码,进行日志类型区分
      if (statusCode >= 500) {
        this.logger.error(logFormat, originalUrl);
      } else if (statusCode >= 400) {
        this.logger.warn(logFormat, originalUrl);
      } else {
        this.logger.log(logFormat, originalUrl);
      }
    });

    next();
  }
}

在主模块配置该中间件,应用于所有路由,这样所有请求都会打印基础日志:

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { LoggerMiddleware } from './middlewares/logger.middleware';

@Module({
  //...
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

在 main.ts 中使用 useLogger 更换日志记录器,我们在使用的使用可以使用 NestJS 内置的 Logger 就行。

import { writeFileSync } from 'fs';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 注册全局接口参数验证
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
      stopAtFirstError: true,
      skipMissingProperties: false, // 确保所有的属性都被验证
    }),
  );
  // 注册全局过滤器
  app.useGlobalFilters(new HttpExceptionFilter());
  // 注册全局拦截器
  app.useGlobalInterceptors(new TransformInterceptor());

  const swaggerOptions = new DocumentBuilder()
    .setTitle('cms-project api')
    .setDescription('内容管理系统描述')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, swaggerOptions);
  SwaggerModule.setup('api-docs', app, document);

  // 将 Swagger 文档保存为 JSON 文件
  writeFileSync('./swagger.json', JSON.stringify(document, null, 2), 'utf8');

  // 更换日志记录器
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));

  await app.listen(8004);
}
bootstrap();

某个业务服务或控制器使用Logger

在 UserService 里使用

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class UserService {
  private logger = new Logger('UserService');

  async create(createUserDto: CreateUserDto) {
    this.logger.log('@@@@ 创建用户参数:', createUserDto);
    // ...业务逻辑
  }

  async findAll(query: ListUserDto) {
    try {
      // ...业务逻辑
    } catch (error) {
      this.logger.error('@@@@ 账号列表查询失败:', error);
      throw new InternalServerErrorException('账号列表查询失败');
    }  
  }
}

在 UserController 里也是一样的

import { Logger } from '@nestjs/common';
// ...其他代码
export class UserController {
  private logger = new Logger('UserController');
  // ...其他代码
  removeUsers(@Body('ids') ids: number[]) {
    this.logger.log('@@@ removeUsers ids:', ids);
    return this.userService.softDeleteUsers(ids);
  }
  // ...其他代码
}

日志记录配置成功效果

因为配置了中间件,全局会记录相应的日志

logger-global

我们手动建立的日志也会打印,接口报错会根据颜色提示

logger-error

这是我们项目日志存放的地方,当然服务器日志通常存储在特定的目录中,上线的时候会配置好

logger-location

集成 nestjs-prometheus 进行服务监控

使用 nestjs-prometheus 来集成 Prometheus 监控系统,监控应用的各种指标,如 HTTP 请求时间、错误率、内存使用等。还可以使用 Grafana 来可视化 Prometheus 收集的指标数据,构建监控大盘,实现实时性能监控和报警机制。

安装prometheus

使用 Homebrew 安装 Prometheus。

# 安装
brew install prometheus

# 启动服务
brew services start prometheus

# 二进制方式启动服务
prometheus --config.file=/usr/local/etc/prometheus.yml

启动服务后可以访问默认的 9090 端口查看:

prometheus

安装 nestjs-prometheus 依赖

npm install --save @willsoto/nestjs-prometheus prom-client

配置 @willsoto/nestjs-prometheus

在应用的主模块(app.module.ts)中注册并配置PrometheusModule。

// ...其他导入
import { PrometheusModule } from '@willsoto/nestjs-prometheus';

@Module({
  imports: [
    // 默认情况下,这将注册一个 /metrics 端点,该端点将返回默认的监控指标。
    PrometheusModule.register(),
    // ...其他模块
  ],
})
export class AppModule {}

创建 MetricsService 提供给其他模块

在 common 目录下新建 metrics/metrics.service.ts,用于给到业务模块去使用:

import { Injectable } from '@nestjs/common';
import { Counter } from 'prom-client';

@Injectable()
export class MetricsService {
  private requestCounter: Counter;

  constructor() {
    this.requestCounter = new Counter({
      name: 'http_request_total',
      help: '统计http请求数量',
      labelNames: ['method', 'path'],
    });
  }

  incrementRequestCount(method: string, path: string) {
    this.requestCounter.labels(method, path).inc();
  }
}

在 UserModule 把作为 providers:

// ...其他代码
import { UserService } from './user.service';
import { MetricsService } from 'src/common/metrics/metrics.service';

@Module({
  // ...其他代码
  providers: [UserService, MetricsService],
})
export class UserModule {}

在 UserController 里使用:

import { MetricsService } from 'src/common/metrics/metrics.service';
// ...其他代码
export class UserController {
  constructor(
    private readonly metricsService: MetricsService,
  ) {}
  // ...其他代码
  create(@Body() createUserDto: CreateUserDto) {
    this.metricsService.incrementRequestCount('Post', '/user');
    return this.userService.create(createUserDto);
  }
}

配置成功效果

访问 http://localhost:8004/metrics 效果如下(8004端口是我自定义的,你们看下main.ts 里的 port)

metrics-success

服务可视化监控Grafana

Grafana下载安装

可以通过官网、docker、homebrew去安装,我mac电脑就选择从 homebrew 安装:

brew update
brew install grafana

启动Grafana

一般使用brew的命令直接启动:

brew services start grafana
# 重启
brew services restart grafana
# 停止
brew services stop grafana

但我现在电脑的 homebrew 太老了,brew update 之后也不行,只能直接二进制命令行操作了:

grafana-server --config=/usr/local/etc/grafana/grafana.ini --homepath /usr/local/share/grafana cfg:default.paths.logs=/usr/local/var/log/grafana cfg:default.paths.data=/usr/local/var/lib/grafana cfg:default.paths.plugins=/usr/local/var/lib/grafana/plugins

# 查找Grafana进程
ps aux | grep grafana-server

# 使用kill命令终止进程,其中<pid>是Grafana进程的ID
kill <pid>

打开浏览器,输入 http://localhost:3000 ,访问 Grafana 的 Web 界面。默认用户名和密码为 admin/admin。

grafana

配置数据源

进入 Grafana 的 Connections > Data Sources,添加一个新的 Prometheus 数据源,并指定 Prometheus 服务的 URL 地址,默认是 http://localhost:9090

grafana-add-datasource

设置页面如下:

grafana-datasource-set

save & test 成功后跳回列表页:

grafana-datasource

在 Dashboards 里新建工作台,用于后续可视化查看数据

grafana-dashboard-add

选择数据源

grafana-select-datasource

配置图表盘

grafana-panel

保存成功

grafana-dashboard-save

身份验证

使用 @nestjs/jwt、passport-jwt 和 @nestjs/passport创建 JWT 的身份验证服务,使用 AuthGuard 创建一个守卫保护路由。

安装jwt相关依赖

npm install @nestjs/jwt passport-jwt @nestjs/passport

创建AuthService

新建 src/auth/auth.service.ts 文件,编写 AuthService 类来提供服务,主要实现:

  • validateUser:查询数据库验证用户的账号和密码是否正确,返回用户信息
  • createToken:生成 Token
  • login:拼接 Token 给到前端
// src/auth/auth.service.ts
import { Injectable, Logger, Req, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from 'src/user/entities/user.entity';
import { removeUserData } from 'src/utils';
import { Repository } from 'typeorm';

@Injectable()
export class AuthService {
  private logger = new Logger('AuthService');

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(account: string, password: string): Promise<User | null> {
    // 查询数据库来验证用户
    const user: User = await this.userRepository.findOne({
      where: { account, isDeleted: 0 },
    });
    // 如果有用户再验证密码
    if (
      user &&
      user.account === account &&
      (await user.validatePassword(password))
    ) {
      delete user.passwordHash;
      delete user.isDeleted;
      return user;
    }
    return null;
  }

  // 生成 Token
  async createToken(data) {
    return await this.jwtService.signAsync(data);
  }

  async login(user: any) {
    const payload = {
      account: user.account,
      userId: user.id,
      roleId: user.roleId,
      roleType: user.roleType,
      roleWeight: user.roleWeight,
    };
    const token = await this.createToken(payload);
    return {
      token,
    };
  }

  // 查询当前账户信息
  async queryCurrentUser(@Req() req) {
    try {
      if (req?.user?.userId) {
        const user = await this.userRepository.findOneBy({
          id: req.user.userId,
        });

        return removeUserData(user);
      }
    } catch (error) {
      this.logger.error('@@@@ 当前登录信息过期,请重新登录:', error);
      throw new UnauthorizedException('当前登录信息过期,请重新登录');
    }
  }
}

新建AuthController

新建 src/auth/auth.controller.ts 文件,编写 AuthController 类给前端提供接口,主要实现:

  • login:帐号登录,调用之前写的服务先进行用户账号密码验证,然后进行 token 获取,最后拼接信息给到前端
  • logout:帐号登出,也就是把 cookie 中的 token 设置过期就行
  • 查询当前账户信息:通过token查询当前登录账户信息
import {
  Controller,
  Post,
  Body,
  BadRequestException,
  Req,
  Response,
} from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';

@Controller('auth')
@ApiTags('身份验证')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('login')
  @ApiOperation({
    summary: '帐号登录',
    description: '根据帐号和密码登录',
  })
  @ApiBody({ type: LoginDto })
  @ApiResponse({ status: 200, description: '帐号登录成功' })
  async login(@Body() loginDto: LoginDto) {
    const user = await this.authService.validateUser(
      loginDto.account,
      loginDto.password,
    );

    if (!user) {
      throw new BadRequestException('帐号或密码不存在');
    }

    try {
      return await this.authService.login(user);
    } catch (err) {
      throw new BadRequestException('登录失败');
    }
  }

  @Post('logout')
  @ApiOperation({
    summary: '帐号登出',
    description: '帐号登出',
  })
  @ApiResponse({ status: 200, description: '帐号登出成功' })
  logout(@Req() req, @Response() res): void {
    // 清除cookie中的jwt
    res.cookie('jwt', '', { httpOnly: true, expires: new Date(0) });
    // 如果是localStorage存的token需要前端自己删除
    res.status(200).send({
      code: 200,
      msg: 'success',
      result: { message: '登出成功' },
    });
  }

  @Get('queryCurrentUser')
  @ApiOperation({
    summary: '查询当前账户信息',
    description: '通过token查询当前登录账户信息',
  })
  @ApiResponse({ status: 200, description: '账户信息查询成功' })
  queryCurrentUser(@Req() req) {
    return this.authService.queryCurrentUser(req);
  }
}

新建LoginDto

新建 src/auth/dto/login.dto.ts 文件:

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

export class LoginDto {
  @IsEmail({}, { message: '账号必须是邮箱格式' })
  @IsString({ message: '账号必须是字符串' })
  @IsNotEmpty({ message: '账号不能为空' })
  @ApiProperty({
    description: '账号(邮箱格式)',
    example: 'niunai@niunai.com',
  })
  account: string;

  @Matches(/^(?=.*[a-zA-Z])(?=.*\d).{8,16}$/, {
    message: '请输入8-16位数字+字母的密码',
  })
  @IsString({ message: '密码必须是字符串' })
  @IsNotEmpty({ message: '密码不能为空' })
  @ApiProperty({ description: '密码', example: 'admin123' })
  password: string;
}

新建AuthModule

新建 src/auth/auth.module.ts 文件:

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
import { User } from 'src/user/entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtAuthGuard } from './jwt-auth.guard';

const jwtModule = JwtModule.register({
  global: true,
  secret: 'niunai', // 在生产环境中使用更安全的密钥管理方式
  signOptions: { expiresIn: '24h' }, // 设置 token 的过期时间
});

@Module({
  imports: [TypeOrmModule.forFeature([User]), PassportModule, jwtModule],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy, JwtAuthGuard],
  exports: [AuthService, JwtAuthGuard], // 如果需要在其他模块中使用 AuthService
})
export class AuthModule {}

配置JWT策略

新建 src/auth/jwt.strategy.ts 文件,确保JWT策略使用 AuthService 来验证用户

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'niunai', // 与 JwtModule.register 中的 secret 匹配
    });
  }

  async validate(payload: any) {
    // 这里返回的数据会被注入到 @Req.user 对象内
    return { 
      userId: payload.userId,
      account: payload.account,
      roleId: payload.roleId,
      roleType: payload.roleType,
      roleWeight: payload.roleWeight,
    };
  }
}

新建JwtAuthGuard路由守卫

新建 src/auth/jwt-auth.guard.ts 文件,可以用于全局、某个controller、某个接口进行jwt校验

import {
  Injectable,
  ExecutionContext,
  Logger,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtService } from '@nestjs/jwt';
import { excludedRoutes } from './excluded.routes';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  private logger = new Logger('JwtAuthGuard');

  constructor(private readonly jwtService: JwtService) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<any> {
    const request = context.switchToHttp().getRequest();
    const { path, method } = request;

    // 检查当前请求是否在排除列表中
    const isExcluded = excludedRoutes.some(
      (route) => route.path === path && route.method === method,
    );

    if (isExcluded) {
      return true; // 如果请求被排除,则不进行 JWT 验证
    }

    const token = request.get('Authorization');

    this.logger.log('@@@@ token:', token);

    if (!token) {
      throw new UnauthorizedException('没有token,请先登录');
    }

    // Bearer token 格式
    const bearerToken = token.split(' ');

    if (bearerToken.length < 2 || bearerToken[0] !== 'Bearer') {
      throw new UnauthorizedException('token格式或类型不正确');
    }

    try {
      const decoded = await this.jwtService.verifyAsync(bearerToken[1]);
      // 令牌验证成功
      if (decoded) {
        request.user = decoded;
        return this.activate(context);
      }
    } catch (error) {
      throw new UnauthorizedException('没有权限,请登录');
    }
  }

  async activate(context: ExecutionContext): Promise<boolean> {
    return super.canActivate(context) as Promise<boolean>;
  }
}

其中 excludedRoutes 就是一个排除路由守卫的数组:

export const excludedRoutes = [{ path: '/auth/login', method: 'POST' }];

全局配置JWT路由守卫

我这边全局配置了 JWT 路由守卫,其实也可以某个 controller、某个接口进行 jwt 验证,但我的应用比较小,也不会有多个 jwt,就简单点使用了全局配置,至于排除就在路由里增加了一个 excludedRoutes 去排除就行了。

// ...其他代码
import { JwtAuthGuard } from './auth/jwt-auth.guard';

async function bootstrap() {
  // ...其他代码
  app.useGlobalGuards(app.get(JwtAuthGuard));

  await app.listen(8004);
}
bootstrap();

权限管理功能接口完成

目前完成了3个用户相关的接口

auth-module

请求响应加密封装

后端 NestJS 使用 crypto 来实现数据加解密,使用 RSA 非对称加密算法和 AES 的对称加密算法进行混合加密,RSA 公钥对 AES 密钥进行加密,AES 对数据进行加密。

同时前端对接口的请求方法进行封装,提供 url 、params、config 参数给到调用方。

后端加密流程:

  • 生成 RSA 密钥对:使用 OpenSSL 或其他工具生成 RSA 公钥和私钥。
  • 加密 AES 密钥:使用 RSA 公钥加密 AES 密钥。
  • 加密数据:使用 AES 对称加密算法加密实际的数据。
  • 发送加密信息:将加密后的 AES 密钥和加密后的数据发送给前端。

前端请求封装:

  • 获取公钥:前端需要从后端获取 RSA 公钥。
  • 加密AES密钥:前端使用获取到的公钥加密 AES 密钥。
  • 加密数据:前端使用加密后的 AES 密钥加密请求数据。
  • 发送请求:前端将加密后的请求数据发送到后端。

目前这块我还没有做,后面有空补上

安全性

前后端都要做一些基础的安全性校验和拦截:

  • 数据验证:对输入的数据进行必要的格式、类型和长度校验,避免 SQL 注入、XSS 等攻击。
  • 防范跨站脚本攻击 (XSS):确保用户生成的内容在输出前进行转义处理,NestJS 内置的管道可以帮助自动转义输出。
  • 防止跨站请求伪造 (CSRF):使用 CSRF 保护模块,为每个请求生成唯一的令牌,并将其与用户会话关联。

公共模块开发

新建 common 文件夹作为公共的模块

Excel文件导出

在 common 目录下新建 excel目录来存放控制器、服务和实例,用于给其他模块调用excel的导出能力

新建 excel/excel.service.ts:

import * as XLSX from 'xlsx';
import { Injectable } from '@nestjs/common';

@Injectable()
export class ExcelService {
  /**
   * 将数据列表导出为 Excel 文件
   * @param data 数据列表
   * @param fileName 文件名
   * @returns 文件缓冲区
   */
  exportAsExcelFile(data: any[]): Buffer {
    const worksheet: XLSX.WorkSheet = XLSX.utils.json_to_sheet(data);
    const workbook: XLSX.WorkBook = {
      Sheets: { data: worksheet },
      SheetNames: ['data'],
    };
    const excelBuffer: any = XLSX.write(workbook, {
      bookType: 'xlsx',
      type: 'buffer',
    });
    return excelBuffer;
  }
}

新建 excel/excel.controller.ts:

import {
  Controller,
  Res,
  HttpStatus,
  Body,
  Post,
  BadRequestException,
} from '@nestjs/common';
import { ExcelService } from './excel.service';
import { ExcelDto } from './dto/excel.dto';
import { ApiBody, ApiTags } from '@nestjs/swagger';

@Controller('export')
@ApiTags('公共Excel导出')
export class ExcelController {
  constructor(private readonly excelService: ExcelService) {}

  @Post('/exportExcel')
  @ApiBody({ type: ExcelDto })
  exportExcel(@Body() body: ExcelDto, @Res() res) {
    try {
      // 导出为 Excel 文件
      const buffer = this.excelService.exportAsExcelFile(body.data);

      // 设置响应头
      res.setHeader(
        'Content-Type',
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      );
      res.setHeader(
        'Content-Disposition',
        `attachment; filename=${encodeURIComponent(body.filename)}`,
      );

      // 发送文件
      res.status(HttpStatus.OK).send(buffer);
    } catch {
      throw new BadRequestException('Excel公共导出接口调用失败');
    }
  }
}

新建 excel/excel.module.ts:

import { Module } from '@nestjs/common';
import { ExcelController } from './excel.controller';
import { ExcelService } from './excel.service';

@Module({
  controllers: [ExcelController],
  providers: [ExcelService],
  exports: [ExcelService],
})
export class ExcelModule {}

新建实例 excel/dto/excel.dto.ts:

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

export class ExcelDto {
  @ApiProperty({
    description: 'excel名称',
    example: '用户表',
  })
  @IsNotEmpty({ message: 'filename必填' })
  filename: string;

  @ApiProperty({
    description: 'excel数据',
    example: [
      { id: 1, name: 'Alice', age: 25 },
      { id: 2, name: 'Bob', age: 30 },
      { id: 3, name: 'Charlie', age: 35 },
    ],
  })
  @IsNotEmpty({ message: 'data必填' })
  data: any[];
}

工具函数开发

新建 src/utils/index.ts 文件,用于在后续业务开发中对一些可复用的地方进行封装,目前主要是对一些表的用户信息设置进行封装:

import * as moment from 'moment';

// 设置创建用户信息
export const setCreatedUser = (req: any, table: any) => {
  const user = req?.user;
  table.createdBy = user?.userId;
  table.createdByAccount = user?.account;
  table.updatedBy = user?.userId;
  table.updatedByAccount = user?.account;

  return table;
};

// 设置更新用户信息
export const setUpdatedUser = (req: any, table: any) => {
  const user = req?.user;
  table.updatedBy = user?.userId;
  table.updatedByAccount = user?.account;

  return table;
};

// 设置删除用户信息
export const setDeletedUser = (req: any, table: any) => {
  const user = req?.user;
  table.updatedBy = user?.userId;
  table.updatedByAccount = user?.account;
  table.isDeleted = 1;

  return table;
};

// 移除一些非必要的数据
export const removeUnnecessaryData = (data: any) => {
  return data.map((item) => {
    const obj = {
      ...item,
      createdTime: item.createdTime
        ? moment(item.createdTime).format('YYYY-MM-DD HH:mm:ss')
        : '',
      updatedTime: item.updatedTime
        ? moment(item.updatedTime).format('YYYY-MM-DD HH:mm:ss')
        : '',
    };
    delete obj.passwordHash;
    delete obj.isDeleted;
    return obj;
  });
};

// 移除一些用户信息
export const removeUserData = (data: any) => {
  const filterData = data;
  delete filterData.createdBy;
  delete filterData.createdByAccount;
  delete filterData.createdTime;
  delete filterData.updatedBy;
  delete filterData.updatedByAccount;
  delete filterData.updatedTime;
  delete filterData.passwordHash;
  delete filterData.isDeleted;

  return filterData;
};

实战合集地址

仓库地址