Nest实战-会议预定系统

141 阅读5分钟

需求&技术栈

meeting_book_system.jpg

数据库设计

会议管理系统.jpg

接口列表

image.png

用户模块

注册Typeorm模块

初始化项目

nest new meeting_root_booking_system_backend -p pnpm

在Docker里启动一个mysql容器

安装数据库相关依赖包:

pnpm i @nestjs/typeorm typeorm mysql2

因为之后我们会频繁的使用nest提供的cli命令快速新建文件,不涉及测试文件,所以我们在 nest-cli.json里直接配置 spec:false


{

"$schema": "https://json.schemastore.org/nest-cli",

"collection": "@nestjs/schematics",

"sourceRoot": "src",

"compilerOptions": {

"deleteOutDir": true

},

"generateOptions": {

"spec": false

}

}

引入 TypeOrmModule


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

import { TypeOrmModule } from '@nestjs/typeorm';

import { AppController } from './app.controller';

import { AppService } from './app.service';

  


@Module({

imports: [

TypeOrmModule.forRoot({

type: 'mysql',

host: 'localhost',

port: 3306,

database: 'meeting_room',

username: 'root',

password: '123456',

synchronize: true,

logging: true,

poolSize: 10,

connectorPackage: 'mysql2',

entities: [],

extra: {

autoPlugin: 'sha256_password',

},

}),

],

controllers: [AppController],

providers: [AppService],

})

export class AppModule {}

用户模块-entity

  1. 生成 user 模块

nest g resource user

image.png

在 user/entities 下新建三个实体 User、Role、Permission。参考数据库设计里的关系表。

用户user表

角色role表

image.png

权限permission表

image.png


import {

Column,

CreateDateColumn,

Entity,

JoinTable,

ManyToMany,

PrimaryGeneratedColumn,

UpdateDateColumn,

} from 'typeorm';

import { Role } from './role.entity';

  


@Entity({

name: 'users',

})

export class User {

@PrimaryGeneratedColumn()

id: number;

  


@Column({

length: 50,

comment: '用户名',

})

username: string;

  


@Column({

length: 50,

comment: '密码',

})

password: string;

  


@Column({

name: 'nick_name',

length: 50,

comment: '昵称',

})

nickName: string;

  


@Column({

comment: '邮箱',

length: 50,

})

email: string;

  


@Column({

comment: '头像',

length: 100,

nullable: true,

})

headPic: string;

  


@Column({

comment: '手机号',

length: 20,

nullable: true,

})

phoneNumber: string;

  


@Column({

comment: '是否冻结',

default: false,

})

isFrozen: boolean;

  


@Column({

comment: '是否是管理员',

default: false,

})

isAdmin: boolean;

  


@CreateDateColumn()

createTime: Date;

  


@UpdateDateColumn()

updateTime: Date;

  


@ManyToMany(() => Role)

@JoinTable({

name: 'user_roles',

})

roles: Role[];

}


import {

Column,

Entity,

JoinTable,

ManyToMany,

PrimaryGeneratedColumn,

} from 'typeorm';

import { Permission } from './permission.entity';

  


@Entity({

name: 'roles',

})

export class Role {

@PrimaryGeneratedColumn()

id: number;

  


@Column({

length: 20,

comment: '角色名',

})

name: string;

  


@ManyToMany(() => Permission)

@JoinTable({

name: 'role_permissions',

})

permissions: Permission[];

}


import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

  


@Entity({

name: 'permissions',

})

export class Permission {

@PrimaryGeneratedColumn()

id: number;

  


@Column({

length: 20,

comment: '权限代码',

})

code: string;

  


@Column({

length: 100,

comment: '权限描述',

})

description: string;

}

在TypeormModule entities配置里引入这三个实体


...

import { Permission } from './user/entities/permission.entity';

import { Role } from './user/entities/role.entity';

import { User } from './user/entities/user.entity';

  


@Module({

imports: [

TypeOrmModule.forRoot({

...

entities: [User, Role, Permission],

...

}),

UserModule,

],

...

})

export class AppModule {}

运行项目

nest start --watch 或者

pnpm run dev --watch

命令行会把msyql执行语句打印出来。

image.png

在数据库里查看,表已经新建好了。

image.png

:::danger

