Nest实战 - 员工模块
前言
本系列主要通过实现一个后台管理系统作为切入点,帮助掘友熟悉nest的开发套路
本系列作为前后端分离项目,主要讲解nest相关知识,前端项目知识一笔带过
完整的示例代码在最后
本章属于实战系列第二章,主要讲解员工模块相关内容,包括
-
头像上传
本地文件上传和阿里oss文件上传 -
头像识别
百度AI识别图像,部分数据完成自动填充 -
typeORM 公共字段填充
@BeforeInsert和@BeforeUpdate的使用和注意事项 -
扩展
process.env的类型 -
数据库相关知识
InLike分页 -
Restful风格的代码开发 -
密码
md5处理
技术栈
- node:19.x
- 后端:nest + mysql
- 前端:react + redux + antd
规则
- 本系列完全遵循
RestFul风格进行开发,即GetPostPutDelete - 本系列的调用链
controller-->service-->三方服务|数据库 - 本系列的三方服务被
消费时,统一注入到nest的IOC中,统一风格;其他函数的使用直接调用即可
员工模块-分页
介绍
- 在本人目前的开发中,分页功能是最常见的功能
- 好处
- 服务端: 提升性能,减小内存的压力,查询效率高
- 客户端: 页面渲染快,不然几万个DOM同时
渲染,更改,页面直接GG
页面预览
开发- controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '分页', }) @Get('page') page( @Query('page') page: number, @Query('pageSize') pageSize: number, @Query('name') name?: string, ) { return this.employeeService.page(page, pageSize, name); }
说明
- 正常来说,
nest接收到的所有基本类型的参数类型都是string,上一章我们在AppModule中加入了全局管道验证,并设置了属性转换功能transform为true,这样nest内部就可以通过ts的类型自动转换了
喏,就是这块做的自动转换 @Query装饰器可以拿到url问号(?)后边的参数值page当前页数,最少传1pageSize每页多少条name用户名,做模糊查询用- 接下来就把接收到的参数传入
EmployeeService的page方法中
开发- service
代码
- 打开
EmployeeService,加入分页代码/** * * @param page 页数 * @param pageSize 每页多少条 * @param name 用户名 * @returns 分页 */ async page(page: number, pageSize: number, name = '') { const [employeeList, total] = await this.employeeRepository.findAndCount({ where: { name: Like(`%${name}%`), }, skip: (page - 1) * pageSize, take: pageSize, }); return new BasePage(page, pageSize, total, employeeList); }
说明
- 上一章中注入了
employeeRepository,所以这里可以直接调用
employeeRepository.findAndCount方法返回一个promise数组,数组的第0项是查询到的对象数组,第1项是符合当前条件的总条数name字段默认空字符串 '',不然会被当作undefind处理where中的Like等同于sql语句中的Like,Like(%${name}%)表示值左右模糊查询skip表示跳过多少条数据take表示查询多少条数据,即(每页多少条)skip和take对应sql中的LIMIT关键字- 执行查询操作时,
sql语句如下所示
- 共执行了俩次
sql语句第一句查询列表信息,第二句查询总条数 - 注意在
第一条sql语句中,有排序查询
orderBy关键字,是通过添加到实体类Employee文件Entity装饰器中实现的,这样只要执行select语句的时候,在没有手动添加updateTime排序规则的时候,就总会按照updateTime的倒序进行排序
page方法中最后返回了类BasePage,BasePage只做了一件事情,就是对分页数据进行封装
开发工具类 - 封装分页数据
代码
- 新建
src/common/database/pageInfo.ts,写入/** * 分页数据封装 */ export class BasePage<T> { constructor( private page: number, private pageSize: number, private total: number, private records: T[], ) {} }
说明
- 分页数据每个模块都会使用,故进行封装,统一调用即可
前端开发 - 拦截器处理
说明
- 我们在登陆接口进行了
jwt验证,故没有添加@isPublic()装饰器的接口都需要进行token验证 - 我们会在登陆页面点击
登陆的时候,把员工数据写入redux中
- 前端主要注意
拦截器处理,其他的正常开发即可
前端分页 - 效果截图
- 全量搜索

