地毯式学习nestjs(进阶)

693 阅读18分钟

配置抽离

yarn add nestjs-config
app.module.ts

import * as path from 'path';
import { Module } from '@nestjs/common';

//数据库
import { TypeOrmModule } from '@nestjs/typeorm';

//全局配置
import { ConfigModule, ConfigService } from 'nestjs-config';


@Module({
  imports: [
    //1.配置config目录
    ConfigModule.load(path.resolve(__dirname, 'config', '**/!(*.d).{ts,js}')),  
    
    //2.读取配置,这里读取的是数据库配置
	TypeOrmModule.forRootAsync({
      useFactory: (config: ConfigService) => config.get('database'), 
      inject: [ConfigService],  // 获取服务注入
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

配置数据库

// src -> config -> database

import { join } from 'path';
export default {
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: 'your password',
  database: 'test',
  entities: [join(__dirname, '../', '**/**.entity{.ts,.js}')],
  synchronize: true,
};

环境配置

yarn add cross-env

cross-env的作用是兼容window系统和mac系统来设置环境变量
在package.json中配置

"scripts": {
    "start:dev": "cross-env NODE_ENV=development nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "cross-env NODE_ENV=production node dist/main",
  },

dotenv的使用

yarn add dotenv

根目录创建 env.parse.ts

import * as fs from 'fs';
import * as path from 'path';
import * as dotenv from 'dotenv';

const isProd = process.env.NODE_ENV === 'production';

const localEnv = path.resolve('.env.local');
const prodEnv = path.resolve('.env.prod');

const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv;

// 配置 通过process.env.xx读取变量
dotenv.config({ path: filePath });

导入环境

// main.ts
import '../env.parse'; // 导入环境变量

.env.local

PORT=9000
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=123
MYSQL_DATABASE=test

.env.prod

PORT=9000
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=1234
MYSQL_DATABASE=test

读取环境变量 process.env.MYSQL_HOST形式

文件上传与下载

yarn add @nestjs/platform-express compressing

compressing 文件下载依赖,提供流的方式

配置文件的目录地址,以及文件的名字格式

// src/config/file.ts 上传文件配置

import { join } from 'path';
import { diskStorage } from 'multer';

/**
 * 上传文件配置
 */
export default {
  root: join(__dirname, '../../assets/uploads'),
  storage: diskStorage({
    destination: join(
      __dirname,
      `../../assets/uploads/${new Date().toLocaleDateString()}`,
    ),
    filename: (req, file, cb) => {
      const filename = `${new Date().getTime()}.${file.mimetype.split('/')[1]}`;
      return cb(null, filename);
    },
  }),
};
// app.module.ts
import { ConfigModule, ConfigService } from 'nestjs-config';

@Module({
  imports: [
    // 加载配置文件目录 src/config
    ConfigModule.load(resolve(__dirname, 'config', '**/!(*.d).{ts,js}')),
  ],
  controllers: [],
  providers: [],
})
export class AppModule implements NestModule {}
// upload.controller.ts
import {
  Controller,
  Get,
  Post,
  UseInterceptors,
  UploadedFile,
  UploadedFiles,
  Body,
  Res,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { FileUploadDto } from './dto/upload-file.dto';
import { UploadService } from './upload.service';
import { Response } from 'express';

@Controller('common')
export class UploadController {
  constructor(private readonly uploadService: UploadService) {}

  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  uploadFile(@UploadedFile() file) {
    this.uploadService.uploadSingleFile(file);
    return true;
  }

  // 多文件上传
  @Post('uploads')
  @UseInterceptors(FilesInterceptor('file'))
  uploadMuliFile(@UploadedFiles() files, @Body() body) {
    this.uploadService.UploadMuliFile(files, body);
    return true;
  }

  @Get('export')
  async downloadAll(@Res() res: Response) {
    const { filename, tarStream } = await this.uploadService.downloadAll();
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
    tarStream.pipe(res);
  }
}
// upload.service.ts

import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { join } from 'path';
import { createWriteStream } from 'fs';
import { tar } from 'compressing';
import { ConfigService } from 'nestjs-config';

@Injectable()
export class UploadService {
  constructor(private readonly configService: ConfigService) {}

  uploadSingleFile(file: any) {
    console.log('file', file);
  }
  UploadMuliFile(files: any, body: any) {
    console.log('files', files);
  }
  async downloadAll() {
    const uploadDir = this.configService.get('file').root;
    const tarStream = new tar.Stream();
    await tarStream.addEntry(uploadDir);
    return { filename: 'download.tar', tarStream };
  }
}
// upload.module.ts

import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ConfigService } from 'nestjs-config';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';

@Module({
  imports: [
    MulterModule.registerAsync({
      useFactory: (config: ConfigService) => config.get('file'),
      inject: [ConfigService],
    }),
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}

实现图片随机验证码

nest如何实现图片随机验证码?

这里使用的是svg-captcha这个库,你也可以使用其他的库

yarn add svg-captcha

封装,以便多次调用

src -> utils -> tools.service.ts

import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';

@Injectable()
export class ToolsService {
  async captche(size = 4) {
    const captcha = svgCaptcha.create({  //可配置返回的图片信息
      size, //生成几个验证码
      fontSize: 50, //文字大小
      width: 100,  //宽度
      height: 34,  //高度
      background: '#cc9966',  //背景颜色
    });
    return captcha;
  }
}

在使用的module中引入

import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { ToolsService } from '../../utils/tools.service';

@Module({
  controllers: [UserController],
  providers: [UserService, ToolsService],
})
export class UserModule { }

使用

import { Controller, Get, PostBody } from '@nestjs/common';
import { EmailService } from './email.service';

@Controller('user')
export class UserController{
  constructor(private readonly toolsService: ToolsService,) {}  //注入服务

  @Get('authcode')  //当请求该接口时,返回一张随机图片验证码
  async getCode(@Req() req, @Res() res) {
    const svgCaptcha = await this.toolsService.captche(); //创建验证码
    req.session.code = svgCaptcha.text; //使用session保存验证,用于登陆时验证
    console.log(req.session.code);
    res.type('image/svg+xml'); //指定返回的类型
    res.send(svgCaptcha.data); //给页面返回一张图片
  }

  @Post('/login')
  login(@Body() body, @Session() session) {
  	//验证验证码,由前端传递过来
  	const { code } = body;
  	if(code?.toUpperCase() === session.code?.toUpperCase()){
		console.log(‘验证码通过’)
	}
    return 'hello authcode';
  }
}

前端简单代码

<!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>
    <style>
        form {
            display: flex;
        }

        .input {
            width: 80px;
            height: 32px;
        }

        .verify_img {
            margin: 0px 5px;
        }
    </style>
</head>

<body>
    <h2>随机验证码</h2>
    <form action="/user/login" method="post" enctype="application/x-www-form-urlencoded">
        <input type="text" name='code' class="input" />
        <img class="verify_img" src="/user/code" title="看不清?点击刷新"
            onclick="javascript:this.src='/user/code?t='+Math.random()"> //点击再次生成新的验证码
        <button type="submit">提交</button>
    </form>
</body>

</html>

邮件服务

邮件服务使用文档 nest-modules.github.io/mailer/docs…

// 邮件服务配置
// app.module.ts
import { MailerModule } from '@nestjs-modules/mailer';
import { resolve, join } from 'path';
import { ConfigModule, ConfigService } from 'nestjs-config';

@Module({
  imports: [
    // 加载配置文件目录 src/config
    ConfigModule.load(resolve(__dirname, 'config', '**/!(*.d).{ts,js}')),
    // 邮件服务配置
    MailerModule.forRootAsync({
      useFactory: (config: ConfigService) => config.get('email'),
      inject: [ConfigService],
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule implements NestModule {}
// src/config/email.ts 邮件服务配置
import { join } from 'path';
// npm i ejs -S
import { EjsAdapter } from '@nestjs-modules/mailer/dist/adapters/ejs.adapter';

export default {
  transport: {
    host: 'smtp.qq.com',
    secureConnection: true, // use SSL
    secure: true,
    port: 465,
    ignoreTLS: false,
    auth: {
      user: '123@test.com',
      pass: 'dfafew1',
    },
  },
  defaults: {
    from: '"nestjs" <123@test.com>',
  },
  // preview: true, // 发送邮件前预览
  template: {
    dir: join(__dirname, '../templates/email'), // 邮件模板
    adapter: new EjsAdapter(),
    options: {
      strict: true,
    },
  },
};

邮件服务使用

// email.services.ts
import { Injectable } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';

@Injectable()
export class EmailService {
  // 邮件服务注入
  constructor(private mailerService: MailerService) {}

  async sendEmail() {
    console.log('发送邮件');
    await this.mailerService.sendMail({
      to: 'test@qq.com', // 收件人
      from: '123@test.com', // 发件人
      // subject: '副标题',
      text: 'welcome', // plaintext body
      html: '<h1>hello</h1>', // HTML body content
      // template: 'email', // 邮件模板
      // context: { // 传入邮件模板的data
      //   email: 'test@qq.com',
      // },
    });
    return '发送成功';
  }
}

nest基于possport + jwt做登陆验证

方式与逻辑

  • 基于possport的本地策略和jwt策略
  • 本地策略主要是验证账号和密码是否存在,如果存在就登陆,返回token
  • jwt策略则是验证用户登陆时附带的token是否匹配和有效,如果不匹配和无效则返回401状态码
yarn add @nestjs/jwt @nestjs/passport passport-jwt passport-local passport
yarn add -D @types/passport @types/passport-jwt @types/passport-local

jwt策略 jwt.strategy.ts

// src/modules/auth/jwt.strategy.ts
import { Strategy, ExtractJwt, StrategyOptions } from 'passport-jwt';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromHeader('token'),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret, // 使用密钥解析
    } as StrategyOptions);
  }
	
  //token验证, payload是super中已经解析好的token信息
  async validate(payload: any) {
    return { userId: payload.userId, username: payload.username };
  }
}

本地策略 local.strategy.ts

// src/modules/auth/local.strategy.ts
import { Strategy, IStrategyOptions } from 'passport-local';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthService } from './auth.service';

//本地策略
//PassportStrategy接受两个参数:
//第一个:Strategy,你要用的策略,这里是passport-local,本地策略
//第二个:别名,可选,默认是passport-local的local,用于接口时传递的字符串
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super({
      usernameField: 'username',
      passwordField: 'password',
    } as IStrategyOptions);
  }

  // validate是LocalStrategy的内置方法
  async validate(username: string, password: string): Promise<any> {
    //查询数据库,验证账号密码,并最终返回用户
    return await this.authService.validateUser({ username, password });
  }
}

constants.ts

// src/modules/auth/constants.ts
export const jwtConstants = {
  secret: 'secretKey',
};

使用守卫 auth.controller.ts

// src/modules/auth/auth.controller.ts
import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  // 登录测试 无需token
  @UseGuards(AuthGuard('local')) //本地策略,传递local,执行local里面的validate方法
  @Post('login')
  async login(@Request() req) { //通过req可以获取到validate方法返回的user,传递给login,登陆
    return this.authService.login(req.user);
  }
  // 在需要的地方使用守卫,需要带token才可访问
  @UseGuards(AuthGuard('jwt'))//jwt策略,身份鉴权
  @Get('userInfo')
  getUserInfo(@Request() req) {//通过req获取到被验证后的user,也可以使用装饰器
    return req.user;
  }
}

在module引入jwt配置和数据库查询的实体 auth.module.ts

// src/modules/auth/auth.module.ts
import { LocalStrategy } from './local.strategy';
import { jwtConstants } from './constants';
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { UsersEntity } from '../user/entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forFeature([UsersEntity]),
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '10d' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

auth.service.ts

// src/modules/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { compareSync } from 'bcryptjs';

@Injectable()
export class AuthService {
  constructor(
  	@InjectRepository(UsersEntity),
       private readonly usersRepository: Repository<UsersEntity>,
  	private jwtService: JwtService
    ) {}
  
  validateUser(username: string, password: string) {
    const user = await this.usersRepository.findOne({
      where: { username },
      select: ['username', 'password'],
    });
    if (!user) ToolsService.fail('用户名或密码不正确');
    //使用bcryptjs验证密码
    if (!compareSync(password, user.password)) {
      ToolsService.fail('用户名或密码不正确');
    }
    return user;
  }
  login(user: any) {
    const payload = { username: user.username };  // 把信息存在token
    return {
      token: this.jwtService.sign(payload),
    };
  }
}

最后在app.module.ts中导入即可测试

// app.modules.ts
import { AuthModule } from './modules/auth/auth.module';

@Module({
  imports: [
    ...
    AuthModule, // 导入模块
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule implements NestModule {}

使用postman测试

对数据库的密码加密:md5和bcryptjs

密码加密

一般开发中,是不会有人直接将密码明文直接放到数据库当中的。因为这种做法是非常不安全的,需要对密码进行加密处理。
好处:

  • 预防内部网站运营人员知道用户的密码
  • 预防外部的攻击,尽可能保护用户的隐私

加密方式

  • 使用md5:每次生成的值是一样的,一些网站可以破解,因为每次存储的都是一样的值
  • 使用bcryptjs:每次生成的值是不一样的
yarn add md5

加密

import * as md5 from 'md5';

const passwrod = '123456';
const transP = md5(passwrod);  // 固定值:e10adc3949ba59abbe56e057f20f883e

给密码加点”盐”:目的是混淆密码,其实还是得到固定的值

const passwrod = '123456';
const salt = 'dmxys'
const transP = md5(passwrod + salt);  // 固定值:4e6a2881e83262a72f6c70f48f3e8022

验证密码:先加密,再验证

const passwrod = '123456';
const databasePassword = 'e10adc3949ba59abbe56e057f20f883e'
if (md5(passwrod) === databasePassword ) {
   console.log('密码通过');
}

使用bcryptjs

yarn add bcryptjs
yarn add -D @types/bcryptjs

同一密码,每次生成不一样的值

import { compareSync, hashSync } from 'bcryptjs';

const passwrod = '123456';
const transformPass = hashSync(passwrod);  $2a$10$HgTA1GX8uxbocSQlbQ42/.Y2XnIL7FyfKzn6IC69IXveD6F9LiULS
const transformPass2 = hashSync(passwrod); $2a$10$mynd130vI1vkz4OQ3C.6FeYXGEq24KLUt1CsKN2WZqVsv0tPrtOcW
const transformPass3 = hashSync(passwrod); $2a$10$bOHdFQ4TKBrtcNgmduzD8esds04BoXc0JcrLme68rTeik7U96KBvu

验证密码:使用不同的值 匹配 密码123456,都能通过

const password = '123456';
const databasePassword1 = '$2a$10$HgTA1GX8uxbocSQlbQ42/.Y2XnIL7FyfKzn6IC69IXveD6F9LiULS'
const databasePassword2 = '$2a$10$mynd130vI1vkz4OQ3C.6FeYXGEq24KLUt1CsKN2WZqVsv0tPrtOcW'
const databasePassword3 = '$2a$10$bOHdFQ4TKBrtcNgmduzD8esds04BoXc0JcrLme68rTeik7U96KBvu'

if (compareSync(password, databasePassword3)) {
   console.log('密码通过');
}

推荐使用bcryptjs,算法要比md5高级

角色权限

RBAC

    • RBAC是基于角色的权限访问控制(Role-Based Access Control)一种数据库设计思想,根据设计数据库设计方案,完成项目的权限管理
    • 在RBAC中,有3个基础组成部分,分别是:用户角色权限,权限与角色相关联,用户通过成为适当角色而得到这些角色的权限
  • 权限:具备操作某个事务的能力
  • 角色:一系列权限的集合

如:一般的管理系统中:
销售人员:仅仅可以查看商品信息
运营人员:可以查看,修改商品信息
管理人员:可以查看,修改,删除,以及修改员工权限等等
管理人员只要为每个员工账号分配对应的角色,登陆操作时就只能执行对应的权限或看到对应的页面

权限类型

  • 展示(菜单),如:显示用户列表,显示删除按钮等等…
  • 操作(功能),如:增删改查,上传下载,发布公告,发起活动等等…

数据库设计

数据库设计:可简单,可复杂,几个人使用的系统和几千人使用的系统是不一样的
小型项目:用户表,权限表
中型项目:用户表,角色表,权限表
大型项目:用户表,用户分组表,角色表,权限表,菜单表…

没有角色的设计

只有用户表,菜单表,两者是多对多关系,有一个关联表

缺点:

  • 新建一个用户时,在用户表中添加一条数据
  • 新建一个用户时,在关联表中添加N条数据
  • 每次新建一个用户需要添加1+N(关联几个)条数据
  • 如果有100个用户,每个用户100个权限,那需要添加10000条数据

基于RBAC的设计

用户表和角色表的关系设计:

如果你希望一个用户可以有多个角色,如:一个人即是销售总监,也是人事管理,就设计多对多关系
如果你希望一个用户只能有一个角色,就设计一对多,多对一关系

角色表和权限表的关系设计:

一个角色可以拥有多个权限,一个权限被多个角色使用,设计多对多关系

多对多关系设计

用户表与角色表是多对多关系,角色表与菜单表是多对多关系

更加复杂的设计

实现流程

  1. 数据表设计
  2. 实现角色的增删改查
  3. 实现用户的增删改查,增加和修改用户的时候需要选择角色
  4. 实现权限的增删改查
  5. 实现角色与授权的关联
  6. 判断当前登录的用户是否有访问菜单的权限
  7. 根据当前登录账户的角色信息动态显示左侧菜单(前端)

代码实现

这里将实现一个用户,部门,角色,权限的例子:
用户通过成为部门的一员,则拥有部门普通角色的权限,还可以单独给用户设置角色,通过角色,获取权限。
权限模块包括,模块,菜单,操作,通过type区分类型,这里就不再拆分。

关系总览:

  • 用户 - 部门:一对多关系,这里设计用户只能加入一个部门,如果设计可以加入多个部门,设计为多对多关系
  • 用户 - 角色:多对多关系,可以给用户设置多个角色
  • 角色 - 部门:多对多关系,一个部门多个角色
  • 角色 - 权限:多对多关系,一个角色拥有多个权限,一个权限被多个角色使用

数据库实体设计

用户

import {
  Column,
  Entity,
  ManyToMany,
  ManyToOne,
  JoinColumn,
  JoinTable,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { RoleEntity } from '../../role/entities/role.entity';
import { DepartmentEntity } from '../../department/entities/department.entity';

@Entity({ name: 'user' })
export class UsersEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: 'varchar',
    length: 30,
    nullable: false,
    unique: true,
  })
  username: string;

  @Column({
    type: 'varchar',
    name: 'password',
    length: 100,
    nullable: false,
    select: false,
    comment: '密码',
  })
  password: string;

  @ManyToMany(() => RoleEntity, (role) => role.users)
  @JoinTable({ name: 'user_role' })
  roles: RoleEntity[];

  @ManyToOne(() => DepartmentEntity, (department) => department.users)
  @JoinColumn({ name: 'department_id' })
  department: DepartmentEntity;
}

角色

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToMany,
  JoinTable,
} from 'typeorm';
import { UsersEntity } from '../../user/entities/user.entity';
import { DepartmentEntity } from '../../department/entities/department.entity';
import { AccessEntity } from '../../access/entities/access.entity';

@Entity({ name: 'role' })
export class RoleEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 30 })
  rolename: string;

  @ManyToMany(() => UsersEntity, (user) => user.roles)
  users: UsersEntity[];

  @ManyToMany(() => DepartmentEntity, (department) => department.roles)
  department: DepartmentEntity[];

  @ManyToMany(() => AccessEntity, (access) => access.roles)
  @JoinTable({ name: 'role_access' })
  access: AccessEntity[];
}

部门

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToMany,
  OneToMany,
  JoinTable,
} from 'typeorm';
import { UsersEntity } from '../../user/entities/user.entity';
import { RoleEntity } from '../../role/entities/role.entity';

