目录

124.会议室预订系统预定管理模块-后端开发

目录

这节我们来实现预定管理模块的后端。

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/042ccf36141a473f868e40c115db73c4~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1010&h=1012&s=145901&e=png&b=fefcfc

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1f9ef4e312e941b0bc7ab4ea9f7e1d59~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=932&h=1014&s=128397&e=png&b=fdf5f4

涉及到这些接口:

接口路径 请求方式 描述
/booking/list GET 预订列表
/booking/approve POST 审批预订申请
/booking/add POST 申请预订
/booking/apply/:id GET 通过预订
/booking/reject/:id GET 取消预订
/booking/unbind/:id GET 解除预订
/booking/history GET 预订历史
/booking/urge GET 催办

我们来写一下。

先创建 Booking 的 entity。

在后端项目下创建一个 meeting-room 模块:

nest g resource booking

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c86e5a75b22e49f4a82b7d8afc2e7f5f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=800&h=282&s=77313&e=png&b=191919

然后修改 booking.entity.ts

根据当时设计的表来写:

字段名 数据类型 描述
id INT 预订ID
user_id INT 预订用户ID
room_id INT 会议室ID
start_time DATETIME 会议开始时间
end_time DATETIME 会议结束时间
status VARCHAR(20) 状态(申请中、审批通过、审批驳回、已解除)
note VARCHAR(100) 备注
create_time DATETIME 创建时间
update_time DATETIME 更新时间
import { MeetingRoom } from "src/meeting-room/entities/meeting-room.entity";
import { User } from "src/user/entities/user.entity";
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@Entity()
export class Booking {

    @PrimaryGeneratedColumn()
    id: number;

    @Column({
        comment: '会议开始时间'
    })
    startTime: Date;

    @Column({
        comment: '会议结束时间'
    })
    endTime: Date;

    @Column({
        length: 20,
        comment: '状态(申请中、审批通过、审批驳回、已解除)',
        default: '申请中'
    })
    status: string;

    @Column({
        length: 100,
        comment: '备注',
        default: ''
    })
    note: string;

    @ManyToOne(() => User)
    user: User;

    @ManyToOne(() => MeetingRoom)
    room: MeetingRoom;

    @CreateDateColumn({
        comment: '创建时间'
    })
    createTime: Date;

    @UpdateDateColumn({
        comment: '更新时间'
    })
    updateTime: Date;
}

这里 Booking 和 User、MeetingRoom 是多对一的关系:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0ce5525d99274999b65f24e9aafe8658~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1076&h=926&s=117455&e=png&b=fcf8f8

我们在 entities 引入:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/23764ebbdca246b391385c0097757285~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1122&h=928&s=208639&e=png&b=202020

typeorm 会自动建表和创建外键:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bb7ed06f9e6d4d7bb85fd5e5ce93f855~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1426&h=334&s=136162&e=png&b=191919

在 mysql workbench 里可以看到这个表:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/25a051cae54e4f0db0e36de5ee857ebb~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1268&h=664&s=273300&e=png&b=f2efee

这两个外键都是 restrict 的约束:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b0ef7d08a86b49ea8ac4ae3c84fae41e~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1650&h=532&s=148042&e=png&b=f9f8f8

restrict 是只有从表没有相关记录,才可以删除主表记录。

但我们会议室是支持删除的,怎么办呢?

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/faca1dd8a66c4ada88748a4b6af44ac7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=534&h=242&s=54380&e=png&b=fcfcfc

可以在删除失败时提示有预定记录,然后手动取消预订后再删除。

这个后面再处理。

我们先插入一些数据:

在 BookingService 增加一个 initData 方法:

@InjectEntityManager()
private entityManager: EntityManager;