MySQL里没有boolean类型,所以使用的tinyint存储,1-true,0-false。TypeORM会自动映射为boolean。

:::

image.png

两个中间表 user_roles、role_permissions

image.png

image.png

准备工作就绪,可以开始业务代码的开发。

按照 用户模块的接口列表,逐一实现。

用户注册

在 user.controller.ts 新增 register(post)方法


import { Controller, Post, Body } from '@nestjs/common';

import { UserService } from './user.service';

import { RegisterUserDto } from './dto/register-user.dto';

  


@Controller('user')

export class UserController {

constructor(private readonly userService: UserService) {}

  


@Post('register')

async register(@Body() dto: RegisterUserDto) {

console.log('注册填的信息:', dto);

return 'success';

}

}


export class RegisterUserDto {

username: string;

  


nickName: string;

  


password: string;

  


email: string;

  


captcha: string;

}

postman测试一下

image.pngimage.png

校验

接着我们需要对发送的请求体做校验,安装两个包:

pnpm i class-validator class-transform --save

全局开启ValidationPipe (NestJS官网使用class-validator docs.nestjs.com/pipes#class…)


import { NestFactory } from '@nestjs/core';

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

import { AppModule } from './app.module';

  


async function bootstrap() {

const app = await NestFactory.create(AppModule);

  


app.useGlobalPipes(new ValidationPipe());

await app.listen(3000);

}

bootstrap();

正如这句话描述

Once these are installed, we can add a few decorators to the CreateCatDto class. Here we see a significant advantage of this technique: the CreateCatDto class remains the single source of truth for our Post body object (rather than having to create a separate validation class).

不需要专门新建一个验证类,NestJS的Pipe结合基于装饰器的验证非常方便且强大。

给RegisterUserDto 添加校验


import { IsString, IsNotEmpty, MinLength, IsEmail } from 'class-validator';

  


export class RegisterUserDto {

@IsString()

@IsNotEmpty({ message: '用户名不能为空' })

username: string;

  


@IsString()

@IsNotEmpty({ message: '昵称不能为空' })

nickName: string;

  


@IsNotEmpty({ message: '密码不能为空' })

@MinLength(6, { message: '密码至少6位' })

password: string;

  


@IsNotEmpty({ message: '邮箱不能为空' })

@IsEmail({}, { message: '邮箱格式不正确' })

email: string;

  


@IsNotEmpty({ message: '验证码不能为空' })

captcha: string;

}

在postman里重新测试一下

image.png

在user.service.ts实现具体的注册业务逻辑

  1. 创建logger

  2. 注入数据库Repository


import { Injectable, Logger } from '@nestjs/common';

import { InjectRepository } from '@nestjs/typeorm';

import { Repository } from 'typeorm';

import { User } from './entities/user.entity';

import { RegisterUserDto } from './dto/register-user.dto';

  


@Injectable()

export class UserService {

logger = new Logger();

  


@InjectRepository(User)

private userRepository: Repository<User>;

  


async register(params: RegisterUserDto) {}

}

这里需要在 UserModule里注册Typeorm并注入User实体。否则会报以下错误

image.png


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

import { TypeOrmModule } from '@nestjs/typeorm';

import { UserService } from './user.service';

import { UserController } from './user.controller';

import { User } from './entities/user.entity';

  


@Module({

imports: [TypeOrmModule.forFeature([User])],

controllers: [UserController],

providers: [UserService],

})

export class UserModule {}

注册逻辑

image.png

安装ioredis

pnpm i ioredis --save

pnpm i @types/ioredis -D

新建redis模块

nest g module redis

nest g service redis

image.png

Docker启动redis容器

image.png

新建连接的redis 的 provider redis.providers.ts


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

import { Redis } from 'ioredis';

import { REDIS_CLIENT } from './redis.constants';

  


export type RedisClient = Redis;

  


export const RedisProviders: Provider[] = [

{

provide: REDIS_CLIENT,

useFactory: (): RedisClient => {

const redis = new Redis({

host: 'localhost',

port: 6379,

db: 1,

});

return redis;

},

},

];

  



export const REDIS_CLIENT = 'REDIS_CLIENT';

在redis.module.ts引入provider


import { Module, Global } from '@nestjs/common';

import { RedisService } from './redis.service';

import { RedisProviders } from './redis.providers';

  


@Global()

@Module({

providers: [...RedisProviders, RedisService],

exports: [...RedisProviders, RedisService],

})

export class RedisModule {}

这里把 redis模块设置为全局的,方便其他地方使用,不需要额外再导入redis模块。

redis.service添加两个方法,方便获取和设置redis


import { Injectable, Inject } from '@nestjs/common';

import { REDIS_CLIENT } from './redis.constants';

import { RedisClient } from './redis.providers';

  


@Injectable()

export class RedisService {

@Inject(REDIS_CLIENT)

private redisClient: RedisClient;

  


async get(key: string) {

return await this.redisClient.get(key);

}

  


async set(key: string, value: string | number | Buffer, ttl?: number) {

await this.redisClient.set(key, value);

if (ttl) {

await this.redisClient.expire(key, ttl);

}

}

}

按照上面的注册逻辑开始代码的编写:


import {

Injectable,

Logger,

Inject,

HttpException,

HttpStatus,

} from '@nestjs/common';

import { InjectRepository } from '@nestjs/typeorm';

import { Repository } from 'typeorm';

import { User } from './entities/user.entity';

import { RegisterUserDto } from './dto/register-user.dto';

import { RedisService } from 'src/redis/redis.service';

import { md5 } from 'src/utils';

  


@Injectable()

export class UserService {

logger = new Logger();

  


@InjectRepository(User)

private userRepository: Repository<User>;

  


@Inject(RedisService)

private redisService: RedisService;

  


async register(params: RegisterUserDto) {

const captcha = await this.redisService.get(`captcha_${params.email}`);

if (!captcha) {

throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);

}

  


const user = await this.userRepository.findOneBy({

username: params.username,

});

  


if (user) {

throw new HttpException('用户已存在', HttpStatus.BAD_REQUEST);

}

  


const { username, nickName, password, email } = params;

const newUser = new User();

newUser.username = username;

newUser.nickName = nickName;

newUser.password = md5(password);

newUser.email = email;

  


try {

await this.userRepository.save(newUser);

return '注册成功';

} catch (error) {

this.logger.error(error, UserService);

return '注册失败';

}

}

}

