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

1,411 阅读16分钟

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

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

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

引言

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

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

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

技术栈介绍

  • 主框架NestJSTypeScriptRxjs
  • 身份验证JWT
  • API接口文档Swagger
  • 接口调试工具Apifox
  • 单元测试jest
  • 数据库MySQLtypeormmysql2
  • 数据库可视化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项目

开发项目我们需要调试接口,那主流的就是 postmanapifox,我个人推荐 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 使用 useGlobalFiltersuseGlobalInterceptors 使用全局过滤器和拦截器:

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 进行接口调试,那我们就要把 swaggerapi 文档导入 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 ,访问 GrafanaWeb 界面。默认用户名和密码为 admin/admin

grafana

配置数据源

进入 GrafanaConnections > 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/jwtpassport-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 对数据进行加密。

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

后端加密流程

  • 生成 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;
};

实战合集地址

仓库地址