async initData() {
    const user1 = await this.entityManager.findOneBy(User, {
      id: 1
    });
    const user2 = await this.entityManager.findOneBy(User, {
      id: 2
    });

    const room1 = await this.entityManager.findOneBy(MeetingRoom, {
      id: 3
    });
    const room2 = await await this.entityManager.findOneBy(MeetingRoom, {
      id: 6
    });

    const booking1 = new Booking();
    booking1.room = room1;
    booking1.user = user1;
    booking1.startTime = new Date();
    booking1.endTime = new Date(Date.now() + 1000 * 60 * 60);

    await this.entityManager.save(Booking, booking1);

    const booking2 = new Booking();
    booking2.room = room2;
    booking2.user = user2;
    booking2.startTime = new Date();
    booking2.endTime = new Date(Date.now() + 1000 * 60 * 60);

    await this.entityManager.save(Booking, booking2);

    const booking3 = new Booking();
    booking3.room = room1;
    booking3.user = user2;
    booking3.startTime = new Date();
    booking3.endTime = new Date(Date.now() + 1000 * 60 * 60);

    await this.entityManager.save(Booking, booking3);

    const booking4 = new Booking();
    booking4.room = room2;
    booking4.user = user1;
    booking4.startTime = new Date();
    booking4.endTime = new Date(Date.now() + 1000 * 60 * 60);

    await this.entityManager.save(Booking, booking4);
}

我们先查询出 2 个 User ,2 个 MeetingRoom,然后创建 4 个 Booking。

用 save 把 4 条记录保存到数据库。

用 repl 的方式跑起来:

npm run repl

调用 initData 方法:

await get(BookingService).initData()

会有一系列 insert 语句:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6873b0d2476047758201688b78025212~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1434&h=1158&s=322145&e=png&b=191919

在数据库的 booking 表可以看到插入了 4 条记录:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/843cfb4558e441549b88d708fc480718~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1812&h=372&s=213892&e=png&b=f0eeed

然后来写 booking 模块的接口:

首先是 list 接口。

它支持根据条件搜索,并且可以分页:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8b6228a200f64202b0203a6f779a71a7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1628&h=1126&s=174963&e=png&b=ffffff

我们来写一下:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/53ab55853c58452e807a9c92a9b261af~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1416&h=732&s=235976&e=png&b=1f1f1f

@Get('list')
async list(
    @Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo')) pageNo: number,
    @Query('pageSize', new DefaultValuePipe(10), generateParseIntPipe('pageSize')) pageSize: number,
    @Query('username') username: string,
    @Query('meetingRoomName') meetingRoomName: string,
    @Query('meetingRoomPosition') meetingRoomPosition: string,
    @Query('bookingTimeRangeStart') bookingTimeRangeStart: number,
    @Query('bookingTimeRangeEnd') bookingTimeRangeEnd: number,
) {
    return this.bookingService.find(pageNo, pageSize, username, meetingRoomName, meetingRoomPosition, bookingTimeRangeStart, bookingTimeRangeEnd);
}

这种列表接口我们写过,就是传入分页参数、搜索参数,然后在 service 里把它们查出来返回。

这里的时间用 number 来接收。

我们去 BookingService 里实现下这个方法:

async find(pageNo: number, pageSize: number, username: string, meetingRoomName: string, meetingRoomPosition: string, bookingTimeRangeStart: number, bookingTimeRangeEnd: number ) {
    const skipCount = (pageNo - 1) * pageSize;

    const [bookings, totalCount] = await this.entityManager.findAndCount(Booking, {
      where: {
        user: {
          username: Like(`%${username}%`)
        },
        room: {
          name: Like(`%${meetingRoomName}%`),
          location: Like(`%${meetingRoomPosition}%`)
        },
        startTime: Between(new Date(bookingTimeRangeStart), new Date(bookingTimeRangeEnd))
      },
      relations: {
        user: true,
        room: true,
      },
      skip: skipCount,
      take: pageSize
    });

    return {
      bookings,
      totalCount
    }
}

很容易看懂,就是接个 where 条件,还有分页。

要注意下日期的范围查询使用 between and 语法,这里使用 Between 操作符。

先测试下。

停掉服务,我们用 repl 的方式测:

npm run repl

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/88c0b199130a49159048e6f249688373~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1178&h=810&s=193210&e=png&b=191919

先看下有啥数据:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2f0745b3aa334c13bd7e825185e90a8f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1808&h=500&s=250575&e=png&b=f3f1f0

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/17c6d4c0efca4b9a9d7adc470fce4768~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1596&h=506&s=289766&e=png&b=ebe9e8

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/56e4d750cc6e408f8fb30d74146da8dc~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1370&h=526&s=199313&e=png&b=f1efee

然后测试下:

在 repl 拿到两个时间戳:

new Date('2023-09-29').getTime()
new Date('2023-09-30').getTime()

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c0a9c7af4794c979a84fce5acfb1706~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=550&h=172&s=22866&e=png&b=181818

调用下这个方法:

