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
首先新建项目:
然后我们看看项目 UI,支持导入 swagger api
文档的,后面会介绍下怎么导入。
新建数据库链接
我们先进行项目数据库的安装,数据库使用大众最熟悉的 mysql
,关系映射选择 typeorm
。
安装依赖
npm install --save @nestjs/typeorm typeorm mysql2
新建数据库
新建一个数据库连接:
使用 navicat
新建一个数据库连接:
连接右键新建数据库:
新建数据库配置文件:
在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
看看:
上图只能看到没有分组的接口,并且没有接口描述,也没有参数定义,但 @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
的导入数据功能:
导入成功后,接口如下,发起请求试验下:
集成 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);
}
// ...其他代码
}
日志记录配置成功效果
因为配置了中间件,全局会记录相应的日志
我们手动建立的日志也会打印,接口报错会根据颜色提示
这是我们项目日志存放的地方,当然服务器日志通常存储在特定的目录中,上线的时候会配置好
集成 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
端口查看:
安装 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)
服务可视化监控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
的 Connections
> Data Sources
,添加一个新的 Prometheus
数据源,并指定 Prometheus
服务的 URL
地址,默认是 http://localhost:9090
设置页面如下:
save & test
成功后跳回列表页:
在 Dashboards
里新建工作台,用于后续可视化查看数据
选择数据源
配置图表盘
保存成功
身份验证
使用 @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个用户相关的接口
请求响应加密封装
后端 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;
};
实战合集地址
- NestJS实战-产品需求规划
- NestJS实战-前端搭建
- NestJS实战-后端开发-全局配置
- NestJS实战-后端开发-用户及权限模块
- NestJS实战-后端开发-文章专栏功能模块
- NestJS实战-前后端联调
- NestJS实战-系统总结