@Entity({ name: 'department' })
export class DepartmentEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 30 })
  departmentname: string;

  @OneToMany(() => UsersEntity, (user) => user.department)
  users: UsersEntity[];

  @ManyToMany(() => RoleEntity, (role) => role.department)
  @JoinTable({ name: 'department_role' })
  roles: RoleEntity[];
}

权限

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  Tree,
  TreeChildren,
  TreeParent,
  ManyToMany,
} from 'typeorm';
import { RoleEntity } from '../../role/entities/role.entity';

@Entity({ name: 'access' })
@Tree('closure-table')
export class AccessEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 30, comment: '模块' })
  module_name: string;

  @Column({ type: 'varchar', length: 30, nullable: true, comment: '操作' })
  action_name: string;

  @Column({ type: 'tinyint', comment: '类型:1:模块,2:菜单,3:操作' })
  type: number;

  @Column({ type: 'text', nullable: true, comment: '操作地址' })
  url: string;

  @TreeParent()
  parentCategory: AccessEntity;

  @TreeChildren()
  childCategorys: AccessEntity[];

  @ManyToMany(() => RoleEntity, (role) => role.access)
  roles: RoleEntity[];
}

接口实现

由于要实现很多接口,这里只说明一部分,其实都是数据库的操作,所有接口如下:

根据用户的id获取信息:id,用户名,部门名,角色,这些信息在做用户登陆时传递到token中。

这里设计的是:创建用户时,添加部门,就会成为部门的普通角色,也可单独设置角色,但不是每个用户都有单独的角色。

async getUserinfoByUid(uid: number) {
	获取用户
    const user = await this.usersRepository.findOne(
      { id: uid },
      { relations: ['roles'] },
    );
    if (!user) ToolsService.fail('用户ID不存在');

    const sql = `
    select 
    user.id as user_id, user.username, user.department_id, department.departmentname, role.id as role_id, rolename
    from
    user, department, role, department_role as dr
    where 
    user.department_id = department.id
    and department.id = dr.departmentId
    and role.id = dr.roleId
    and user.id = ${uid}`;
    
    const result = await this.usersRepository.query(sql);
    const userinfo = result[0];
    
    const userObj = {
      user_id: userinfo.user_id,
      username: userinfo.username,
      department_id: userinfo.department_id,
      departmentname: userinfo.departmentname,
      roles: [{ id: userinfo.role_id, rolename: userinfo.rolename }],
    };

	// 如果用户的角色roles有值,证明单独设置了角色,所以需要拼接起来
    if (user.roles.length > 0) {
      const _user = JSON.parse(JSON.stringify(user));
      userObj.roles = [...userObj.roles, ..._user.roles];
    }
    return userObj;
}