await get(BookingService).find(1, 10, 'guang', '天王', '三层', 1695945600000, 1696032000000)

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4b18ee8e248343cfb529d0a17a78e90a~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1462&h=644&s=192491&e=png&b=1a1a1a

打印了一堆 sql,下面有查询的结果:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/10cbfd103e7744f6b1d7886b2c0ad2ca~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=862&h=944&s=148203&e=png&b=181818

查询出来的是 id 为 1 和 3 的记录。

因为条件是 user.name 包含 guang:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0ec8364a4fa44ae5bc5f633f944dd4f0~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=964&h=212&s=99906&e=png&b=f8f8f8

room.name 包含天王:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/19e492b053b74f84b0cc655397ff7ca7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=966&h=238&s=85084&e=png&b=f9f9f9

所以查出这两条是对的:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5c458434016047c09cb7cb998d13d740~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1222&h=256&s=112303&e=png&b=fbfbfb

查询逻辑写完了,我们还得优化下。

因为这些参数是可选的,我们要处理下没有传入的情况:

async find(pageNo: number, pageSize: number, username: string, meetingRoomName: string, meetingRoomPosition: string, bookingTimeRangeStart: number, bookingTimeRangeEnd: number ) {
    const skipCount = (pageNo - 1) * pageSize;

    const condition: Record<string, any> = {};

    if(username) {
      condition.user = {
        username: Like(`%${username}%`)
      }
    }

    if(meetingRoomName) {
      condition.room =  {
        name: Like(`%${meetingRoomName}%`)
      }
    }

    if(meetingRoomPosition) {
      if (!condition.room) {
        condition.room = {}
      }
      condition.room.location = Like(`%${meetingRoomPosition}%`)
    }

    if(bookingTimeRangeStart) {
      if(!bookingTimeRangeEnd) {
        bookingTimeRangeEnd = bookingTimeRangeStart + 60 * 60 * 1000
      }
      condition.startTime = Between(new Date(bookingTimeRangeStart), new Date(bookingTimeRangeEnd))
    }

    const [bookings, totalCount] = await this.entityManager.findAndCount(Booking, {
      where: condition,
      relations: {
        user: true,
        room: true,
      },
      skip: skipCount,
      take: pageSize
    });

    return {
      bookings,
      totalCount
    }
}

就是如果传入了,就加到 condition 上。

其中,如果 endTime 没传入,那就用 startTime + 一小时 来搜索。

此外,这里查询出来 user 信息是包含密码的,其实应该把它去掉:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/13ec75ba46ee4028ab8cda12ddc01104~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=620&h=320&s=47688&e=png&b=181818

可以在这里指定 select 的字段:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0a0454d125eb488dbc819af1fad47e82~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=696&h=394&s=42350&e=png&b=202020

但这样有点麻烦。

我们直接查出来之后把它删掉就好了:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a0b84c310fa24d4c844f005527a8e72d~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=672&h=644&s=71783&e=png&b=1f1f1f

bookings.map(item => {
    delete item.user.password;
    return item;
})

把 repl 停掉,把服务跑起来:

npm run start:dev

我们在 postman 里测试下:

http://localhost:3005/booking/list?meetingRoomName=天王&username=guang

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b594883ca9614d5dbdb4e598aa6ed477~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1196&h=1248&s=188012&e=png&b=fdfdfd

没啥问题。

接下来是申请预定的接口

接口路径 请求方式 描述
/booking/list GET 预订列表
/booking/add POST 申请预订

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/436d2d7f602845598785593260ad4742~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1368&h=968&s=74070&e=png&b=d7d7d7

在 BookingController 添加一个接口:

@Post('add')
@RequireLogin()
async add(@Body() booking: CreateBookingDto, @UserInfo('userId') userId: number) {
    await this.bookingService.add(booking, userId);
    return 'success'
}

这里需要用 @UserInfo 拿到 userId。

从 request.user 拿到 userId 的信息,需要登录,所以添加 @RequireLogin 装饰器。

因为我们在 LoginGuard 里做了判断,只有有这个装饰器的 handler 才会从 header 中解析出用户信息放在 reqeust.user 上:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5b035eeae2f64dd6840cadb3393b4981~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1238&h=1046&s=217962&e=png&b=1f1f1f

创建用到的 dto:

import { IsNotEmpty, IsNumber } from "class-validator";