- 模糊搜索

公共模块 - 文件上传/预览
介绍
- 文件上传下载功能比较通用,且为了遵循软件的
单一原则,所以把它抽离成基础公共模块 - 如果所示,我们会在
新增修改时使用文件上传 预览功能
本地文件上传
安装
- 安装
@types/multer提供ts类型支持yarn add @types/multer
代码
-
终端中执行
nest g module base nest g service base nest g controller base -
会生成以下结构文件,当然手动创建也可以,
注意⚠️手动创建的模块需要手动引入到AppModule中
-
打开
src/base/base.module.ts,写入import { Module } from '@nestjs/common'; import { MulterModule } from '@nestjs/platform-express'; import { BaseController } from './base.controller'; import { BaseService } from './base.service'; import { diskStorage } from 'multer'; import { checkDirAndCreate } from 'src/common/utils'; import { webcrypto } from 'crypto'; @Module({ imports: [ MulterModule.register({ storage: diskStorage({ destination(req, file, callback) { const filePath = `public/uploads/${file.mimetype.split('/')[0]}/`; checkDirAndCreate(filePath); return callback(null, `./${filePath}`); }, filename(req, file, callback) { console.log(req.file); const suffix = file.originalname.substring( file.originalname.lastIndexOf('.'), ); const fileName = Date.now() + '-' + webcrypto.randomUUID() + suffix; callback(null, fileName); }, }), fileFilter(req, file, callback) { return callback(null, true); }, }), ], controllers: [BaseController], providers: [BaseService], }) export class BaseModule {} -
新建
src/common/utils/index.ts,写入import { existsSync, mkdirSync } from 'fs'; /** * 创建文件夹 * @param filePath 文件路径 */ export const checkDirAndCreate = (filePath: string) => { const pathArr = filePath.split('/'); let checkPath = '.'; for (let i = 0; i < pathArr.length; i++) { checkPath += `/${pathArr[i]}`; if (!existsSync(checkPath)) { mkdirSync(checkPath); } } }; /** * * @param src 文件地址 * @returns 获取文件后缀名 */ export const getFileSuffix = (src: string) => { return src.substring(src.lastIndexOf('.')); }; /** * 类赋值-合并 * @param oldVal 旧值 * @param newVal 新值 */ export function classAssign<T extends object>(oldVal: T, newVal: T): T { for (const k in newVal) { oldVal[k] = newVal[k]; } return oldVal; } -
新建
src/base/base.controller.ts,写入import { Controller, Headers, Post, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { isPublic } from 'src/auth/constants'; @ApiTags('公共模块') @Controller('base') export class BaseController { @ApiOperation({ summary: '上传本地', }) @isPublic() @Post('/uploadLocal') @UseInterceptors(FileInterceptor('file')) uploadLocal( @UploadedFile() file: Express.Multer.File, @Headers('host') host: string, ) { // 如果是 localhost 就加上http:// if (!host.includes('://')) { host = `http://${host}`; } return `${host}/${file.path}`; } }
说明
- 文件上传请求方式一定为
post - 前端需要在
headers中设置"Content-Type": "multipart/form-data"来传输二进制文件 - 发起
url为v1/base/uploadLocal请求方式为post的时候,首先会经过@isPublic()装饰器去放行(免认证),然后进入拦截器,传入FileInterceptor('file'),这时就会进入MulterModule.register方法中,也就是这块
- 接着会把文件写到
public文件夹下
- 最后会走到
BaseController中的uploadLocal方法中,接着我们通过装饰器@Headers('host')拿到headers中的host,对file.path做拼接后即可返回 - 接着,我们用
postman测试,发现数据已经被成功返回
开启静态文件预览
- 问题
- 拿到后端返回的图片地址发现无法预览,会被
nest的拦截器所拦截
- 拿到后端返回的图片地址发现无法预览,会被
- 打开
src/main.ts,写入// 开启静态文件预览 app.useStaticAssets('public', { prefix: '/public/', }); - 保存文件,刷新浏览器,图片出来了,
完美,收工
阿里oss文件上传
介绍
- 阿里云对象存储OSS(Object Storage Service)是一款海量、安全、低成本、高可靠的云存储服务,提供99.9999999999%(12个9)的数据持久性,99.995%的数据可用性。多种存储类型供选择,全面优化存储成本。
前置准备 - 购买对象存储OSS
- 第一步,打开官网,登录阿里云,账号和扫码都可以

- 第二步 选择对象存储OSS

- 第三步,没有购买过OSS的,选择立即购买

- 第四步,买完之后选择管理控制台

- 第五步,刚开始进入,
Bucket列表为空,创建Bucket即可
- 第六步,填入
Bucket名称,选择资源组,读写权限设置公共读写,点击确定即可

- 第七步,确定后会跳转到这个页面,点击返回即可回到列表页面

- 第八步,打开帮助文档

- 第九步,找到
Nodejs版本的文件上传文档就可以愉快的阅读了
- 第十步,OSS构造函数需要的四个参数
regionaccessKeyIdaccessKeySecretbucket,在这里可以找到 - 回到列表页,点
Bucket名称
Bucket名称就是你的bucketoss-cn-hangzhou就是你的region
- 第十一步,点击
accessKey管理
- 第十二步,选哪个都行,区别就是子用户权限更小些

- 第十三步,点击
查看sercet,发送手机验证码,即可获取到accessKeyIdaccessKeySecret

安装
- 安装
ali-ossyarn add ali-oss
代码
- 打开
根目录/.config/.dev.yml,将配置信息写入配置文件# 阿里 ALI: accessKeyId: accessKeyId accessKeySecret: accessKeySecret oss: region: oss-cn-hangzhou bucket: nest-study-backend

- 新建
src/common/ALI/oss.module.ts和src/common/ALI/oss.service.ts - 分别写入
import { Module } from '@nestjs/common'; import { AliOssService } from './oss.service'; @Module({ providers: [AliOssService], exports: [AliOssService], }) export class AliOssModule {}/* eslint-disable @typescript-eslint/no-var-requires */ import { Injectable } from '@nestjs/common'; import { getConfig } from '../utils/ymlConfig'; import { CustomException } from 'src/common/exceptions/custom.exception'; import { webcrypto } from 'crypto'; import * as path from 'path'; import type { PutObjectResult } from 'ali-oss'; const OSS = require('ali-oss'); const moment = require('moment'); /** * 阿里 oss 服务 */ @Injectable() export class AliOssService { // 通过静态方法获取app实例 static getOssClient() { const { accessKeyId, accessKeySecret, oss } = getConfig('ALI'); return new OSS({ ...oss, accessKeyId, accessKeySecret, }); } // 获取oss路径 static getOssPath(suffix: string) { const ymd: string = moment().format('YYYY/MM/DD'); // 格式 2023/01/17/uuid return `${ymd}/${webcrypto.randomUUID()}${suffix}`; } /** * * @param url * @param suffix * @returns 上传buffer 到 oss */ async putLocal(url: string, suffix: string) { try { return AliOssService.getOssClient().put( AliOssService.getOssPath(suffix), path.normalize(url), ); } catch (error) { throw new CustomException(error); } } /** * * @param buffer * @param suffix * @returns 上传buffer 到 oss */ async putBuffer(buffer: Buffer, suffix: string): Promise<PutObjectResult> { try { return await AliOssService.getOssClient().put( AliOssService.getOssPath(suffix), buffer, ); } catch (error) { throw new CustomException(error); } } /** * 删除资源 * @param path 资源文件路径 */ async deleteFile(path: string) { try { await AliOssService.getOssClient().delete(path); } catch (error) { throw new CustomException(error); } } } - 修改
src/base/base.module.ts,走oss上传,要注掉storage,不然会走本地存储,同时file.buffer也拿不到import { Module } from '@nestjs/common'; import { MulterModule } from '@nestjs/platform-express'; import { BaseController } from './base.controller'; import { BaseService } from './base.service'; import { diskStorage } from 'multer'; import { checkDirAndCreate } from 'src/common/utils'; import { webcrypto } from 'crypto'; import { AliOssModule } from 'src/common/ALI/oss.module'; import { BaiduFaceModule } from 'src/common/BAIDU/face.module'; @Module({ imports: [ AliOssModule, BaiduFaceModule, MulterModule.register({ // storage: diskStorage({ // destination(req, file, callback) { // const filePath = `public/uploads/${file.mimetype.split('/')[0]}/`; // checkDirAndCreate(filePath); // return callback(null, `./${filePath}`); // }, // filename(req, file, callback) { // console.log(req.file); // const suffix = file.originalname.substring( // file.originalname.lastIndexOf('.'), // ); // const fileName = Date.now() + '-' + webcrypto.randomUUID() + suffix; // callback(null, fileName); // }, // }), fileFilter(req, file, callback) { return callback(null, true); }, }), ], controllers: [BaseController], providers: [BaseService], }) export class BaseModule {} - 修改
src/base/base.controller.ts增加uploadOSS接口import { Controller, Headers, Post, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { isPublic } from 'src/auth/constants'; import { getFileSuffix } from '../common/utils/index'; import { AliOssService } from '../common/ALI/oss.service'; @ApiTags('公共模块') @Controller('base') export class BaseController { constructor(private readonly aliOssService: AliOssService) {} @ApiOperation({ summary: '上传本地', }) @isPublic() @Post('/uploadLocal') @UseInterceptors(FileInterceptor('file')) uploadLocal( @UploadedFile() file: Express.Multer.File, @Headers('host') host: string, ) { console.log(host, file); // 如果是 localhost 就加上http:// if (!host.includes('://')) { host = `http://${host}`; } return `${host}/${file.path}`; } @ApiOperation({ summary: '上传阿里OSS', }) @isPublic() @Post('/uploadOSS') @UseInterceptors(FileInterceptor('file')) async uploadOSS(@UploadedFile() file: Express.Multer.File) { // oss文件上传 const { url } = await this.aliOssService.putBuffer( file.buffer, getFileSuffix(file.originalname), ); return url; } }
测试
- 老规矩,打开
postman,输入localhost:3000/v1/base/uploadOSS,开始测试
- 进入阿里云后台,点击
Bucket,发现文件上传成功了
小结
- 俩种方式都可以实现文件的上传下载
- 具体情况看公司喜好,个人建议放到
OSS,安全而且按量收费也挺合适的 - 本次以阿里云为例做了演示,其他云产品自行参考,套路都一样
公共模块 - 百度人脸识别
目的
- 为了体现产品的智能化(
少的操作做多的事情) - 实现上传头像,
自动填充生日和性别
介绍
- 快速检测人脸并返回人脸框位置,输出人脸150个关键点坐标,准确识别多种属性信息
前置准备 - 购买人脸检测产品
- 第一步,打开官网,登陆百度AI,扫码和账号登录都可以

- 第二步,选择
人脸检测与属性分析
- 第三步,点击
立即使用
- 第三步,开通
人脸识别
- 第四步,根据需求,开通对应的功能即可,
注意,开通服务前,需要先进行充值
- 第五步,订单没有疑问,直接下一步

- 第六步,开通成功后,返回
管理控制台即可
- 发现
人脸检测服务成功启用
- 第七步,查看
api文档
- 第八步,找到
NodeSDK,就可以愉快的阅读了
- 第九步,
APP_IDAPP_KEYSECRET_KEY可以在这里找到 - 账户ID就是
APP_ID
安全认证中可以拿到APP_KEY和SECRET_KEY
安装
- 安装
baidu-aip-sdkyarn add baidu-aip-sdk
代码
- 打开
根目录/.config/.dev.yml,将配置信息写入配置文件#百度 BAIDU: appId: appId accessKey: accessKey secretKey: secretKey

-
新建
src/common/BAIDU/face.module.ts和src/common/BAIDU/face.service.ts -
分别写入
import { Module } from '@nestjs/common'; import { BaiduFaceService } from './face.service'; @Module({ providers: [BaiduFaceService], exports: [BaiduFaceService], }) export class BaiduFaceModule {}/* eslint-disable @typescript-eslint/no-var-requires */ import { Injectable } from '@nestjs/common'; import { CustomException } from '../exceptions/custom.exception'; import { getConfig } from '../utils/ymlConfig'; const AipFaceClient = require('baidu-aip-sdk').face; export interface FaceInfo { error_code: number; error_msg: string; log_id: number; timestamp: number; cached: number; result: { face_num: number; face_list: { face_token: string; location: { left: number; top: number; width: number; height: number; rotation: number; }; face_probability: number; angle: { yaw: number; pitch: number; roll: number; }; age: number; gender: { type: 'male' | 'female'; probability: number; }; }[]; }; } /** * 百度人脸识别 */ @Injectable() export class BaiduFaceService { // 新建一个对象,建议只保存一个对象调用服务接口 static getFaceClient() { const { appId, accessKey, secretKey } = getConfig('BAIDU'); return new AipFaceClient(appId, accessKey, secretKey); } async getFaceInfo( imageUrl: string, imageType = 'URL', options = { face_field: 'age,gender', }, ) { try { const faceInfo: FaceInfo = await BaiduFaceService.getFaceClient().detect( imageUrl, imageType, options, ); return faceInfo; } catch (error) { throw new CustomException(error); } } } -
修改
src/base/base.controller.ts下uploadOSS方法,添加调用人脸识别代码@ApiOperation({ summary: '上传阿里OSS', }) @isPublic() @Post('/uploadOSS') @UseInterceptors(FileInterceptor('file')) async uploadOSS(@UploadedFile() file: Express.Multer.File) { // oss文件上传 const { url, name } = await this.aliOssService.putBuffer( file.buffer, getFileSuffix(file.originalname), ); // 执行人脸识别函数 const faceInfo = await this.baiduFaceService.getFaceInfo(url); // 如果人脸识别失败,删除阿里云存储的图片 if (faceInfo.error_code !== 0) { await this.aliOssService.deleteFile(name); // 返回人脸识别错误提示 throw new CustomException(faceInfo.error_msg); } // 返回阿里oss图片地址和人脸识别信息 return { url, ...faceInfo }; }
说明
BaiduFaceService中调用人脸识别的时候,options参数的face_field参数一定要显式的传入,否则不会返回相关属性
- 人脸识别失败的图片,直接删除即可,无效的文件会占用资源,毕竟
阿里OSS是按量收费的
测试
- 老规矩,打开
postman,输入localhost:3000/v1/base/uploadOSS,开始测试,数据成功返回,歪瑞古德
员工模块 - 新增
页面预览

开发 - controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '创建员工', }) @Post() create(@Body() employee: Employee) { employee.password = md5('123456'); return this.employeeService.create(employee); }
说明
@Body装饰器可以获取到post请求body中的数据- 创建初始密码,并对其进行
md5加密 - 将
employee传入service层,进行数据库交互
开发 - service
代码
- 打开
EmployeeService,加入以下代码/** * * @param employee Employee * @returns 创建员工 */ create(employee: Employee) { return this.employeeRepository.save(classAssign(new Employee(), employee)); } - 打开
src/types/index.d.ts,加入对process.dev的扩展代码import { Request } from 'express'; import { Employee } from '../employee/entities/employee.entity'; export type TIdAndUsername = 'id' | 'username'; declare module 'express' { interface Request { user: Pick<Employee, TIdAndUsername>; } } declare global { namespace NodeJS { interface ProcessEnv { RUNNING: string; id: Employee['id']; } } } - 打开
src/auth/strategy/jwt.strategy.ts,将employee.id添加到process.envimport { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { getConfig } from '../../common/utils/ymlConfig'; import { Employee } from '../../employee/entities/employee.entity'; import { TIdAndUsername } from '../../types/index'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ // 提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供token的标准方法 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // false 将JWT没有过期的责任委托给Passport模块 ignoreExpiration: false, // 密钥 secretOrKey: getConfig('JWT')['secret'], }); } // jwt验证 async validate( payload: Pick<Employee, TIdAndUsername> & { iat: number; exp: number }, ) { if (!process.env.id) { process.env.id = payload.id; } return { id: payload.id, username: payload.username, }; } }

说明
- 将传入的数据经过
classAssign包装后,存入到数据库 classAssign函数对值进行和合并处理
- 由于在
BaseEntity中添加了@BeforeInsert和@BeforeUpdate俩个装饰器,可以在执行insert和update之前做前置操作
- 这俩个
装饰器中,统一处理了createTimeupdateTimecreateUserupdateUser - 如果调用
employeeRepository.save的时候直接传入employee参数,这俩个前置装饰器时不会生效的,应为employee中没有insert和update方法 sql语句如下
- 状态
status在设计数据库的时候,默认填充0,即启用状态
员工模块 - 根据id查询
开发 - controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '根据ID查询', }) @Get('/:id') findOne(@Param('id') id: string) { return this.employeeService.findById(id); }
说明
@Param装饰器可以拿到路径上的参数/employee/1,即1,然后封装到id属性中- 将
id传入service层,调用数据库即可
开发 - service
代码
- 打开
EmployeeService,加入以下代码/** * * @param id id * @returns 根据ID查询 */ async findById(id: string) { const employee = await this.employeeRepository.findOneBy({ id }); if (!employee) { throw new CustomException('id不存在'); } return employee; }
说明
- 根据
id查询数据库,如果没查到数据直接抛出自定义异常信息 - 有查询数据,直接返回,前端做
数据回显
员工模块 - 更新员工
开发 - controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '更新', }) @Put() update(@Body() employee: Employee) { return this.employeeService.update(employee); }
说明
- 将前端传入的参数直接传入
service层即可
开发 - service
代码
- 打开
EmployeeService,加入以下代码/** * * @param employee * @returns 更新 */ async update(employee: Employee) { return !!( await this.employeeRepository.update( { id: employee.id }, classAssign(new Employee(), employee), ) ).affected; }
说明
- 没有黑魔法,将数据存入数据库即可,然后返回状态
true更新成功,false更新失败
员工模块 - 删除员工
开发 - controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '删除,支持批量操作', }) @Delete() del(@Query('ids') ids: string[]) { return this.employeeService.delete(ids); }
说明
- 前端会传入字符串
/ids=1,2,3这样的格式,由于我们前面添加了全局管道转换,nest会根据ts类型,进行自动转换 - 将转换后的数据传入
service层即可
开发 - service
代码
- 打开
EmployeeService,加入以下代码/** * * @param ids ids * @returns 删除 */ async delete(ids: string[]) { // 只能删除停用的账号 const count = await this.employeeRepository.countBy({ id: In(ids), status: 1, }); if (count > 0) { throw new CustomException('不能删除启用中的账号'); } return !!(await this.employeeRepository.delete({ id: In(ids) })).affected; }
说明
- 启用中的账号是不能删除的,可以通过
count进行查询 In等同sql中的IN关键字
员工模块 - 设置启用 - 禁用
开发 - controller
代码
- 打开
EmployeeController,加入以下代码@ApiOperation({ summary: '启用,禁用,支持批量操作', }) @Post('status/:status') setStatus(@Param('status') status: number, @Query('ids') ids: string[]) { return this.employeeService.setStatus(ids, status); }
说明
- 将接收到的
status和ids直接传入service层即可
开发 - service
代码
- 打开
EmployeeService,加入以下代码/** * * @param ids ids * @returns 设置员工状态 启用 - 禁用 */ async setStatus(ids: string[], status: number) { const employee = new Employee(); employee.status = status; return !!(await this.employeeRepository.update({ id: In(ids) }, employee)) .affected; }
说明
- 没有黑魔法,直接根据
id更改status即可
总结
- 到现在
nest部分的员工模块已经全部开发完成 - 前置工作比较复杂,需要考虑代码的
健壮性,以及阿里云和百度AI的账号产品服务开通 - 代码封装完成后,
CRUD其实就很简单了, - 最后就是需要多理解业务需求,根据业务去拆分、整合代码,尽量遵循
开闭原则和单一原则
写在最后
-
本章主要讲解员工模块,如有问题欢迎在评论区留言
-
nest代码已经放在 gitee demo/v4分支 -
对
mysql不熟悉的可以看下 前端玩转mysql和Nodejs连接Mysql 这俩篇文章
转载自:https://juejin.cn/post/7189823020058279996