// 接口请求结果:
{
    "status": 200,
    "message": "请求成功",
    "data": {
        "user_id": 1,
        "username": "admin",
        "department_id": 1,
        "departmentname": "销售部",
        "roles": [
            {
                "id": 1,
                "rolename": "销售部员工"
            },
            {
                "id": 5,
                "rolename": "admin"
            }
        ]
    }
}

结合possport + jwt 做用户登陆授权验证

在验证账户密码通过后,possport 返回用户,然后根据用户id获取用户信息,存储token,用于路由守卫,还可以使用redis存储,以作他用。

async login(user: any): Promise<any> {
    const { id } = user;
    const userResult = await this.userService.getUserinfoByUid(id);
    const access_token = this.jwtService.sign(userResult);
    await this.redisService.set(`user-token-${id}`, access_token, 60 * 60 * 24);
    return { access_token };
}

{
    "status": 200,
    "message": "请求成功",
    "data": {
        "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZGVwYXJ0bWVudF9pZCI6MSwiZGVwYXJ0bWVudG5hbWUiOiLplIDllK7pg6giLCJyb2xlcyI6W3siaWQiOjEsInJvbGVuYW1lIjoi6ZSA5ZSu6YOo5ZGY5belIn0seyJpZCI6NSwicm9sZW5hbWUiOiJhZG1pbiJ9XSwiaWF0IjoxNjIxNjA1Nzg5LCJleHAiOjE2MjE2OTIxODl9.VIp0MdzSPM13eq1Bn8bB9Iu_SLKy4yoMU2N4uwgWDls"
    }
}