export class CreateBookingDto {
    @IsNotEmpty({ message: '会议室名称不能为空'})
    @IsNumber()
    meetingRoomId: number;

    @IsNotEmpty({ message: '开始时间不能为空' })
    @IsNumber()
    startTime: number;

    @IsNotEmpty({ message: '结束时间不能为空' })
    @IsNumber()
    endTime: number;

    note: string;
}

然后在 BookingService 实现下 add 方法:

async add(bookingDto: CreateBookingDto, userId: number) {
    const meetingRoom = await this.entityManager.findOneBy(MeetingRoom, {
      id: bookingDto.meetingRoomId
    });

    if(!meetingRoom) {
      throw new BadRequestException('会议室不存在');
    }

    const user = await this.entityManager.findOneBy(User, {
      id: userId
    });

    const booking = new Booking();
    booking.room = meetingRoom;
    booking.user = user;
    booking.startTime = new Date(bookingDto.startTime);
    booking.endTime = new Date(bookingDto.endTime);

    await this.entityManager.save(Booking, booking);
}

就是根据 id 查询出 meeetingRoom 和 user,然后创建 booking,保存。

测试下:

先登录拿到 token:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/429fd7f4eed94ca295c4cb61dc3843ec~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1130&h=1110&s=179496&e=png&b=fdfdfd

带在 Aothrization 的 header 上访问 add 接口:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6bd96c8f23244ce4b25f0d8ecfff3437~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1416&h=496&s=74638&e=png&b=f9f9f9

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/48feddfb140f41a58d9a086d44a3ad59~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=850&h=854&s=93099&e=png&b=fcfcfc

{
    "meetingRoomId": 3,
    "startTime": 1703986859333,
    "endTime": 1703987859333
}

在 mysql workbench 查询下,可以看到记录成功插入了:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ebdd0963347447f684a66ae7e05330db~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1440&h=470&s=299324&e=png&b=fbfbfb

当然,现在的接口还是有问题的,我们得限制下,同一个会议室一段时间内只能被预定一次。

那怎么保证预定的时间不会冲突呢?

其实一般的会议室预订系统都是这样做的:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/53461a04dd6a4c7b9f29aa729e09c050~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1744&h=1198&s=205957&e=png&b=ffffff

在右边列出来会议室在一天之内哪些时间可用,哪些时间被预定了。

然后只能在没有被预定的时间内选择。

这里我们就简化一下,查询下已经预定的记录里有没有包含这段时间的就好了。

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bccdacb437e3480cb9d8cf79c9f387bb~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1086&h=1150&s=270729&e=png&b=1f1f1f

async add(bookingDto: CreateBookingDto, userId: number) {
    const meetingRoom = await this.entityManager.findOneBy(MeetingRoom, {
      id: bookingDto.meetingRoomId
    });

    if(!meetingRoom) {
      throw new BadRequestException('会议室不存在');
    }

    const user = await this.entityManager.findOneBy(User, {
      id: userId
    });

    const booking = new Booking();
    booking.room = meetingRoom;
    booking.user = user;
    booking.startTime = new Date(bookingDto.startTime);
    booking.endTime = new Date(bookingDto.endTime);

    const res = await this.entityManager.findOneBy(Booking, {
      room: {
        id: meetingRoom.id
      },
      startTime: LessThanOrEqual(booking.startTime),
      endTime: MoreThanOrEqual(booking.endTime)
    });

    if(res) {
      throw new BadRequestException('该时间段已被预定');
    }
    
    await this.entityManager.save(Booking, booking);
  }

测试下:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7d4fd3a8cde41a0a83dbdb173bf4dd4~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=716&h=788&s=88366&e=png&b=fcfcfc

{
    "meetingRoomId": 3,
    "startTime": 1703986959333,
    "endTime": 1703986859333
}

当预定一个已经被预定的时间段时,会提示已被预定。

然后继续写后面接口:

接口路径 请求方式 描述
/booking/list GET 预订列表
/booking/add POST 申请预订
/booking/apply/:id GET 通过预订
/booking/reject/:id GET 取消预订
/booking/unbind/:id GET 解除预订

接下来写修改预定状态的这三个接口。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/593bc2412c174809b19888fefe54554f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1024&h=700&s=116315&e=png&b=fefefe

状态有这 4 种:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4d0f0af1a9654811a5868a55c3db2ee7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=946&h=472&s=74897&e=png&b=1f1f1f

