需求&技术栈
数据库设计
接口列表
用户模块
注册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
- 生成 user 模块
nest g resource user
在 user/entities 下新建三个实体 User、Role、Permission。参考数据库设计里的关系表。
用户user表
角色role表
权限permission表
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执行语句打印出来。
在数据库里查看,表已经新建好了。
:::danger
MySQL里没有boolean类型,所以使用的tinyint存储,1-true,0-false。TypeORM会自动映射为boolean。
:::
两个中间表 user_roles、role_permissions
准备工作就绪,可以开始业务代码的开发。
按照 用户模块的接口列表,逐一实现。
用户注册
在 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测试一下
校验
接着我们需要对发送的请求体做校验,安装两个包:
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里重新测试一下
在user.service.ts实现具体的注册业务逻辑
-
创建logger
-
注入数据库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实体。否则会报以下错误
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 {}
注册逻辑
安装ioredis
pnpm i ioredis --save
pnpm i @types/ioredis -D
新建redis模块
nest g module redis
nest g service redis
Docker启动redis容器
新建连接的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手动存一下验证码
postman测一下
成功后在mysql里查询下是否已经有该用户
我们使用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 处理。