后端的权限访问

使用守卫,装饰器,结合token,验证访问权限

逻辑:

  • 第一步:在controller使用自定义守卫装饰接口路径,在请求该接口路径时,全部进入守卫逻辑
  • 第二步:使用自定义装饰器装饰特定接口,传递角色,自定义守卫会使用反射器获取该值,以判断该用户是否有权限

如下:findOne接口使用了自定义装饰器装饰接口,意思是只能admin来访问

import {
  Controller,
  Get,
  Body,
  Patch,
  Post,
  Param,
  Delete,
  UseGuards,
  ParseIntPipe,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { AuthGuard } from '../../common/guard/auth.guard';
import { Roles } from '../../common/decorator/role.decorator';

@UseGuards(AuthGuard)   // 自定义守卫
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) { }

  @Get()
  async findAll() {
    const [data, count] = await this.userService.findAll();
    return { count, data };
  }

  @Get(':id')
  @Roles('admin')  // 自定义装饰器
  async findOne(@Param('id', new ParseIntPipe()) id: number) {
    return await this.userService.findOne(id);
  }
}

装饰器

import { SetMetadata } from '@nestjs/common';

// SetMetadata作用:将获取到的值,设置到元数据中,然后守卫通过反射器才能获取到值
export const Roles = (...args: string[]) => SetMetadata('roles', args);