修改下 redis.controller里的代码


import { Controller, Post, Body } from '@nestjs/common';

import { UserService } from './user.service';

import { RegisterUserDto } from './dto/register-user.dto';

  


@Controller('user')

export class UserController {

constructor(private readonly userService: UserService) {}

  


@Post('register')

async register(@Body() dto: RegisterUserDto) {

return await this.userService.register(dto);

}

}

开始测试,目前我们没有生成验证码的逻辑,所以先在redis手动存一下验证码

image.png

postman测一下

image.png

成功后在mysql里查询下是否已经有该用户

image.png

我们使用email发送&接收验证码。新建email模块

nest g resource email

安装发送邮件的依赖包

pnpm i nodemailer --save


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

import { Transporter, createTransport } from 'nodemailer';

  


@Injectable()

export class EmailService {

transporter: Transporter;

  


constructor() {

this.transporter = createTransport({

host: 'smtp.qq.com',

port: 587,

secure: false,

auth: {

user: '你的邮箱',

pass: '授权码',

},

});

}

  


async sendEmail({ to, subject, html }) {

await this.transporter.sendMail({

from: {

name: '会议室预定系统',

address: '你的邮箱',

},

to,

subject,

html,

});

}

}

设置 EmailModule为全局的,并导出 EmailService.


import { Module, Global } from '@nestjs/common';

import { EmailService } from './email.service';

import { EmailController } from './email.controller';

  


@Global()

@Module({

controllers: [EmailController],

providers: [EmailService],

exports: [EmailService],

})

export class EmailModule {}

在UserController新增一个get发送邮件的路由


import { Controller, Inject, Get, Query } from '@nestjs/common';

import { EmailService } from './email.service';

import { RedisService } from 'src/redis/redis.service';

  


@Controller('email')

export class EmailController {

constructor(private readonly emailService: EmailService) {}

  


@Inject(RedisService)

private redisService: RedisService;

  


@Get('register-captcha')

async captcha(@Query('address') address: string) {

const code = Math.random().toString().slice(2, 8);

  


await this.redisService.set(`captcha_${address}`, code, 5 * 30);

  


await this.emailService.sendEmail({

to: address,

subject: '注册验证码',

html: `<strong>你的注册验证码是 ${code}</strong>`,

});

}

}

