NestJS 项目实战(下)
概述
这个项目在半年前就已经做完了,为了方便出个教程,近期抽出时间来重新做一遍,下面直接开始
创建项目
- 上篇说过:有三种创建方式,这里使用 Cli 创建,执行下面命令
$ nest new nest-server
- 选择 npm
- 目录结构
- 请留意基础库的版本,然后安装启动(启动使用 --watch 修饰一下,可以监听代码改动)
配置环境
默认启动的是
3000
端口,为了工程化,我们把环境相关的配置提出去
- 根目录新建
.env
和.env.prod
两个文件,对应测试和线上的环境配置,提前把后面用到的配置都加好- .env
# 测试环境配置 # 数据库地址 DB_HOST=localhost # 数据库端口 DB_PORT=3306 # 数据库登录名 DB_USER=root # 数据库登录密码 DB_PASSWORD=rootpass # 数据库名字 DB_DATABASE=NestData # 认证标识 SECRET=keycode # Redis 域名 REDIS_HOST=127.0.0.1 # Redis 端口号 REDIS_PORT=6379 # Redis 数据库 REDIS_DB=0 # Redis 设置的密码 REDIS_PASSPORT=182
- .env.prod
# 生产环境配置 # 数据库地址 DB_HOST=192.168.xxx.xxx # 数据库端口 DB_PORT=3306 # 数据库登录名 DB_USER=root # 数据库登录密码 DB_PASSWORD=rootpass # 数据库名字 DB_DATABASE=NestData # 认证标识 SECRET=keycode # Redis 域名 REDIS_HOST=127.0.0.1 # Redis 端口号 REDIS_PORT=6379 # Redis 数据库 REDIS_DB=0 # Redis 设置的密码 REDIS_PASSPORT=182
- 新建目录文件
/config/env.ts
import * as fs from 'fs'
import * as path from 'path'
const isProd: boolean = process.env.NODE_ENV === 'production'
function parseEnv() {
const localEnv = path.resolve('.env')
const prodEnv = path.resolve('.env.prod')
if (!fs.existsSync(localEnv) && !fs.existsSync(prodEnv)) {
throw new Error('缺少环境配置文件')
}
const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv
return { path:filePath }
}
export default parseEnv()
- 新建目录文件
/src/constant/ServerListen.ts
,端口声明提出去
/**
* @desc server-listen 常量
*/
export const PORT = 3001; // 服务端口
export const IP = '172.16.1.xx'; // 自己的主机IP
main.ts
修改监听
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(PORT);
}
bootstrap();
安装 MySql
因为后面要做数据入库的操作,所以先装好
-
MySql 下载(没有Oracle账号的,用邮箱注册一下,然后就可以下载了)
选择好自己对应的版本
- Mac 用户下载完之后,启动位置在
系统偏好设置
面板,点进去,start server
就可以了
- 数据库不能可视化查看,所以需要下载一个mysql-workbench,安装好之后打开,就可以查看和操作数据库表了,新建连接需要对应我们的项目配置。
可以手动创建库表,或者代码中去用实体映射创建。
安装 Redis
后面需要用 Redis 做 token 存储等的缓存方案
-
Redis 命令参考
- 启动命令:
$ /opt/homebrew/opt/redis/bin/redis-server /opt/homebrew/etc/redis.conf
- 进入认证环境:
redis-cli -h 127.0.0.1 -p 6379 -a password
- 认证账户:
auth 182
- 查看当前存在的服务和 PID
ps axu|grep redis
- 强制退出 redis 服务:
sudo kill -9 18505
(18505 数字位对应上条命令输出的第二列字段) - 域名:
127.0.0.1
- 端口:
6379
- 密码:
182
-
启动完成后是这样的
接口测试
-
新建用户模块目录,先写个接口测试下流程
/src/feature/user/
- 控制器文件
user.controller.ts
import { UserService } from './user.service'; import { Controller, Get, Post, Req } from '@nestjs/common'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} @Post('mytest') async testFn(@Req() req) { return { code: 200, msg: '测试GET接口返回成功', }; } }
- 模块注册
user.module.ts
import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ imports: [], controllers: [UserController], providers: [UserService], exports: [UserService], }) export class UserModule {}
- 服务文件
user.service.ts
import { HttpException, Injectable } from '@nestjs/common'; @Injectable() export class UserService {}
- 以上内容创建完成之后接口是调用不到的,需要在
app.module.ts
中引入挂载
import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UserModule } from './feature/user/user.module'; @Module({ imports: [UserModule], // 每个接口模块都需要在这里引入 controllers: [AppController], providers: [AppService], }) export class AppModule {}
-
使用 ApiPost 测试返回值
- 但是如果控制器的函数调用了
service
,而service
拿数据时候出错,抛出了异常
import { UserService } from './user.service'; import { Controller, Get, Post, Req } from '@nestjs/common'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} @Post('mytest') async testFn(@Req() req) { return await this.userService.testService(''); } }
import { HttpException, Injectable } from '@nestjs/common'; @Injectable() export class UserService { async testService(post) { if (!post) { throw new HttpException('缺少参数', 200); } else { return { msg: '成功了', }; } } }
此时再看返回值,其实就有些失控了,所以需要用到下面要说的的过滤器 和 拦截器 👇🏻
- 控制器文件
过滤器
过滤器利用洋葱模型,在起点和终点之间对
错误的返回值
做过滤操作
- 新建文件
/src/core/filter/transform.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
let status = exception.getStatus(); // 获取异常状态码
/**
* @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
* code === -1 :前端直接全局报message的错
* code === [其它] 单独进行特殊场景判断
*/
const message = exception.message
? exception.message
: `${status >= 500 ? 'Service Error' : 'Client Error'}`;
const errorResponse = {
data: {},
message: message,
code: -1,
};
// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}
main.ts
注册
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';
import { HttpExceptionFilter } from './core/filter/transform.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局注册过滤器
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(PORT);
}
bootstrap();
拦截器
拦截器利用洋葱模型,在起点和终点之间对
常规返回值
做包装返回
- 新建文件
/src/core/interceptor/transform.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
interface Response<T> {
data: T;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
// console.log('===拦截器===', data);
return {
data,
code: 0,
msg: '请求成功',
};
}),
);
}
}
main.ts
注册
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PORT, IP } from './constant/ServerListen';
import { HttpExceptionFilter } from './core/filter/transform.filter';
import { TransformInterceptor } from './core/interceptor/transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局注册过滤器
app.useGlobalFilters(new HttpExceptionFilter());
// 全局注册拦截器
app.useGlobalInterceptors(new TransformInterceptor());
await app.listen(PORT);
}
bootstrap();
经过 过滤器
和 拦截器
的处理,返回结构基本已经得到规范,前端按照 code 去区分场景就可以了
中间件
这里新建一个日志中间件,跑一下中间件使用的流程
- 新建文件
/src/core/middleware/logger.middleware.ts
/**
* @file 日志中间件
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
// req:请求参数
// res:响应参数
// next:执行下一个中件间
use(req: Request, res: Response, next: () => void) {
const { method, path } = req;
// console.log(`===日志中间件===, ${method}, ${path}`);
next();
}
}
app.module.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './feature/user/user.module';
import { LoggerMiddleware } from './core/middleware/logger.middleware';
@Module({
imports: [UserModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware) // 应用中间件
// .exclude({ path: 'user', method: RequestMethod.POST }) // 排除user的post方法
.forRoutes('*'); // 监听路径 参数:路径名或*,*是匹配所以的路由
// .forRoutes({ path: 'user', method: RequestMethod.POST }, { path: 'album', method: RequestMethod.ALL }); //多个
// .apply(UserMiddleware) // 支持多个中间件
// .forRoutes('user')
}
}
这里测试下效果,我们请求接口后,会执行中间件的打印
路由前缀
当前路由:/user/mytest
目标路由:/api/user/mytest
格式:/
顶级路由前缀
/模块路由
/单个接口路由
解释一下:为了规范项目开发流程,我们给定一个前缀作为该项目的服务接口的标识,为的是项目多了之后只看前缀就知道是哪个项目,这里给定的是
/api
,你也可以用其它字符串。
main.ts
入口函数添加
// 设置全局路由前缀
app.setGlobalPrefix('api');
- 测试返回值
DTO校验
我们这步会写一个简单的注册接口来跑一下流程
此校验通过它的规则让我们的 编码效率 和 可维护性 大大提高
- 安装校验依赖
class-validator
&class-transformer
cnpm i class-validator class-transformer --save
user.controller.ts
添加控制器
@Post('register')
async register(@Body() post: UserDOT.RegisterUserDto) {
return await this.userService.Register(post);
}
user.service.ts
添加接口服务
import { HttpException, Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
async testService(post) {
return {
msg: '成功了',
};
}
// 用户注册
async Register(post) {
return {
msg: '注册接口',
};
}
}
- 同级创建
dto
校验文件:user.dto.ts
这里声明了该注册接口的入参和类型,以及为空时的返回值,在编写的时候注意规范,同一个DTO类只能服务一个接口,但不影响他们继承于其它公用类,同一个模块DTO文件可以写多个DTO类服务当前模块的多个接口,这样更方便工程化。
import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';
export class RegisterUserDto {
@IsNotEmpty({ message: '账号必填' })
readonly account: string;
@IsNotEmpty({ message: '密码必填' })
readonly password: string;
}
- DTO采用管道的概念,需要全局注册管道才可以生效:
main.ts
import { ValidationPipe } from '@nestjs/common';
// 全局注册一下管道ValidationPipe
app.useGlobalPipes(new ValidationPipe());
- 最后还需要修改过滤器,因为规则发生了变化:
transform.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
let status = exception.getStatus(); // 获取异常状态码
/**
* @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
* code === -1 :前端直接全局报message的错
* code === [其它] 单独进行特殊场景判断
*/
const exceptionResponse: any = exception.getResponse();
let validMessage: string = '';
for (let key in exception) {
console.log('===过滤器===', key, exception[key]);
}
if (typeof exceptionResponse === 'object') {
validMessage =
typeof exceptionResponse.message === 'string'
? exceptionResponse.message
: exceptionResponse.message[0];
}
const message = exception.message
? exception.message
: `${status >= 500 ? 'Service Error' : 'Client Error'}`;
const errorResponse = {
data: {},
message: validMessage || message,
code: -1,
};
// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}
- 此时再看请求返回(后面再做数据入库,这里只是解释 DTO 概念)
MySql
- 安装所需依赖:
mysql
&typeorm
&@nestjs/typeorm
&@nestjs/config
cnpm i mysql typeorm @nestjs/typeorm @nestjs/config --save
- 在 user 模块下新建库表映射文件:
user.entity.ts
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
@Entity('user') // 库表名称
export class UserEntity {
// 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。 Uuid 是一个独特的字符串
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20 })
account: string;
@Exclude()
@Column({
length: 100,
// select: false // 表示隐藏此列 和@Exclude二选一
})
password: string;
@Column({ length: 20, default: 'User' })
nickname: string;
@Column({ default: 'http://localhost:3001/static/default.png' })
avatar: string;
@CreateDateColumn({
type: 'timestamp',
comment: '创建时间',
})
create_time: Date;
@UpdateDateColumn({
type: 'timestamp',
comment: '更新时间',
})
update_time: Date;
@DeleteDateColumn({
type: 'timestamp',
comment: '删除时间',
})
delete_time: Date;
}
user.module.ts
引入实体
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
user.service.ts
接口服务
import { HttpException, Injectable } from '@nestjs/common';
import { UserEntity } from './user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
// 用户注册
async Register(post: Partial<UserEntity>): Promise<object> {
const { account } = post;
const user = await this.userRepository.findOne({ where: { account } });
if (user) {
throw new HttpException('账号已存在', 200);
}
const newUser = await this.userRepository.create(post);
await this.userRepository.save(newUser);
return {};
}
}
- 改造
app.module.ts
注册引入 TypeOrmModule
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './feature/user/user.module';
import { LoggerMiddleware } from './core/middleware/logger.middleware';
import { UserEntity } from './feature/user/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService, ConfigModule } from '@nestjs/config';
// 数据表集合
const Entities: Array<any> = [UserEntity];
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
type: 'mysql', // 数据库类型
entities: Entities, // 数据表实体
// autoLoadEntities: true, // 可以打开此配置项,表示<entities>配置自动引入,避免忘记
host: configService.get('DB_HOST', 'localhost'), // 主机,默认为localhost
port: configService.get<number>('DB_PORT', 3306), // 端口号
username: configService.get('DB_USER', 'root'), // 用户名
password: configService.get('DB_PASSWORD', 'rootpass'), // 密码
database: configService.get('DB_DATABASE', 'NestData'), //数据库名
timezone: '+08:00', // 服务器上配置的时区
synchronize: true, // 根据实体自动创建数据库表, 生产环境建议关闭
}),
}),
UserModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware) // 应用中间件
// .exclude({ path: 'user', method: RequestMethod.POST }) // 排除user的post方法
.forRoutes('*'); // 监听路径 参数:路径名或*,*是匹配所以的路由
// .forRoutes({ path: 'user', method: RequestMethod.POST }, { path: 'album', method: RequestMethod.ALL }); //多个
// .apply(UserMiddleware) // 支持多个中间件
// .forRoutes('user')
}
}
- 测试注册接口
刚才库里是没有表的,接收到请求后实体帮我们创建了一张表,并且将入参入库
使用相同的参数再调用注册
看返回值,说明写的逻辑是没有问题的,具体如何操作库
?TypeORM的规则如何
?慢慢往后看。
接口文档
后端往往需要输出一个接口文档给前端作为 调用参考 或者 后期追溯
这里我们用 NestJS 内置集成的
Swgger
- 安装依赖:
@nestjs/swagger
cnpm i @nestjs/swagger --save
- 新建文件独立配置:
src/common/swagger.ts
/**
* @file 配置 Swagger文档
* @desc 此配置在main主入口调用Init即可
*/
import { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as CONST from '../constant/SwaggerConfig';
export class SwaggerConfig {
app: NestExpressApplication;
constructor(app: NestExpressApplication) {
this.app = app;
}
public Init(): void {
const config = new DocumentBuilder()
.setTitle(CONST.TITLE)
.setDescription(CONST.DESC)
.addBearerAuth() // 为了可以顺畅的使用Swagger来测试传递bearer token接口
.setVersion(CONST.VER)
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(this.app, config);
SwaggerModule.setup('docs', this.app, document);
}
}
- 新建独立声明文件:
src/constant/SwaggerConfig.ts
/**
* @desc swagger 常量
*/
export const TITLE = 'NestJS提供服务';
export const DESC = '管理后台接口文档';
export const VER = '1.0.0';
main.ts
新增内容
import { SwaggerConfig } from './common/swagger';
// 设置swagger文档
new SwaggerConfig(app).Init();
- 访问文档:
http://localhost:3001/docs#/
不难发现,文档有了,接口有了,但是没有字段和标注,这是因为我们在
DTO
的位置没有加注释,在Controller
的位置没有加描述。下面改造补全 👇🏻
user.dto.ts
export class RegisterUserDto {
@ApiProperty({ description: '账号' })
@IsNotEmpty({ message: '账号必填' })
readonly account: string;
@ApiProperty({ description: '密码' })
@IsNotEmpty({ message: '密码必填' })
readonly password: string;
}
user.controller.ts
import { UserService } from './user.service';
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import * as UserDOT from './user.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('用户系列接口:/api/user')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@ApiOperation({ summary: '用户注册' })
@Post('register')
async register(@Body() post: UserDOT.RegisterUserDto) {
return await this.userService.Register(post);
}
}
app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('顶级路由:/api')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
- 再看改完之后的效果(分层和注释在其它接口中也是如此)
前面我们在 mysql 中创建了一张 user
表,这张表作为我们的用户表,也就是注册之后的用户信息放在这里。
下面我们还要做登录,登录涉及到用户 Token 的持久化,这个持久化 Mysql 显然不合理,由于 Redis 可以设置过期时间,刚好符合这个场景,所以使用 Redis,前面已经教过它如何下载了,这里直接使用。
安装各类依赖
cnpm i xxx --save
- 认证相关:
@nestjs/passport
&@nestjs/jwt
&passport-jwt
&passport
- 编码规则:
bcryptjs
&passport-local
&@types/passport-local
- 图片验证码:
svg-captcha
- Redis:
cache-manager-redis-store@2.0.0
&cache-manager@4.0.0
&@types/cache-manager@4.0.2
- 导出:
exceljs
- 限制接口频率:
@nestjs/throttler
- 定时任务:
@nestjs/schedule
新建目录
-
src/feature
下新建一个auth
模块 用来写认证相关(也就是登录流程) -
src/feature
下新建一个tasks
模块 用来写定时任务 -
src/feature
下新建一个upload
模块 用来写上传 -
src
下新建一个redis
模块 用来存放 Redis 相关内容
完善剩余功能
feature/auth/auth.controller.ts
-
获取验证码
-
验证验证码
-
账号密码登录
-
import {
Body,
ClassSerializerInterceptor,
Controller,
Post,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RedisCacheService } from '../../redis/redis-cache.service';
import * as AuthDOT from './auth.dto';
@ApiTags('AUTH 认证')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private redisCacheService: RedisCacheService,
) { }
/**
* 获取图片验证码
*/
@ApiOperation({ summary: '获取图片验证码' })
@Post('authcode') // 当请求该接口时,返回一张随机图片验证码
async getCode(@Body() post) {
// req.session.code = svgCaptcha.text; // 使用session保存验证,用于登陆时验证
// res.type('image/svg+xml'); // 指定返回的类型
// res.send(svgCaptcha.data); // 给页面返回一张图片
return await this.authService.GetCode(post);
}
/**
* 验证图片验证码
*/
@ApiOperation({ summary: '验证图片验证码' })
@Post('comparecode')
async compareCode(@Body() post: AuthDOT.CompareCodeDto) {
return await this.authService.CompareCode(post);
}
/**
* 账号密码登录
*/
@ApiOperation({ summary: '认证登录' })
// @UseGuards:使用守卫 @AuthGuard:认证守卫
@UseGuards(AuthGuard('local'))
@UseInterceptors(ClassSerializerInterceptor)
@Post('login')
async login(@Body() post: AuthDOT.LoginDto, @Req() req) {
// console.log('===', user, req);
// const userInfo = req.user;
return await this.authService.login(post, req.user);
}
/**
* 微信扫码登录
*/
}
feature/auth/auth.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class CompareCodeDto {
@ApiProperty({ description: '验证码Key' })
@IsNotEmpty({ message: '验证码必填' })
readonly VerificationKey: string;
@ApiProperty({ description: '验证码Value' })
@IsNotEmpty({ message: '验证码必填' })
readonly VerificationCode: string;
}
export class LoginDto {
@ApiProperty({ description: '账号' })
@IsNotEmpty({ message: '账号必填' })
readonly account: string;
@ApiProperty({ description: '密码' })
@IsNotEmpty({ message: '密码必填' })
readonly password: string;
}
feature/auth/auth.entity.ts
登录实体映射
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
PrimaryColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* @desc 此表配合redis完成一个合理的token存取过程
* redis 用于<查>,然后验证
* 登录表 用于记录用户登录状态,方便统计
*/
@Entity('user_token')
export class UserTokenEntity {
@PrimaryGeneratedColumn()
id: string;
@Column({ length: 200 })
uuid: string; // 此<uuid>使用<user>表中的主键
@Column({ length: 200 })
token: string;
@Column({ length: 20 })
account: string;
@Column({ length: 20, default: '' })
nickname: string;
@CreateDateColumn({
type: 'timestamp',
comment: '创建时间',
})
create_time: Date;
@UpdateDateColumn({
type: 'timestamp',
comment: '更新时间',
})
update_time: Date;
@DeleteDateColumn({
type: 'timestamp',
comment: '删除时间',
})
delete_time: Date;
}
feature/auth/auth.module.ts
-
JWT 注册
-
JWT 挂载
-
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/user.entity';
import { LocalStorage } from './local.strategy';
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
import { JwtStorage } from './jwt.strategy';
import { JwtModule } from '@nestjs/jwt';
import { UserModule } from '../user/user.module';
import { UserTokenEntity } from './auth.entity';
import { RedisCacheModule } from '../../redis/redis-cache.module';
import { ToolsCaptcha } from '../../common/captcha';
/**
* 这里不建议将秘钥写死在代码也, 它应该和数据库配置的数据一样,从环境变量中来
*/
const jwtModule = JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
secret: configService.get('SECRET', 'key'),
// 先不设置有效期 配合其他代码和<redis>完成<token自动续期>
// signOptions: { expiresIn: '4h' },
};
},
});
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity]),
TypeOrmModule.forFeature([UserTokenEntity]),
PassportModule,
jwtModule,
UserModule,
RedisCacheModule,
],
controllers: [AuthController],
providers: [AuthService, LocalStorage, JwtStorage, ToolsCaptcha],
exports: [jwtModule],
})
export class AuthModule { }
feature/auth/auth.service.ts
-
生成 Token
-
Token 处理
-
生成验证码
-
验证验证码
-
清空验证码
-
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/user.entity';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from './auth.entity';
import * as CONST from '../../constant/LengthOfTime';
import * as REDIS from '../../constant/RedisKeyPrefix';
import { ToolsCaptcha } from '../../common/captcha';
import { CreateEncrypt } from '../../common/encrypt';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userService: UserService,
private readonly toolsCaptcha: ToolsCaptcha,
private redisCacheService: RedisCacheService,
@InjectRepository(UserTokenEntity)
private readonly UserTokenRepository: Repository<UserTokenEntity>,
) { }
// 生成token
async createToken(user: Partial<UserEntity>) {
return await this.jwtService.sign(user);
}
async login(post, user: Partial<UserEntity>) {
// 传入 id 和 account 序列化一个token
const token = await this.createToken({
id: user.id,
account: user.account,
});
/**
* @desc token 过期处理
* 在登录时,将jwt生成的token,存入redis,并设置有效期为30分钟。存入redis的key由用户信息组成, value是token值
*/
await this.redisCacheService.cacheSet(
`${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
token,
CONST.TOKEN_FIRST_SET_TIME,
);
// 组合数据入库
const row: Partial<UserTokenEntity> = {
uuid: user.id,
nickname: user.nickname || '',
account: user.account || '',
token: token,
};
const findRow = await this.UserTokenRepository.findOne({
where: { uuid: user.id },
});
if (findRow) {
await this.UserTokenRepository.remove(findRow);
}
const newUser = await this.UserTokenRepository.create(row);
await this.UserTokenRepository.save(newUser);
return {
token: token,
userInfo: {
id: user.id,
account: user.account,
avatar: user.avatar,
nickname: user.nickname,
},
};
}
async getUser(user) {
return await this.userService.findOne(user.id);
}
async GetCode(post) {
const svgCaptcha = await this.toolsCaptcha.captche(); // 创建验证码
const createID = new CreateEncrypt().nanoid(); // 编码一个id
this.redisCacheService.cacheSet(
`${REDIS.RedisPrefixCaptcha}${createID}`,
`${svgCaptcha.text}`,
CONST.CAPCODE_FIRST_SET_TIME,
);
return {
key: createID,
svg: svgCaptcha.data,
};
}
async CompareCode(post) {
// 验证码校验判断
if (!post.VerificationCode || !post.VerificationKey) {
throw new HttpException('缺少验证码', 200);
} else {
const key = `${REDIS.RedisPrefixCaptcha}${post.VerificationKey}`;
const value = await this.redisCacheService.cacheGet(key);
if (!value) {
throw new HttpException('验证码不正确', 200);
} else if (value !== post.VerificationCode) {
this.redisCacheService.cacheDel(key);
throw new HttpException('验证码不正确', 200);
} else {
// 验证完之后,删掉此验证码
this.redisCacheService.cacheDel(key);
}
}
return {};
}
}
feature/auth/jwt.strategy.ts
-
JWT Token 校验方案
-
提取 Token
-
校验 Token(是否存在、是否过期)
-
/**
* @file token校验相关
* @desc JSON Web Token(JWT)
* @doc 此方案无需维护登录表,简化了前后端交互方案,同时传递给客户端的token使用私钥进行了加密,无需担心安全问题
*
* 标记了 @UseGuards(AuthGuard('jwt')) 的控制器会经过此文件的检测
* 检测请求的header中<Authorization>传递的token是否正确
*/
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StrategyOptions, Strategy, ExtractJwt } from 'passport-jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/user.entity';
import { AuthService } from './auth.service';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from './auth.entity';
import * as CONST from '../../constant/ApiWriteList';
import * as Duration from '../../constant/LengthOfTime';
import * as REDIS from '../../constant/RedisKeyPrefix';
export class JwtStorage extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private readonly configService: ConfigService,
private readonly authService: AuthService,
private readonly redisCacheService: RedisCacheService,
@InjectRepository(UserTokenEntity)
private readonly UserTokenRepository: Repository<UserTokenEntity>,
) {
super({
/**
* 策略中的ExtractJwt提供多种方式从请求中提取JWT,常见的方式有以下几种:
fromHeader: 在Http 请求头中查找JWT
fromBodyField: 在请求的Body字段中查找JWT
fromAuthHeaderAsBearerToken:在授权标头带有Bearer方案中查找JWT
*/
/**
* @desc 这里采用<fromAuthHeaderAsBearerToken>方案
* @todo 触发方式(header内key:value形式传递):Authorization: `Bearer ${token}`
*/
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('SECRET'),
passReqToCallback: true,
} as StrategyOptions);
}
async validate(req, user: UserEntity) {
console.log('检测', req, user);
/**
* @desc 取出token并验证
* 在验证token时, 从redis中取token,如果取不到token,可能是token已过期。
*/
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
const cacheToken = await this.redisCacheService.cacheGet(
`${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
);
if (!cacheToken) {
throw new UnauthorizedException('token 已过期');
}
/**
* @desc 用户唯一登录
* 当用户登录时,每次签发的新的token,会覆盖之前的token,
* 判断redis中的token与请求传入的token是否相同, 不相同时, 可能是其他地方已登录, 提示token错误
*/
if (token != cacheToken) {
throw new UnauthorizedException('token不正确');
}
const existUser = await this.authService.getUser(user);
if (!existUser) {
throw new UnauthorizedException('token不正确');
}
/**
* 在token认证通过后,重新设置过期时间
* 因为使用的cache-manager没有通过直接更新有效期方法,通过重新设置来实现
*/
// 忽略无需续签的接口队列
if (!CONST.TOKEN_AUTOMATIC_RENEWAL_IGNORE_LIST.includes(req.url)) {
this.redisCacheService.cacheSet(
`${REDIS.RedisPrefixToken}${user.id}&${user.account}`,
token,
Duration.TOKEN_AUTOMATIC_RENEWAL_TIME,
);
const findRow = await this.UserTokenRepository.findOne({
where: { uuid: user.id },
});
/**
* 使用<dayjs>的时间方法生成时间字符串替换库中的<update_time>
*/
findRow.update_time = new Date();
console.log('时间转时间戳方法==>', findRow.update_time.getTime());
const updatePost = this.UserTokenRepository.merge(findRow, {});
await this.UserTokenRepository.save(updatePost);
}
return existUser;
}
}
feature/auth/local.strategy.ts
账号密码本地认证
/**
* @file 账号密码本地认证
* 标记了@UseGuards(AuthGuard('local'))的控制器会执行
*/
import { compareSync } from 'bcryptjs';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { UserEntity } from '../user/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, HttpException } from '@nestjs/common';
export class LocalStorage extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {
// 如果不是username、password, 在constructor中配置
super({
// key 必须是 usernameField 和 passwordField
usernameField: 'account',
passwordField: 'password',
} as IStrategyOptions);
}
async validate(account: string, password: string) {
// 因为密码是加密后的,没办法直接对比用户名密码,只能先根据用户名查出用户,再比对密码
const user = await this.userRepository
.createQueryBuilder('user')
.addSelect('user.password')
.where('user.account=:account', { account })
.getOne();
if (!user) {
throw new HttpException('用户名不正确!', 200);
// throw new BadRequestException('用户名不正确!');
}
if (!compareSync(password, user.password)) {
throw new HttpException('密码错误!', 200);
// throw new BadRequestException('密码错误!');
}
return user;
}
}
feature/tasks/tasks.module.ts
定时任务模块
import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { UserModule } from '../user/user.module';
@Module({
imports: [UserModule],
providers: [TasksService],
})
export class TasksModule {}
feature/tasks/tasks.service.ts
定时任务服务
import { UserService } from '../user/user.service';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, Interval, Timeout } from '@nestjs/schedule';
@Injectable()
export class TasksService {
constructor(private readonly userService: UserService) { }
private readonly logger = new Logger(TasksService.name);
/**
* @desc 下方装饰器详解
*
* * * * * * 分别对应的意思:
第1个星:秒
第2个星:分钟
第3个星:小时
第4个星:一个月中的第几天
第5个星:月
第6个星:一个星期中的第几天
如:
45 * * * * *:每隔45秒执行一次
*/
@Cron('45 * * * * *') // 每隔45秒执行一次
handleCron() {
this.logger.debug('定时任务--日志--45秒');
}
// @Interval(10) // 每秒10次的定时任务
// handleInterval() {
// this.userService.mockData();
// this.logger.debug('定时任务--日志--0.1秒');
// }
@Timeout(5000) // 5秒后,只执行一次
handleTimeout() {
this.logger.debug('定时任务--单次--日志--5秒');
// this.userService.mockData();
}
}
feature/upload/upload.controller.ts
图片上传路由
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
UploadedFile,
HttpException,
} from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('上传相关接口:/api/upload')
@Controller('upload')
export class UploadController {
constructor(private readonly uploadService: UploadService) { }
@ApiOperation({ summary: '头像上传接口' })
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
upload(@UploadedFile() file) {
console.log('file', file);
if (file.filename && file.destination) {
return {
filename: file.filename,
url: 'http://localhost:3001/' + 'static/upload/' + file.filename,
};
} else {
return new HttpException('上传图片失败', 200);
}
}
}
feature/upload/upload.module.ts
图片上传模块
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import RootDirPath from '../../common/RootDirpath';
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
/**
* 访问地址:http://localhost/:3001/static/upload/1676272943743.png
* 由于根目录配置了虚拟路径,所以<public>需要改成<static>才可以访问
*/
destination: join(RootDirPath, '/public/upload'),
filename: (_, file, callback) => {
const fileName = `${new Date().getTime() + extname(file.originalname)
}`;
return callback(null, fileName);
},
}),
limits: {
fileSize: 1024 * 1024, // 设置最大上传 1MB
},
}),
],
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule { }
feature/upload/upload.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UploadService {}
feature/user/user.controller.ts
用户模块-
用户注册
-
修改密码
-
退出登录
-
获取用户列表(分页)
-
拉黑单个用户
-
恢复单个用户
-
获取用户数据(mysql)
-
获取登录数据(redis)
-
获取验证码列表
-
清空验证码列表
-
下线单个用户
-
下线所以用户
-
修改昵称
-
上传头像
-
导出表格
-
import { UserService, UserRo } from './user.service';
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Query,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import * as UserDOT from './user.dto';
import { AuthGuard } from '@nestjs/passport';
@ApiTags('用户系列接口:/api/user')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) { }
@ApiOperation({ summary: '获取用户信息' })
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt')) // 此项表示该接口需要Auth认证
@Get()
async getUserInfo(@Req() req) {
return req.user;
}
/**
* 用户注册
* @param post
*/
@UseInterceptors(ClassSerializerInterceptor) // 接口返回数据时过滤掉使用 @Exclude 标记的列
@ApiOperation({ summary: '用户注册' })
@Post('register')
async register(@Body() post: UserDOT.RegisterUserDto) {
return await this.userService.Register(post);
}
/**
* 修改密码
* @param post
*/
@ApiOperation({ summary: '修改密码' })
@Post('updatePassword')
async updatePassword(
@Body() post: UserDOT.UpdatePasswordUserDto,
@Req() req,
) {
const userInfo = req.user; // 拿到请求的用户信息
return await this.userService.UpdatePassword(post, userInfo);
}
/**
* 退出登录
* @param post
*/
@ApiOperation({ summary: '退出登录' })
@Post('loginOut')
// 这里不接收参数,无需dot
async loginOut(@Body() post: {}, @Req() req) {
const userInfo = req.user; // 拿到请求的用户信息
return await this.userService.LoginOut(post, userInfo);
}
/**
* 获取用户列表
* @param post
*/
@ApiOperation({ summary: '获取用户列表' })
@Post('getUserList')
async getUserList(@Body() post: UserDOT.GetUserListUserDto, @Req() req) {
return await this.userService.GetUserList(post);
}
/**
* 删除单个用户(拉黑)
* @param post
*/
@ApiOperation({ summary: '删除单个用户(拉黑)' })
@Post('deleteUser')
async deleteUser(@Body() post: UserDOT.DeleteUserDto, @Req() req) {
return await this.userService.DeleteUser(post);
}
/**
* 恢复单个用户
* @param post
*/
@ApiOperation({ summary: '恢复单个用户' })
@Post('recoverUser')
async recoverUser(@Body() post: UserDOT.RecoverUserDto, @Req() req) {
return await this.userService.RecoverUser(post);
}
/**
* 查询登录表用户(分页) Mysql
* @param post
*/
@ApiOperation({ summary: '查询登录表用户(分页) Mysql' })
@Post('getLoginUser')
async getLoginUser(@Body() post: UserDOT.GetLoginUserDto, @Req() req) {
return await this.userService.GetLoginUser(post);
}
/**
* 查询登录表用户 Redis
* @param post
*/
@ApiOperation({ summary: '查询登录表用户 Redis' })
@Post('getCatchLoginUser')
async getCatchLoginUser(@Body() post: {}, @Req() req) {
return await this.userService.GetCatchLoginUser(post);
}
/**
* 查询验证码列表 Redis
* @param post
*/
@ApiOperation({ summary: '查询验证码列表 Redis' })
@Post('getCapcodeList')
async getCapcodeList(@Body() post: {}, @Req() req) {
return await this.userService.GetCapcodeList(post);
}
/**
* 清空验证码列表 Redis
* @param post
*/
@ApiOperation({ summary: '清空验证码列表 Redis' })
@Post('clearCapcodeList')
async clearCapcodeList(@Body() post: {}, @Req() req) {
return await this.userService.ClearCapcodeList(post);
}
/**
* 下线单个用户
* @param post
*/
@ApiOperation({ summary: '下线单个用户' })
@Post('offlineUser')
async offlineUser(@Body() post: UserDOT.OfflineUserDto, @Req() req) {
return await this.userService.OfflineUser(post);
}
/**
* 下线所有用户
* @param post
*/
@ApiOperation({ summary: '下线所有用户' })
@Post('offlineAllUser')
async offlineAllUser(@Body() post: {}, @Req() req) {
return await this.userService.OfflineAllUser(post);
}
/**
* 用户设置昵称
* @param post
*/
@ApiOperation({ summary: '用户设置昵称' })
@Post('updateNickname')
async updateNickname(@Body() post: UserDOT.SetNicknameDto, @Req() req) {
const userInfo = req.user; // 拿到请求的用户信息
return await this.userService.UpdateNickname(post, userInfo);
}
/**
* 用户设置头像
* @param post
*/
@ApiOperation({ summary: '用户设置头像' })
@Post('updateAvatar')
async updateAvatar(@Body() post: UserDOT.SetAvatarDto, @Req() req) {
const userInfo = req.user; // 拿到请求的用户信息
return await this.userService.UpdateAvatar(post, userInfo);
}
/**
* 导出 用户表当前页数据
* @param post
*/
@ApiOperation({ summary: '导出 用户表当前页数据' })
@Post('exportExcel')
async exportExcel(@Body() post: UserDOT.GetUserListUserDto, @Req() req) {
const userInfo = req.user; // 拿到请求的用户信息
return await this.userService.ExportExcel(post, userInfo);
}
}
feature/user/user.dto.ts
import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { DataListCommonDto } from '../../common/CommonDto';
export class RegisterUserDto {
@ApiProperty({ description: '账号' })
@IsNotEmpty({ message: '账号必填' })
readonly account: string;
@ApiProperty({ description: '密码' })
@IsNotEmpty({ message: '密码必填' })
readonly password: string;
}
export class LoginUserDto {
@ApiProperty({ description: '账号' })
@IsNotEmpty({ message: '账号必填' })
readonly account: string;
@ApiProperty({ description: '密码' })
@IsNotEmpty({ message: '密码必填' })
readonly password: string;
}
export class UpdatePasswordUserDto {
@ApiProperty({ description: '旧密码' })
@IsNotEmpty({ message: '原密码必填' })
readonly old_password: string;
@ApiProperty({ description: '新密码' })
@IsNotEmpty({ message: '新密码必填' })
readonly new_password: string;
}
export class DeleteUserDto {
@ApiProperty({ description: '用户id' })
@IsNotEmpty({ message: '缺少用户id' })
readonly id: string;
}
export class RecoverUserDto {
@ApiProperty({ description: '用户id' })
@IsNotEmpty({ message: '缺少用户id' })
readonly id: string;
}
export class GetLoginUserDto {
@ApiProperty({ description: 'pagesize' })
@IsNotEmpty({ message: '缺少pagesize' })
readonly pagesize: string;
@ApiProperty({ description: 'pagenum' })
@IsNotEmpty({ message: '缺少pagenum' })
readonly pagenum: string;
}
export class OfflineUserDto {
@ApiProperty({ description: '用户id' })
@IsNotEmpty({ message: '缺少用户id' })
readonly id: string;
}
export class SetNicknameDto {
@ApiProperty({ description: '用户昵称' })
@IsNotEmpty({ message: '缺少用户昵称' })
@MaxLength(10, { message: '昵称最大长度10位' })
@MinLength(2, { message: '昵称最小长度2位' })
readonly nickname: string;
}
export class SetAvatarDto {
@ApiProperty({ description: '头像地址' })
@IsNotEmpty({ message: '缺少图片地址' })
readonly url: string;
}
export class GetUserListUserDto extends DataListCommonDto { }
feature/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserEntity } from './user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserTokenEntity } from '../auth/auth.entity';
import { RedisCacheModule } from '../../redis/redis-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity]),
TypeOrmModule.forFeature([UserTokenEntity]),
RedisCacheModule,
],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
feature/user/user.service.ts
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as ExcelJS from 'exceljs';
import { Like, Repository } from 'typeorm';
import { UserEntity } from './user.entity';
import { RedisCacheService } from '../../redis/redis-cache.service';
import { UserTokenEntity } from '../auth/auth.entity';
var bcrypt = require('bcryptjs');
import * as REDIS from '../../constant/RedisKeyPrefix';
import fs from 'fs';
import path from 'path';
import RootDirpath from '../../common/RootDirpath';
export interface UserRo {
userInfo: UserEntity;
}
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private redisCacheService: RedisCacheService,
@InjectRepository(UserTokenEntity)
private readonly UserTokenRepository: Repository<UserTokenEntity>,
) { }
async findOne(id: string) {
return await this.userRepository.findOne({ where: { id: id } });
}
// 用户注册
async Register(post: Partial<UserEntity>): Promise<{}> {
const { account } = post;
const user = await this.userRepository.findOne({ where: { account } });
if (user) {
throw new HttpException('账号已存在', 200);
}
/**
* @desc 问题标记
*
* 直接Save,并不会触发@BeforeInsert和@BeforeUpdate,
async create(attributes: DeepPartial<T>) {
return this.repository.save(attributes); // 不会触发BeforeInsert
}
* 解决办法
方法一、利用plainToClass
async create(attributes: DeepPartial<T>) {
const entity = plainToClass(Admin, attributes);
return this.repository.save(entity);
}
方法二、利用new Entiry()
async create(attributes: DeepPartial<T>) {
const entiry = Object.assign(new Admin(), attributes)
return this.repository.save(entiry);
}
方法三、利用Entity.create(EntitySchema)
async create(dataDto: DataDto) {
const entityDto = this.respository.create(dataDto)
return this.repository.save(entityDto);
}
*/
const newUser = await this.userRepository.create(post);
await this.userRepository.save(newUser);
return {};
}
// 修改密码
async UpdatePassword(post, userInfo): Promise<{}> {
const { id, password, account } = userInfo;
const { old_password, new_password } = post;
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new HttpException('此账号不存在', 200);
}
// 判断新旧密码一致性
// 使用<compareSync>方法进行新旧密码对比
if (new_password === old_password) {
throw new HttpException('新密码与原密码一致', 200);
}
if (!bcrypt.compareSync(old_password, password)) {
throw new HttpException('原密码不正确', 200);
}
// 将新密码单独加密
const salt = await bcrypt.genSaltSync(10);
const endPWD = await bcrypt.hashSync(new_password, salt);
// 合并数据并入库
const updatePost = this.userRepository.merge(user, {
password: endPWD,
});
// 清除redis存储的token,引导用户重新登录
this.redisCacheService.cacheDel(
`${REDIS.RedisPrefixToken}${id}&${account}`,
);
await this.userRepository.save(updatePost);
return {};
}
// 用户登出
async LoginOut(post, userInfo): Promise<{}> {
const { id, account } = userInfo;
// const user = await this.userRepository.findOne({ where: { id } });
// 删除登录表中的此用户
this.UserTokenRepository.delete({ uuid: id });
// 清除redis存储的token
this.redisCacheService.cacheDel(
`${REDIS.RedisPrefixToken}${id}&${account}`,
);
return {};
}
// 创建用户假数据
async mockData() {
const len = 50000;
const phoneNum = 18210249690;
for (let i = 0; i < len; i++) {
const new_user = {
account: String(phoneNum * 1 + i * 1),
password: '182',
};
const user = await this.userRepository.findOne({
where: { account: new_user.account },
});
if (user) return;
const newUser = await this.userRepository.create(new_user);
await this.userRepository.save(newUser);
}
}
// 获取用户列表(分页)
async GetUserList(post) {
const { pagenum = 1, pagesize = 10, keyword } = post;
const qb = this.userRepository.createQueryBuilder('user'); // qb实体
qb.withDeleted(); // 指示是否应在实体结果中包含软删除的行
// 如果有关键字,进行账号模糊查询
if (keyword) {
qb.where({
account: Like(`%${keyword}%`),
});
}
// 定义要返回的字段
qb.select([
'user.id',
'user.account',
'user.nickname',
'user.avatar',
'user.create_time',
'user.update_time',
'user.delete_time',
]); // 需要的属性
// qb.where("user.id = :id", { id: 1 }) // 条件语句
qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
const count = await qb.getCount(); // 查总数
qb.offset(pagesize * (pagenum - 1)); // 偏移位置
qb.limit(pagesize); // 条数
// .skip(5) // 可选:跳过多少条
// .take(10) // 可选:拿多少条
const list = await qb.getMany(); // getMany() 获取所有用户
return {
list,
count,
};
}
// 拉黑单个用户
async DeleteUser(post) {
const { id } = post;
const item = await this.userRepository.findOne({
where: { id } as any,
withDeleted: true, // 指示是否应在实体结果中包含软删除的行
});
console.log('item', item);
if (item) {
await this.userRepository.softRemove(item);
return {};
} else {
throw new HttpException('用户不存在', 200);
}
}
// 恢复单个用户
async RecoverUser(post) {
const { id } = post;
const item = await this.userRepository.findOne({
where: { id } as any,
withDeleted: true,
});
if (item) {
await this.userRepository.recover(item);
return {};
} else {
throw new HttpException('用户不存在', 200);
}
}
// 查询登录表用户(分页) Mysql
async GetLoginUser(post) {
const { pagenum = 1, pagesize = 10 } = post;
const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
qb.select([
'user.id',
'user.uuid',
'user.token',
'user.account',
'user.nickname',
'user.create_time',
'user.update_time',
]); // 需要的属性
qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
const count = await qb.getCount(); // 查总数
qb.offset(pagesize * (pagenum - 1)); // 偏移位置
qb.limit(pagesize); // 条数
const list = await qb.getMany(); // getMany() 获取所有用户
return {
list,
count,
};
}
// 查询登录表用户 Redis
async GetCatchLoginUser(post) {
const arr = await this.redisCacheService.cacheStoreKeys();
const keys = arr.filter(
(item) => item.indexOf(REDIS.RedisPrefixToken) > -1,
);
if (keys?.length) {
let datas = [];
for (let i = 0; i < keys.length; i++) {
const val = await this.redisCacheService.cacheGet(keys[i]);
if (val) {
let noPrefix = keys[i].split(REDIS.RedisPrefixToken)[1]; // 不包含前缀的值
datas.push({
KEY: keys[i],
VALUE: val,
uuid: noPrefix.split('&')[0],
account: noPrefix.split('&')[1],
token: val,
});
}
}
return {
list: datas,
count: datas.length,
};
}
return {};
}
// 查询图片验证码 Redis
async GetCapcodeList(post) {
const arr = await this.redisCacheService.cacheStoreKeys();
const keys = arr.filter(
(item) => item.indexOf(REDIS.RedisPrefixCaptcha) > -1,
);
if (keys?.length) {
let datas = [];
for (let i = 0; i < keys.length; i++) {
const val = await this.redisCacheService.cacheGet(keys[i]);
if (val) {
let noPrefix = keys[i].split(REDIS.RedisPrefixToken)[1]; // 不包含前缀的值
datas.push({
KEY: keys[i],
VALUE: val,
});
}
}
return {
list: datas,
count: datas.length,
};
}
return {};
}
// 下线单个用户
async OfflineUser(post) {
const { id, account } = post;
// 查找登录表里的用户
const findRow = await this.UserTokenRepository.findOne({
where: { uuid: id },
});
if (findRow) {
// 删除登录表中的用户
await this.UserTokenRepository.remove(findRow);
// 清除redis缓存里的用户
this.redisCacheService.cacheDel(
`${REDIS.RedisPrefixToken}${id}&${account}`,
);
return {};
} else {
throw new HttpException('用户不存在', 200);
}
}
// 下线所有用户
async OfflineAllUser(post) {
const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
// 清空 登录表
qb.delete().execute();
// // 清空 redis
// this.redisCacheService.cacheClear();
// return {};
const arr = await this.redisCacheService.cacheStoreKeys();
const keys = arr.filter(
(item) => item.indexOf(REDIS.RedisPrefixToken) > -1,
);
if (keys?.length) {
for (let i = 0; i < keys.length; i++) {
this.redisCacheService.cacheDel(keys[i]);
}
}
return {};
}
// 清空图片验证码
async ClearCapcodeList(post) {
const qb = this.UserTokenRepository.createQueryBuilder('user'); // qb实体
// // 清空 redis
const arr = await this.redisCacheService.cacheStoreKeys();
const keys = arr.filter(
(item) => item.indexOf(REDIS.RedisPrefixCaptcha) > -1,
);
if (keys?.length) {
for (let i = 0; i < keys.length; i++) {
this.redisCacheService.cacheDel(keys[i]);
}
}
return {};
}
// 用户设置昵称
async UpdateNickname(post, userInfo) {
const { id } = userInfo;
const { nickname } = post;
const user = await this.userRepository.findOne({ where: { id } });
if (user && nickname) {
const updatePost = this.userRepository.merge(user, {
nickname: nickname,
});
await this.userRepository.save(updatePost);
const findUser = await this.userRepository.findOne({
where: { id },
});
return {
userInfo: {
account: findUser.account,
avatar: findUser.avatar,
nickname: findUser.nickname,
id: findUser.id,
},
};
} else {
return {};
}
}
// 用户设置头像
async UpdateAvatar(post, userInfo) {
const { id } = userInfo;
const { url } = post;
const user = await this.userRepository.findOne({ where: { id } });
if (user && url) {
const updatePost = this.userRepository.merge(user, {
avatar: url,
});
await this.userRepository.save(updatePost);
const findUser = await this.userRepository.findOne({
where: { id },
});
return {
userInfo: {
account: findUser.account,
avatar: findUser.avatar,
nickname: findUser.nickname,
id: findUser.id,
},
};
} else {
return {};
}
}
async ExportExcel(post, userInfo) {
// 解构前端入参
const { pagenum = 1, pagesize = 10, keyword } = post;
const qb = this.userRepository.createQueryBuilder('user'); // qb实体
qb.withDeleted(); // 指示是否应在实体结果中包含软删除的行
// 如果有关键字,进行账号模糊查询
if (keyword) {
qb.where({
account: Like(`%${keyword}%`),
});
}
// 定义要返回的字段
qb.select([
'user.id',
'user.account',
'user.nickname',
'user.avatar',
'user.create_time',
'user.update_time',
]); // 需要的属性=
qb.orderBy('user.create_time', 'ASC'); // @arguments[1]: 'ASC' 升序 'DESC' 降序
qb.skip(pagesize * (pagenum - 1)); // 跳过多少条
qb.take(pagesize); // 拿多少条
// result是通过前端传递的参数从数据库获取需要导出的信息
const result = await qb.getMany(); // getMany() 获取所有用户
const workbook = new ExcelJS.Workbook();
// 设置 Excel 的 sheet
const worksheet = workbook.addWorksheet('【 用户注册表数据 】');
// 定义表头名称和字段名
worksheet.columns = [
{ header: 'ID', key: 'id', width: 42 },
{ header: '账号', key: 'account', width: 25 },
{ header: '昵称', key: 'nickname', width: 25 },
{ header: '头像', key: 'avatar', width: 50 },
{ header: '注册时间', key: 'create_time', width: 25 },
{ header: '更新时间', key: 'update_time', width: 25 },
];
worksheet.addRows(result);
// 针对头像列each循环 修改样式
const AvatarColumn = worksheet.getColumn('avatar');
AvatarColumn.eachCell((item) => {
item.style.font = {
italic: true, // 斜体
underline: 'single', // 下划线 单条
color: {
// argb: 'be14807f', // 字体颜色
theme: 3,
},
};
item.value = {
text: item.value as string, // 设置 value
hyperlink: item.value as string, // 设置 link
};
});
// 获取第一行
const FirstRow = worksheet.getRow(1);
FirstRow.height = 28; // 第一行高度
FirstRow.font = {
size: 18, // 字体大小
bold: true, // 加粗
// 字体颜色 argb和theme二选一即可
color: {
// argb: 'be14807f'
theme: 5, // 0白 1黑 2灰 3蓝 5红
},
};
const content = await workbook.xlsx.writeBuffer();
return {
filename: '用户注册表导出--' + new Date().getTime(),
content, // 前端接受到的数据格式为{type: 'buffer', data: []}
};
}
}
feature/user/user.entity.ts
用户表实体
import {
BeforeInsert,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
const bcrypt = require('bcryptjs');
@Entity('user') // 库表名称
export class UserEntity {
// 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。 Uuid 是一个独特的字符串
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20 })
account: string;
@Exclude()
@Column({
length: 100,
// select: false // 表示隐藏此列 和@Exclude二选一
})
password: string;
@Column({ length: 20, default: 'User' })
nickname: string;
@Column({ default: 'http://localhost:3001/static/default.png' })
avatar: string;
@CreateDateColumn({
type: 'timestamp',
comment: '创建时间',
})
create_time: Date;
@UpdateDateColumn({
type: 'timestamp',
comment: '更新时间',
})
update_time: Date;
@DeleteDateColumn({
type: 'timestamp',
comment: '删除时间',
})
delete_time: Date;
/**
* @func 在密码入库前加密替换
* @desc <注册 修改密码> 会执行此逻辑
*/
@BeforeInsert()
async encryptPwd() {
var salt = await bcrypt.genSaltSync(10);
this.password = await bcrypt.hashSync(this.password, salt);
}
}
src/redis/redis-cache.module.ts
Redis 模块
/**
* 为了启用缓存, 导入ConfigModule, 并调用register()或者registerAsync()传入响应的配置参数
*/
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisCacheService } from './redis-cache.service';
import { CacheModule, Module, Global } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
@Module({
imports: [
/**
* <CacheModule>的<registerAsync>方法采用 Redis Store 配置进行通信
* 由于Redis 信息写在配置文件中,所以采用<registerAsync()>方法来处理异步数据,如果是静态数据, 可以使用register
*/
CacheModule.registerAsync({
/**
* isGlobal 属性设置为true 来将其声明为全局模块
* 当我们将RedisCacheModule在AppModule中导入时, 其他模块就可以直接使用,不需要再次导入
*/
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
store: redisStore, // store 属性值redisStore ,表示'cache-manager-redis-store' 库
host: configService.get('REDIS_HOST'),
port: configService.get('REDIS_PORT'),
db: 0, //目标库,
auth_pass: configService.get('REDIS_PASSPORT'), // 密码,没有可以不写
};
},
}),
],
providers: [RedisCacheService],
exports: [RedisCacheService],
})
export class RedisCacheModule { }
src/redis/redis-cache.service.ts
Redis 服务方法-
存入
-
获取
-
清空
-
/**
* @file 在service实现缓存的读写
*
* @desc redis主要做了哪些功能?
* 我们借助redis来实现token过期处理、token自动续期、以及用户唯一登录。
- 过期处理:把用户信息及token放进redis,并设置过期时间
- token自动续期:token的过期时间为30分钟,如果在这30分钟内没有操作,则重新登录,如果30分钟内有操作,就给token自动续一个新的时间,防止使用时掉线。
- 户唯一登录:相同的账号,不同电脑登录,先登录的用户会被后登录的挤下线
* @desc 为什么要使用redis来存token?
1. 引入一个中间件管理token就避免了单点问题,对于分布式系统来说,不管你是哪一台服务处理的用户请求,我都是从redis获取的token。
2. redis的响应速度非常快,如果不出现网络问题,基本上是毫秒级别相应。
3. 对于token来说,是有时效性的,redis天然支持设置过期时间以及通过一些二方包提供的API到达自动续时效果。
*/
import { Injectable, Inject, CACHE_MANAGER } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class RedisCacheService {
constructor(
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) { }
// 存入指定缓存
cacheSet(key: string, value: string, ttl: number) {
this.cacheManager.set(key, value, { ttl }, (err) => {
if (err) throw err;
});
}
// 获取指定缓存
async cacheGet(key: string): Promise<any> {
return this.cacheManager.get(key);
}
// 清除指定缓存
async cacheDel(key: string): Promise<any> {
return this.cacheManager.del(key, (err) => {
if (err) throw err;
});
}
// 清空redis全部缓存
async cacheClear(): Promise<any> {
return this.cacheManager.reset();
}
// 查询redis全部Key
async cacheStoreKeys(): Promise<any> {
return this.cacheManager.store.keys();
}
}
/src/common/captcha.ts
生成图片验证码
/**
* @file 生成图片验证码
*/
import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';
@Injectable()
export class ToolsCaptcha {
async captche(size = 4) {
const captcha = svgCaptcha.create({
// 可配置返回的图片信息
size, // 生成几个验证码
fontSize: 50, // 文字大小
width: 100, // 宽度
height: 34, // 高度
background: '#cc9966', // 背景颜色
});
return captcha;
}
}
/src/common/CommonDto.ts
业务公用的dot类
/**
* @file 业务公用的dot类
*/
import { IsNotEmpty, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 分页实体类
export class DataListCommonDto {
@ApiProperty({ description: '分页条数' })
@IsNotEmpty({ message: '分页条数必填' })
@Max(100, { message: '最多多分页100条数据' }) // 限制分页,避免数据被大量导出
readonly pagesize: string;
@ApiProperty({ description: '分页页数' })
@IsNotEmpty({ message: '分页页数必填' })
readonly pagenum: string;
@ApiProperty({ description: '排序字段' })
readonly order?: string;
}
src/common/encrypt.ts
ID编码生成类
/**
* ID编码生成类
*/
export class CreateEncrypt {
guid(): string {
// eg: "a1ca0f7b-51bd-4bf3-a5d5-6a74f6adc1c7"
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
function (c) {
var r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
uuid(): string {
// eg: "ffb7cefd-02cb-4853-8238-c0292cf988d5"
var s = [];
var hexDigits = '0123456789abcdef';
for (var i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = '4'; // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = '-';
var uuid = s.join('');
return uuid;
}
nanoid(size: number = 21): string {
// eg: "AfRTJv9hRo42vKKUDBQLX"
let urlAlphabet =
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
let id = '';
// A compact alternative for `for (var i = 0; i < step; i++)`.
let i = size;
while (i--) {
// `| 0` is more compact and faster than `Math.floor()`.
id += urlAlphabet[(Math.random() * 64) | 0];
}
return id;
}
}
src/common/RootDirpath.ts
import { join } from 'path';
/**
* @des 跟路径拼接,避免嵌套太深的文件夹过度使用../
* @return /Users/.../nestjs_server_template/
*/
const RootDirPath = join(__dirname, '../../../');
export default RootDirPath;
src/constant/ApiWriteList.ts
-
接口白名单队列
-
自动续期逻辑的忽略名单
-
JWT 鉴权忽略检测的接口名单
-
/**
* @file 接口白名单队列
*/
// token 自动续期逻辑的忽略名单
export const TOKEN_AUTOMATIC_RENEWAL_IGNORE_LIST: Array<string> = [
'/api/auth/authcode', // 获取图片验证码
'/api/auth/comparecode', // 验证图片验证码
'/api/auth/login', // 登录
'/api/user/register', // 注册
'/api/user/loginOut', // 退出登录
];
// JWT 鉴权忽略检测的接口名单
export const JWT_IGNORE_LIST: Array<string> = [
'/api/auth/authcode', // 获取图片验证码
'/api/auth/comparecode', // 验证图片验证码
'/api/auth/login', // 登录
'/api/user/register', // 注册
];
src/constant/LengthOfTime.ts
/**
* @desc 时长 常量
*/
// 登录时生成token存入redis设置的过期时间(秒)
export const TOKEN_FIRST_SET_TIME = 1800;
// 验证码存入redis设置的过期时间(秒)
export const CAPCODE_FIRST_SET_TIME = 180;
// 有效期内接收请求token自动续期有效时长(秒)
export const TOKEN_AUTOMATIC_RENEWAL_TIME = 1800;
src/constant/RedisKeyPrefix.ts
redis数据的key前缀
/**
* @desc redis数据的key前缀 常量
*/
export const RedisPrefixToken = 'TOKEN_$$_'; // token 前缀
export const RedisPrefixCaptcha = 'CAPTCHA_$$_'; // 图片验证码 前缀
src/core/filter/transform.filter.ts
过滤器判断场景
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
let status = exception.getStatus(); // 获取异常状态码
/**
* @todo 这里后期要根据<status>状态码,对应的去映射<code>码给前端
* code === -1 :前端直接全局报message的错
* code === [其它] 单独进行特殊场景判断
*/
const exceptionResponse: any = exception.getResponse();
let validMessage: string = '';
for (let key in exception) {
// console.log('===过滤器===', key, exception[key]);
}
if (typeof exceptionResponse === 'object') {
validMessage =
typeof exceptionResponse.message === 'string'
? exceptionResponse.message
: exceptionResponse.message[0];
}
const message = exception.message
? exception.message
: `${status >= 500 ? 'Service Error' : 'Client Error'}`;
// 单独拦截<Unauthorized> , 以中文形式返回给客户端 , 一般token不正确会进入
if (validMessage === 'Unauthorized') {
validMessage = '未经授权的请求';
}
if (
validMessage === 'ThrottlerException: Too Many Requests' ||
message === 'ThrottlerException: Too Many Requests'
) {
validMessage = '操作频率过高';
}
const errorResponse = {
data: {},
message: validMessage || message,
code: -1,
};
// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}
src/core/guard/jwt.guard.ts
鉴权守卫
/**
* @desc 将AuthGuard('jwt')单个接口守卫升级为当前类
* 实现全局注册鉴权守卫
*/
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import * as ApiWriteList from '../../constant/ApiWriteList';
export class JwtAuthGuard extends AuthGuard('jwt') {
public requestUrl = '';
constructor() {
super();
}
getRequest(context: ExecutionContext) {
const ctx = context.switchToHttp();
const request = ctx.getRequest();
this.requestUrl = request.url;
return request;
}
handleRequest<User>(err, user: User): User {
// 判断:当前接口是否需要跳过全局JWT鉴权守卫
if (ApiWriteList.JWT_IGNORE_LIST.includes(this.requestUrl)) {
return {} as User;
}
if (err || !user) {
throw new UnauthorizedException('身份验证失败');
}
return user;
}
}
app.controller.ts
-
配置顶级路由
-
配置模版引擎路由和返回值
-
import { Controller, Get, Redirect, Render } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('顶级路由:/api')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) { }
@ApiOperation({ summary: '前台 访问:/api/default' })
@Get('default') // 前台 访问:/api/default
@Render('default/index') // 使用render渲染模板引擎,参数就是 /views 文件夹下的 index.ejs
defaultIndex() {
return {
// 只有返回参数在模板才能获取,如果不传递参数,必须返回一个空对象
msg: '欢迎访问<default>前台页面',
};
}
@ApiOperation({ summary: '后台 访问:/api/admin' })
@Get('admin') // 后台 访问:/api/admin
@Render('admin/index') // 使用render渲染模板引擎,参数就是 /views 文件夹下的 index.ejs
adminIndex() {
return {
// 只有返回参数在模板才能获取,如果不传递参数,必须返回一个空对象
msg: '欢迎访问<admin>后台页面',
};
}
}
main.ts
-
设置全局路由前缀
-
全局注册过滤器
-
全局注册拦截器
-
全局注册鉴权守卫
-
跨域资源共享
-
允许跨站访问
-
防止跨站脚本攻击
-
CSRF保护
-
全局注册管道
-
设置swagger文档
-
配置静态资源目录
-
设置虚拟路径
-
配置模板渲染引擎
-
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { PORT, IP } from './constant/ServerListen';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './core/filter/transform.filter';
import { TransformInterceptor } from './core/interceptor/transform.interceptor';
import { SwaggerConfig } from './common/swagger';
import { JwtAuthGuard } from './core/guard/jwt.guard';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// 设置全局路由前缀
app.setGlobalPrefix('api');
// 全局注册过滤器
app.useGlobalFilters(new HttpExceptionFilter());
// 全局注册拦截器
app.useGlobalInterceptors(new TransformInterceptor());
// 全局注册鉴权守卫
app.useGlobalGuards(new JwtAuthGuard());
// 跨域资源共享
app.enableCors(); // 允许跨站访问 或:const app = await NestFactory.create(AppModule, { cors: true });
// 防止跨站脚本攻击
// app.use(helmet()); // 打开配置可能会有跨域问题
// CSRF保护:跨站点请求伪造
// app.use(csurf({ cookie: true }));
// 全局注册一下管道ValidationPipe
app.useGlobalPipes(new ValidationPipe());
// 设置swagger文档
new SwaggerConfig(app).Init();
// 配置静态资源目录
app.useStaticAssets(join(__dirname, '../../public'), {
// 配置虚拟目录,比如 http://localhost:3001/static/002.png 来访问public目录里面的文件
prefix: '/static/', // 设置虚拟路径
});
// 配置模板渲染引擎
app.setBaseViewsDir(join(__dirname, '../../views'));
/**
* @desc 配置模版引擎使用规则
* @params ejs 和 hbs 二选一即可
*/
app.setViewEngine('ejs');
await app.listen(PORT);
}
bootstrap();
- 新建 后台模版
/views/admin/index.ejs
- 新建 前台模版
/views/default/index.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h3>NestJS 服务</h3>
<h3>ejs 模板引擎</h3>
<h3><%=data.msg%></h3>
</body>
</html>
最终结果
- 接口文档
- 使用 vue3 写了个简单的前端项目,前端去调用后端服务
- 前端:3000 端口
- 后端:3001 端口
- mysql:3306 端口
- redis:6379 端口
- 调用接口注册了3个用户
- 密码需要加密入库,也就是只有用户知道,或者修改密码,无法解码。
- 对比过程仔细去看代码,跟md5的对比过程类似,都是对比加密后的串,只是编码规则不一样而已
- 前端多次调用查看验证码,然后我们去看Redis可视化
VS Code 可以安装 DataBase,提供基础的可视化,过期时间也看得到,这就是缓存,再过一分钟去看,就什么都看不到了,因为已经过期,这就是我们代码里设置的过期时间。
- 登录进去页面之后,看下主页的布局
可以看到:红框标注的内容会调用前面写的那些接口,包括(CRUD、上传图片、模糊查询、分页、用户登录流程...等)
注意事项
-
每个模块的
Entity
实体类记得在app.module.ts
文件中挂载 -
DTO
规则记得全局挂载 管道,并且在控制器的参数处映射拦截 -
项目许多地方需要在全局挂载引入,这种情况下我们应该想到尽量去轻量化入口,也就是配置能提取的就提出去,在入口一行引入实例化解决挂载问题
-
还有较多
DTO
和TypeROM
的知识,后面会更新补上。 -
文章涉及代码较多,尤其是 鉴权 部分,需要融汇贯通地理解下。
-
注意不要忘记模块的引入、抛出、constructor 声明...等。
-
鉴权部分前期涉及到自定义装饰器,中期涉及到每个接口都需要添加装饰器比较繁琐,所以后来把它们封装了管道统一调用,不需要的地方加入接口白名单就可以了,这些文中都做好了。
-
列举出来的功能文中都已经实现了,具体细节和原因配合注释去理解就行。