在 BookingController 添加三个路由:

@Get("apply/:id")
async apply(@Param('id') id: number) {
    return this.bookingService.apply(id);
}

@Get("reject/:id")
async reject(@Param('id') id: number) {
    return this.bookingService.reject(id);
}

@Get("unbind/:id")
async unbind(@Param('id') id: number) {
    return this.bookingService.unbind(id);
}

然后在 BookingService 里实现这三个方法:

async apply(id: number) {
    await this.entityManager.update(Booking, {
      id
    }, {
      status: '审批通过'      
    });
    return 'success'
}

async reject(id: number) {
    await this.entityManager.update(Booking, {
      id
    }, {
      status: '审批驳回'      
    });
    return 'success'
}

async unbind(id: number) {
    await this.entityManager.update(Booking, {
      id
    }, {
      status: '已解除'      
    });
    return 'success'
}

postman 里测试下:

http://localhost:3005/booking/apply/1

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0612aeebd6c442d49b1a5d78680f56e2~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=846&h=704&s=63886&e=png&b=fcfcfc

http://localhost:3005/booking/reject/2

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/eb615fe172d341578ff636ed48a0f7c7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=792&h=652&s=59278&e=png&b=fcfcfc

http://localhost:3005/booking/reject/3

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1d195e2cd06f4784bf5fb6bfd3e300aa~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=790&h=622&s=58757&e=png&b=fbfbfb

在 mysql workbench 里可以看到状态成功被修改了:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cf2ce3b8cce641d0b89c7c196d6d6f28~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1202&h=322&s=140258&e=png&b=f6f6f6

接下来是催办的接口:

接口路径 请求方式 描述
/booking/list GET 预订列表
/booking/apply/:id GET 通过预订
/booking/reject/:id GET 取消预订
/booking/unbind/:id GET 解除预订
/booking/urge GET 催办

按当时的设计,催办要发送邮件和短信,不过我们没买短信服务,这里就发邮件好了。

但也不是每次催办都会发邮件,我们在 redis 里加个标识,半小时内只发一次邮件。

我们在 BookingController 添加一个 urge 接口:

@Get('urge/:id')
async urge(@Param('id') id: number) {
    return this.bookingService.urge(id);
}

然后在 BookingService 添加实现逻辑:

@Inject(RedisService)
private redisService: RedisService;

@Inject(EmailService)
private emailService: EmailService;

async urge(id: number) {
    const flag = await this.redisService.get('urge_' + id);

    if(flag) {
      return '半小时内只能催办一次,请耐心等待';
    }

    let email = await this.redisService.get('admin_email');

    if(!email) { 
      const admin = await this.entityManager.findOne(User, {
        select: {
          email: true
        },
        where: {
          isAdmin: true
        }
      });

      email = admin.email

      this.redisService.set('admin_email', admin.email);
    }

    this.emailService.sendMail({
      to: email,
      subject: '预定申请催办提醒',
      html: `id 为 ${id} 的预定申请正在等待审批`
    });
    
    this.redisService.set('urge_' + id, 1, 60 * 30);
}

我们注入了 EmailService 和 RedisService。

先用 redisService 查询 flag,查到的话就提醒半小时内只能催办一次。

然后用 redisService 查询 admin 的邮箱,没查到的话到数据库查,然后存到 redis。

之后发催办邮件,并且在 redis 里存一个 30 分钟的 flag。

测试下:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5b323c02e3dc43a486a9e8b4abca837b~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=792&h=598&s=54092&e=png&b=fcfcfc

第一次催办,管理员会收到邮件:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/595528fe560d4bda82b78af295a47e99~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=510&h=270&s=28591&e=png&b=f7f7f7

第二次催办,会提示半小时只能催办一次:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cb30b489a7bd4905afc02727c1de6738~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=846&h=624&s=65832&e=png&b=fcfcfc

在 RedisInsight 里可以看到这两个 key:

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/58d86bb56ea64ebc8836aaec345a4179~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1334&h=542&s=70661&e=png&b=141414

这样,催办接口就完成了。

当然,这里最好是在邮件里带一个具体的链接,点击可以直接打开对应的页面来处理申请。

等后面写完这个页面再改。

案例代码上传了小册仓库

总结

这节我们完成了预定管理模块的后端代码,包括列表、添加预定、审批、催办等。

后端代码完成了,下节我们来写前端部分的代码。