postman测试下

收件箱看下收到

redis也存储了对应的key/value

数据库动态配置

我们使用yaml文件

根目录下新建 config/config.yaml

db:
  mysql:
    host: 'localhost'
    port: 3306
    database: 'meeting_room_booking_system'
    username: 'root'
    password: '123456'

  redis:
    host: 'localhost'
    port: 6379
    db: 1

# nodemailer 相关配置
email:
  host: 'smtp.qq.com'
  port: 587
  user: ''
  pass: ''

# nest 服务配置
SERVER_PORT: 3000

安装 

pnpm i @nestjs/config --save

pnpm i js-yaml

pnpm i @types/js-yaml -D

新建 src/configuaration.ts 写读取yaml文件逻辑

import * as yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { join } from 'path';

const YAML_CONFIG_FILENAME = 'config/config.yaml';

export default () => {
  return yaml.load(readFileSync(join(YAML_CONFIG_FILENAME), 'utf-8')) as Record<string, any>;
};

在 AppModule引入

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { Permission } from './user/entities/permission.entity';
import { Role } from './user/entities/role.entity';
import { User } from './user/entities/user.entity';
import { RedisModule } from './redis/redis.module';
import { EmailModule } from './email/email.module';
import configuration from './configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      ignoreEnvFile: true,
      isGlobal: true,
      load: [configuration],
    }),
    TypeOrmModule.forRootAsync({
      useFactory(configService: ConfigService) {
        return {
          type: 'mysql',
          host: configService.get('db.mysql.host'),
          port: configService.get('db.mysql.port'),
          database: configService.get('db.mysql.database'),
          username: configService.get('db.mysql.username'),
          password: configService.get('db.mysql.password'),
          synchronize: true,
          logging: true,
          poolSize: 10,
          connectorPackage: 'mysql2',
          entities: [User, Role, Permission],
          extra: {
            autoPlugin: 'sha256_password',
          },
        };
      },
      inject: [ConfigService],
    }),
    UserModule,
    RedisModule,
    EmailModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

测试一下

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    private readonly configService: ConfigService,
  ) {}

  @Get()
  getHello(): string {
    console.log(this.configService.get('db.redis'));
    return this.appService.getHello();
  }
}

浏览器访问 http://localhost:3000/

可以在命令行看已经正常打印

把涉及到这些配置的代码里都改一下

import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe());

  const configService = app.get(ConfigService);
  await app.listen(configService.get('SERVER_PORT'));
}
bootstrap();
import { Injectable } from '@nestjs/common';
import { Transporter, createTransport } from 'nodemailer';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class EmailService {
  transporter: Transporter;

  constructor(private readonly configService: ConfigService) {
    this.transporter = createTransport({
      host: configService.get<string>('email.host'),
      port: 587,
      secure: false,
      auth: {
        user: configService.get<string>('email.user'),
        pass: configService.get<string>('email.pass'),
      },
    });
  }

  async sendEmail({ to, subject, html }) {
    await this.transporter.sendMail({
      from: {
        name: '会议室预定系统',
        address: this.configService.get<string>('email.user'),
      },
      to,
      subject,
      html,
    });
  }
}
import { ConfigService } from '@nestjs/config';
import { Provider } from '@nestjs/common';
import { Redis } from 'ioredis';
import { REDIS_CLIENT } from './redis.constants';

export type RedisClient = Redis;

export const RedisProviders: Provider[] = [
  {
    provide: REDIS_CLIENT,
    useFactory: (configService: ConfigService): RedisClient => {
      const redis = new Redis({
        host: configService.get<string>('db.redis.host'),
        port: configService.get('db.redis.port'),
        db: configService.get('db.redis.db'),
      });
      return redis;
    },
    inject: [ConfigService],
  },
];

用户登录

初始化

先初始化用户、角色和权限的数据。在UserService注入Role、Permission的Repository。