自定义守卫

返回true则有访问权限,返回false则直接报403

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Reflector } from '@nestjs/core'; // 反射器,作用与自定义装饰器桥接
import { ToolsService } from '../../utils/tools.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService,
  ) { }

  // 白名单数组
  private whiteUrlList: string[] = [];

  // 验证该次请求是否为白名单内的路由
  private isWhiteUrl(urlList: string[], url: string): boolean {
    if (urlList.includes(url)) {
      return true;
    }
    return false;
  }

  canActivate(context: ExecutionContext): boolean {
    // 获取请求对象
    const request = context.switchToHttp().getRequest();

    // 验证是否是白名单内的路由
    if (this.isWhiteUrl(this.whiteUrlList, request.url)) return true;

    // 获取请求头中的token字段,解析获取存储在token的用户信息
    const token = context.switchToRpc().getData().headers.token;
    const user: any = this.jwtService.decode(token);
    if (!user) ToolsService.fail('token获取失败,请传递token或书写正确');

    // 使用反射器,配合装饰器使用,获取装饰器传递过来的数据
    const authRoles = this.reflector.get<string[]>(
      'roles',
      context.getHandler(),
    );

    // 如果没有使用roles装饰,就获取不到值,就不鉴权,等于白名单
    if (!authRoles) return true;

    // 如果用户的所属角色与装饰器传递过来的值匹配则通过,否则不通过
    const userRoles = user.roles;
    for (let i = 0; i < userRoles.length; i++) {
      if (authRoles.includes(userRoles[i].rolename)) {
        return true;
      }
    }
    return false;
  }
}

简单测试

两个用户,分别对应不同的角色,分别请求user的findOne接口
用户1:销售部员工和admin
用户2:人事部员工

用户1:销售部员工和admin
{
    "status": 200,
    "message": "请求成功",
    "data": {
        "user_id": 1,
        "username": "admin",
        "department_id": 1,
        "departmentname": "销售部",
        "roles": [
            {
                "id": 1,
                "rolename": "销售部员工"
            },
            {
                "id": 5,
                "rolename": "admin"
            }
        ]
    }
}

用户2:人事部员工
{
    "status": 200,
    "message": "请求成功",
    "data": {
        "user_id": 2,
        "username": "admin2",
        "department_id": 2,
        "departmentname": "人事部",
        "roles": [
            {
                "id": 3,
                "rolename": "人事部员工"
            }
        ]
    }
}


不出意外的话:2号用户的请求结果
{
    "status": 403,
    "message": "Forbidden resource",
    "error": "Forbidden",
    "path": "/user/1",
    "timestamp": "2021-05-21T14:44:04.954Z"
}

前端的权限访问则是通过权限表url和type来处理

定时任务

nest如何开启定时任务?

定时任务场景

每天定时更新,定时发送邮件

没有controller,因为定时任务是自动完成的

yarn add @nestjs/schedule
// src/tasks/task.module.ts
import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service';

@Module({
  providers: [TasksService],
})
export class TasksModule {}

在这里编写你的定时任务

// src/tasks/task.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { Cron, Interval, Timeout } from '@nestjs/schedule';

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);

  @Cron('45 * * * * *')  每隔45秒执行一次
  handleCron() {
    this.logger.debug('Called when the second is 45');
  }

  @Interval(10000)  每隔10秒执行一次
  handleInterval() {
    this.logger.debug('Called every 10 seconds');
  }

  @Timeout(5000)  5秒只执行一次
  handleTimeout() {
    this.logger.debug('Called once after 5 seconds');
  }
}

自定义定时时间

* * * * * * 分别对应的意思:
第1个星:秒
第2个星:分钟
第3个星:小时
第4个星:一个月中的第几天
第5个星:月
第6个星:一个星期中的第几天

如:
45 * * * * *:每隔45秒执行一次

挂载-使用

// app.module.ts

import { TasksModule } from './tasks/task.module';
import { ScheduleModule } from '@nestjs/schedule';

imports: [
    ConfigModule.load(path.resolve(__dirname, 'config', '**/!(*.d).{ts,js}')),
    ScheduleModule.forRoot(),
    TasksModule,
  ],

接入Swagger接口文档

  • 优点:不用写接口文档,在线生成,自动生成,可操作数据库,完美配合dto
  • 缺点:多一些代码,显得有点乱,习惯就好
yarn add @nestjs/swagger swagger-ui-express -D
// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  // 创建实例
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  
  // 创建接口文档服务
  const options = new DocumentBuilder()
    .addBearerAuth() // token认证,输入token才可以访问文档
    .setTitle('接口文档')
    .setDescription('接口文档介绍') // 文档介绍
    .addServer('http://localhost:9000', '开发环境')
    .addServer('https://test.com/release', '正式环境')
    .setVersion('1.0.0') // 文档版本
    .setContact('poetry', '', 'test@qq.com')
    .build();
  // 为了创建完整的文档(具有定义的HTTP路由),我们使用类的createDocument()方法SwaggerModule。此方法带有两个参数,分别是应用程序实例和基本Swagger选项。
  const document = SwaggerModule.createDocument(app, options, {
    extraModels: [], // 这里导入模型
  });
  // 启动swagger
  SwaggerModule.setup('api-docs', app, document); // 访问路径 http://localhost:9000/api-docs
  
  // 启动端口
  const PORT = process.env.PORT || 9000;
  await app.listen(PORT, () =>
    Logger.log(`服务已经启动 http://localhost:${PORT}`),
  );
}
bootstrap();

swagger装饰器

swagger.io/

@ApiTags('user')   // 设置模块接口的分类,不设置默认分配到default
@ApiOperation({ summary: '标题', description: '详细描述'})  // 单个接口描述

// 传参
@ApiQuery({ name: 'limit', required: true})    // query参数
@ApiQuery({ name: 'role', enum: UserRole })    // query参数
@ApiParam({ name: 'id' })      // parma参数
@ApiBody({ type: UserCreateDTO, description: '输入用户名和密码' })   // 请求体