import {
  Injectable,
  Logger,
  Inject,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { Role } from './entities/role.entity'
import { Permission } from './entities/permission.entity'

@Injectable()
export class UserService {
  logger = new Logger();

  @InjectRepository(User)
  private userRepository: Repository<User>;

  @InjectRepository(Role)
  private roleRepository: Repository<Role>;

  @InjectRepository(Permission)
  private permissionRepository: Repository<Permission>;
  ...
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
import { Role } from './entities/role.entity'
import { Permission } from './entities/permission.entity'

@Module({
  imports: [TypeOrmModule.forFeature([User, Role, Permission])],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

在 UserService 添加initData方法用于初始化一些数据:

async initData() {
    const user1 = new User();
    user1.username = 'xiaozhan';
    user1.password = md5('111111');
    user1.email = 'xiaozhan@qq.com';
    user1.isAdmin = true;
    user1.nickName = '肖战';
    user1.phoneNumber = '13233323333';

    const user2 = new User();
    user2.username = 'wangyibo';
    user2.password = md5('222222');
    user2.email = 'wangyibo@qq.com';
    user2.nickName = '王一博';

    const role1 = new Role();
    role1.name = '管理员';

    const role2 = new Role();
    role2.name = '普通用户';

    const permission1 = new Permission();
    permission1.code = 'ccc';
    permission1.description = '访问 ccc 接口';

    const permission2 = new Permission();
    permission2.code = 'ddd';
    permission2.description = '访问 ddd 接口';

    user1.roles = [role1];
    user2.roles = [role2];

    role1.permissions = [permission1, permission2];
    role2.permissions = [permission1];

    await this.permissionRepository.save([permission1, permission2]);
    await this.roleRepository.save([role1, role2]);
    await this.userRepository.save([user1, user2]);
  }

在 UserController新增一个路由 init-data

@Get('init-data')
async initData() {
  await this.userService.initData();
  return 'done';
}

登录返回用户信息

import {
  Injectable,
  Logger,
  Inject,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
...
import { LoginUserDto } from './dto/login-user.dto';
import { LoginUserVo } from './vo/login-user.vo';

@Injectable()
export class UserService {
  ...
  async login(params: LoginUserDto, isAdmin: boolean) {
    const foundUser = await this.userRepository.findOne({
      where: {
        username: params.username,
      },
      relations: ['roles', 'roles.permissions'],
    });
    if (!foundUser) {
      throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST);
    }
    if (foundUser.password !== md5(params.password)) {
      throw new HttpException('密码不正确', HttpStatus.BAD_REQUEST);
    }
    const vo = new LoginUserVo();
    const {
      username,
      nickName,
      headPic,
      email,
      phoneNumber,
      id,
      isFrozen,
      createTime,
      roles,
    } = foundUser;

    vo.userInfo = {
      id,
      username,
      nickName,
      headPic,
      email,
      phoneNumber,
      isFrozen,
      createTime,
      isAdmin,
      roles: roles.map((item) => item.name),
      permissions: roles.reduce((pre, cur) => {
        cur.permissions.forEach((e) => {
          if (pre.indexOf(e) === -1) {
            pre.push(e);
          }
        });
        return pre;
      }, []),
    };

    return vo;
  }
}

users/vo/login-user.vo.ts

interface UserInfo {
  id: number;

  username: string;

  nickName: string;

  email: string;

  headPic: string;

  phoneNumber: string;

  isFrozen: boolean;

  isAdmin: boolean;

  createTime: Date;

  roles: string[];

  permissions: string[];
}

export class LoginUserVo {
  userInfo: UserInfo;

  accessToken: string;

  refreshToken: string;
}

UserController调login

...
import { LoginUserDto } from './dto/login-user.dto';

@Controller('user')
export class UserController {
  ...

  @Post('login')
  async login(@Body() dto: LoginUserDto) {
    return await this.userService.login(dto, false);
  }

  @Post('admin/login')
  async adminLogin(@Body() dto: LoginUserDto) {
    return await this.userService.login(dto, true);
  }
}

测试

引入jwt (返回token)

安装依赖包

pnpm i @nestjs/jwt --save

AppModule配置

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    ...
    JwtModule.registerAsync({
      useFactory(configService: ConfigService) {
        return {
          secret: configService.get('JWT_SECRET'),
          signOptions: {
            expiresIn: '30m',
          },
        };
      },
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

config/config.yaml添加

JWT_SECRET: 'wangyibo'

统一响应格式和接口记录

错误响应需要在Exception Filter 处理。