// 响应
@ApiResponse({
    status: 200,
    description: '成功返回200,失败返回400',
    type: UserCreateDTO,
})

// 验证
@ApiProperty({ example: 'Kitty', description: 'The name of the Cat' })
name: string;

controller引入@nestjs/swagger, 并配置@ApiBody()和 @ApiParam()不写也是可以的

// user.controller.ts

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Query,
  Param,
  Delete,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';
import {
  ApiOperation,
  ApiTags,
  ApiQuery,
  ApiBody,
  ApiResponse,
} from '@nestjs/swagger';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('user')
@ApiTags('user')  // 设置分类
export class UserController {
  constructor(private readonly userService: UserService) { }

  @Post()
  @ApiOperation({ summary: '创建用户', description: '创建用户' })  // 该接口
  @HttpCode(HttpStatus.OK)
  async create(@Body() user: CreateUserDto) {
    return await this.userService.create(user);
  }

  @Get()
  @ApiOperation({ summary: '查找全部用户', description: '创建用户' })
  @ApiQuery({ name: 'limit', required: true })  请求参数
  @ApiQuery({ name: 'offset', required: true }) 请求参数
  async findAll(@Query() query) {
    console.log(query);
    const [data, count] = await this.userService.findAll(query);
    return { count, data };
  }

  @Get(':id')
  @ApiOperation({ summary: '根据ID查找用户' })
  async findOne(@Param('id', new ParseIntPipe()) id: number) {
    return await this.userService.findOne(id);
  }

  @Patch(':id')
  @ApiOperation({ summary: '更新用户' })
  @ApiBody({ type: UpdateUserDto, description: '参数可选' })  请求体
  @ApiResponse({   响应示例
    status: 200,
    description: '成功返回200,失败返回400',
    type: UpdateUserDto,
  })
  async update(
    @Param('id', new ParseIntPipe()) id: number,
    @Body() user: UpdateUserDto,
  ) {
    return await this.userService.update(id, user);
  }

  @Delete(':id')
  @ApiOperation({ summary: '删除用户' })
  async remove(@Param('id', new ParseIntPipe()) id: number) {
    return await this.userService.remove(id);
  }
}

编写dto,引入@nestjs/swagger

创建

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

export class CreateUserDto {
  @ApiProperty({ example: 'kitty', description: '用户名' })  添加这里即可
  @IsNotEmpty({ message: '用户名不能为空' })
  username: string;

  @ApiProperty({ example: '12345678', description: '密码' })
  @IsNotEmpty({ message: '密码不能为空' })
  @MinLength(6, {
    message: '密码长度不能小于6位',
  })
  @MaxLength(20, {
    message: '密码长度不能超过20位',
  })
  password: string;
}

更新

import {
  IsEnum,
  MinLength,
  MaxLength,
  IsOptional,
  ValidateIf,
  IsEmail,
  IsMobilePhone,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';

export class UpdateUserDto {
  @ApiProperty({ description: '用户名', example: 'kitty', required: false })  不是必选的
  @IsOptional()
  username: string;

  @ApiProperty({ description: '密码', example: '12345678', required: false })
  @IsOptional()
  @MinLength(6, {
    message: '密码长度不能小于6位',
  })
  @MaxLength(20, {
    message: '密码长度不能超过20位',
  })
  password: string;

  @ApiProperty({
    description: '邮箱',
    example: 'llovenest@163.com',
    required: false,
  })
  @IsOptional()
  @IsEmail({}, { message: '邮箱格式错误' })
  @ValidateIf((o) => o.username === 'admin')
  email: string;

  @ApiProperty({
    description: '手机号码',
    example: '13866668888',
    required: false,
  })
  @IsOptional()
  @IsMobilePhone('zh-CN', {}, { message: '手机号码格式错误' })
  mobile: string;

  @ApiProperty({
    description: '性别',
    example: 'female',
    required: false,
    enum: ['male', 'female'],
  })
  @IsOptional()
  @IsEnum(['male', 'female'], {
    message: 'gender只能传入字符串male或female',
  })
  gender: string;

  @ApiProperty({
    description: '状态',
    example: 1,
    required: false,
    enum: [0, 1],
  })
  @IsOptional()
  @IsEnum(
    { 禁用: 0, 可用: 1 },
    {
      message: 'status只能传入数字0或1',
    },
  )
  @Type(() => Number)
  status: number;
}

打开:localhost:3000/api-docs,开始测试接口