iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 20

nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part1

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part1

目標

image

今天目標會是先規劃, TicketModule 的部份。

概念

這邊所關注的 Ticket ,主要職責是用來紀錄每個使用者與活動的關係,並且紀錄其狀態。舉例來說: 當某一個使用者,看到某一個活動,就可以透過對這個活動執行買入的動作。當買入動作執行後,系統會透過使用者的識別子與活動的識別子當做參數,生成一個叫作票卷的紀錄到系統裡,也就是所謂『 Ticket 』。

image

Ticket 也就是票卷,基本就是用來紀錄使用者與活動之間的關係。管理票卷就是處理使用者參與活動的紀錄。針對票卷基本上有以下狀態轉換。

image

可以從使用情境了解到,使用者與活動以及票卷這三個資訊之間具有相依關係。票券會透過活動與使用者資訊組成。

分析

根據之前分析的 Entity 關係圖如下
image

設計規範

  1. 使用者以活動資訊執行買入動作產生票卷。
  2. 管理者對票卷進行檢入動作,更改票卷狀態。紀錄活動參加者人數
  3. 使用者可以查看自己買入的票卷
  4. 管理者可以看到所有活動的票卷數目與參加者人數。

思考

活動參加人數與活動票卷數目,其實都是可以從 Ticket 的紀錄來推導出來。因此,如果額外設計出一個一個 entity 來做紀錄,就必須要考慮到狀態一致性的問題。

但如果每次都需要去查詢整個活動的 Ticket 紀錄來做計數,又會讓效能變差。

兩者取得平衡的方式是建立一個 Read Model 專門來,處理當下總數,而每次建立或是 修改 Ticket 時,就透過事件的方事件更新這個 Read Model 總數。

image

讀取 event Entity 時,則需要新增加這個 EventMeta 的 Entity 的資料。

針對以上規範想出的程式介面

image

建構 ticketModule 與內部結構

nest g mo tickets
nest g s tickets
nest g co tickets
nest g s event-counter tickets --flat

根據規格撰寫驗證

為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫

  1. ticket service
import { Test, TestingModule } from '@nestjs/testing';
import { TicketsService } from './tickets.service';
import { CreateTicketDto } from './dto/ticket.dto';
import { isUUID } from 'class-validator';
import { TicketsStore } from './tickets.store';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PageInfoRequestDto } from '../pagination.dto';

const mockedEventEmitter = {
  emit: jest.fn().mockReturnValue({})
}
describe('TicketsService', () => {
  let service: TicketsService;
  let emitter: EventEmitter2;
  let ticketId: string;
  let userId: string;
  let eventId: string;
  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [TicketsService, TicketsStore, {
        provide: EventEmitter2,
        useValue: mockedEventEmitter
      }],
    }).compile();

    service = module.get<TicketsService>(TicketsService);
    emitter = module.get<EventEmitter2>(EventEmitter2);
  });
  afterEach(() => {
    jest.clearAllMocks();
  })
  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  /**
  given a event id and userid with request ticketNumber 
  {
    eventId: uuid,
    userId: uuid,
    ticketNumber: 1,
  }
   */
  it('should return a created ticketId, and create ticket event should be sent', async () => {
    const createTicketInfo = new CreateTicketDto();
    createTicketInfo.userId = crypto.randomUUID();
    createTicketInfo.eventId = crypto.randomUUID();
    createTicketInfo.ticketNumber = 1;
    const result = await service.createTicket(createTicketInfo);
    expect(result).toHaveProperty('id');
    ticketId = result.id;
    userId = createTicketInfo.userId;
    eventId = createTicketInfo.eventId;
    expect(isUUID(result.id)).toBeTruthy();
    expect(emitter.emit).toHaveBeenCalledTimes(1);
    expect(emitter.emit).toHaveBeenCalledWith('create-ticket-event', 
      { eventId: createTicketInfo.eventId, ticketNumber: createTicketInfo.ticketNumber});
  });
  /**
  given existed id
   */
  it('should return ticketInfo with specific ticketid', async () => {
    const result = await service.getTicket({id: ticketId });
    expect(result).toHaveProperty('userId', userId);
    expect(result).toHaveProperty('eventId', eventId);
    expect(result).toHaveProperty('ticketNumber', 1);
  });
  /**
  given not existed id
   */
  it('should reject with NotFound Exception', async () => {
    await expect(service.getTicket({ id: crypto.randomUUID()})).rejects.toThrow(NotFoundException);
  })
  /**
  given exist event id
   */
  it('should return response data with specific event id', async () => {
    const pageInfo = new PageInfoRequestDto();
    const result = await service.getTickets({ eventId: eventId }, pageInfo);
    expect(result).toHaveProperty('tickets');
    expect(result).toHaveProperty('pageInfo');
    expect(result.pageInfo).toHaveProperty('total', 1);
  });
  /**
  given exist not verified ticket id
   */
  it('should return verified uuid ticket and generate verify ticket event', async () => {
    const result = await service.verifyTicket({ id: ticketId });
    expect(result).toHaveProperty('id', ticketId);
    expect(emitter.emit).toHaveBeenCalledTimes(1)
    expect(emitter.emit).toHaveBeenCalledWith('verify-ticket-event', {
      eventId: eventId,
      ticketNumber: 1
    });
  });
  /**
  given verified ticket id
   */
  it('should reject with BadRequestException', async() => {
    await expect(service.verifyTicket({ id: ticketId })).rejects.toThrow(BadRequestException);
    expect(emitter.emit).toHaveBeenCalledTimes(0);
  });
});
  1. Event Couter Service
import { Test, TestingModule } from '@nestjs/testing';
import { EventsCounterService } from './events-counter.service';
import { EventCounterStore } from './event-counter.store';
import { BadRequestException } from '@nestjs/common';

describe('EventsCounterService', () => {
  let service: EventsCounterService;
  let eventId: string;
  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [EventsCounterService, EventCounterStore],
    }).compile();

    service = module.get<EventsCounterService>(EventsCounterService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  /**
  init event ticket couuter
   */
  it('should initial counter with specific event id', async () => {
    eventId = crypto.randomUUID();
    const result = await service.initCounter({ eventId: eventId });
    expect(result).toHaveProperty('totalTicketNumber', 0);
    expect(result).toHaveProperty('attendeeNumber', 0);
  });
  /**
  increase ticket on specific the exist event
   */
  it('should return increase the ticket number', async () => {
    const result = await service.increaseTicketCount({ eventId: eventId, ticketNumber: 1});
    expect(result).toHaveProperty('totalTicketNumber', 1);
    expect(result).toHaveProperty('attendeeNumber', 0);    
  });
  /**
  increase attendee on specific exist event
   */
  it('should return increase the attendee number', async () => {
    const result = await service.increaseAttendeeCount({ eventId: eventId, ticketNumber: 1});
    expect(result).toHaveProperty('totalTicketNumber', 1);
    expect(result).toHaveProperty('attendeeNumber', 1);
  });
  /**
  increase over total ticket number
   */
  it('should return increase the attendee number', async () => {
    await expect(service.increaseAttendeeCount({ eventId: eventId, ticketNumber: 1})).rejects
     .toThrow(BadRequestException);
  });
});

  1. controller
import { Test, TestingModule } from '@nestjs/testing';
import { TicketsController } from './tickets.controller';
import { CreateTicketDto, GetTicketsDto, VerifyTicketDto } from './dto/ticket.dto';
import { TicketsService } from './tickets.service';
import { PageInfoRequestDto } from '../pagination.dto';

describe('TicketsController', () => {
  const mockTicketService = {
    createTicket: jest.fn().mockResolvedValue({}),
    getTicket: jest.fn().mockResolvedValue({}),
    getTickets: jest.fn().mockResolvedValue({}),
    verifyTicket: jest.fn().mockResolvedValue({}),
  }
  let controller: TicketsController;
  let service: TicketsService;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [TicketsController],
      providers: [{
        provide: TicketsService,
        useValue: mockTicketService,
      }]
    }).compile();

    controller = module.get<TicketsController>(TicketsController);
    service = module.get<TicketsService>(TicketsService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
  /**
  given a event id and userid with request ticketNumber 
  {
    eventId: uuid,
    userId: uuid,
    ticketNumber: 1,
  }
   */
  it('service.createTicket should be called once', async () => {
    const createTicketInfo = new CreateTicketDto();
    createTicketInfo.eventId = crypto.randomUUID();
    createTicketInfo.userId = crypto.randomUUID();
    createTicketInfo.ticketNumber = 1;
    await controller.createTicket(createTicketInfo);
    expect(service.createTicket).toHaveBeenCalledTimes(1);
    expect(service.createTicket).toHaveBeenCalledWith(createTicketInfo);
  });
  /**
  given a exist ticket id
   */
  it('service.getTicket should be called once', async () => {
    const id = crypto.randomUUID();
    await controller.getTicket(id);
    expect(service.getTicket).toHaveBeenCalledTimes(1);
    expect(service.getTicket).toHaveBeenCalledWith({ id });
  });
  /**
  givne a exist user id
   */
  it('service.getTickets should be called once', async () => {
    const getTicketsDto = new GetTicketsDto();
    getTicketsDto.userId = crypto.randomUUID();
    const pageInfo = new PageInfoRequestDto();
    await controller.getTickets(getTicketsDto, pageInfo);
    expect(service.getTickets).toHaveBeenCalledTimes(1);
    expect(service.getTickets).toHaveBeenCalledWith(getTicketsDto, pageInfo);
  });
  /**
  given a non-verify ticket id, with user
   */
  it('service.verifyTicket should be called once', async () => {
    const verifyTicketInfo = new VerifyTicketDto();
    verifyTicketInfo.id = crypto.randomUUID();
    await controller.verifyTicket(verifyTicketInfo);
    expect(service.verifyTicket).toHaveBeenCalledTimes(1);
    expect(service.verifyTicket).toHaveBeenCalledWith(verifyTicketInfo);
  });
});
  1. e2e
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { isUUID } from 'class-validator';
import { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
describe('AppController (e2e)', () => {
  let app: INestApplication;
  let userId: string;
  let refreshToken: string;
  let accessToken: string;
  let postgresql: StartedPostgreSqlContainer;
  let eventId: string;
  let attendeeIdToken: string;
  let attendeeUserId: string;
  let attendEventId: string;
  let verifyTicketId: string;
  beforeAll(async () => {
    postgresql = global.postgresql;
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
    }))
    await app.init();
  });
  afterAll(async () => {
    await app.close();
    await postgresql.stop();
  })

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('ticket-booking-system');
  });
  it('/auth/register (POST) with failed', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        email: 'yu',
        password: '123'
      })
      .expect(400);
  });
  it('/auth/register (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/register')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%',
      })
      .expect(201)
      .expect(({ body }) => {
        userId = body.id;
        attendeeUserId = body.id;
        expect(isUUID(body.id)).toBeTruthy()
      });
  });
  // given login with correct credential
  it('/auth/login (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'admin@hotmail.com',
        password: '1@q#Abz%'
      })
      .expect(201)
      .expect(({body}) => {
        refreshToken = body.refresh_token;
        accessToken = body.access_token;
        expect(body).toHaveProperty('access_token');
        expect(body).toHaveProperty('refresh_token');
      });
  })
  it('/users/:userId (GET)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${userId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body.email).toEqual('yu@hotmail.com')
      });
  })
  it('/users/:userId (GET) with failed', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${crypto.randomUUID()}`)
      .set('Authorization', accessToken)
      .expect(404);
  })
  // given login with wrong credential with Unauthorizatiion
  it('/auth/login (POST) reject with Unauthorization', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%1'
      })
      .expect(401);
  })
  // given wrong acess token to create a event
  it('/events (POST) reject with Unauthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.post('/events')
      .send({
        location: '台北大巨蛋',
        name: '江惠演唱會',
        startDate: '2024-10-01'
      })
      .set('Authorization', '1245')
      .expect(401);
  });
  // given access token to create a event
  it('/events (POST) with successfully create event', () => {
    const agent = request(app.getHttpServer());
    return agent.post('/events')
      .send({
        location: '台北大巨蛋',
        name: '江惠演唱會',
        startDate: '2024-10-01'
      })
      .set('Authorization', accessToken)
      .expect(201)
      .expect(({ body }) => {
        expect(isUUID(body.id)).toBeTruthy();
        eventId = body.id;
      });
  });
  // given exist eventid with existed event
  it('/events/:id (GET) with successfully retrieve event', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events/${eventId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('name', '江惠演唱會');
        expect(body).toHaveProperty('location', '台北大巨蛋')
      });
  });
  // given exist location with existed event
  it('/events/ (GET) with successfully retrieve event', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events`)
      .query({
        location: '台北大巨蛋'
      })
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('events');
        expect(body.events.length).toEqual(1);
      });
  });
  // given exist eventid with existed event
  it('/events/:id (PATCH) with successfully updated event', () => {
    const agent = request(app.getHttpServer());
    return agent.patch(`/events/${eventId}`)
      .set('Authorization', accessToken)
      .send({
        location: '台北小巨蛋'
      })
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('name', '江惠演唱會');
        expect(body).toHaveProperty('location', '台北小巨蛋')
      });
  });
  // given wrong token with get event
  it('/events/ (GET) reject with Uanuthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events`)
      .query({
        location: '台北大巨蛋'
      })
      .set('Authorization', '123')
      .expect(401);
  });
  // given wrong token with delete event
  it('/events/:id (DELETE) reject with Uanuthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.delete(`/events/${eventId}`)
      .set('Authorization', '1234')
      .expect(401)
  });
  // given exist event id with existed event
  it('/events/:id (DELETE) with successfully delete event', (done) => {
    const agent = request(app.getHttpServer());
    agent.delete(`/events/${eventId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('id')
        expect(isUUID(body.id)).toBeTruthy();
      })
      .end((err, res) => {
        if (err) {
          return done(err);
        }
        agent.get(`/events/${eventId}`)
          .set('Authorization', accessToken)
          .expect(404)
          .end((err, res) =>{
            done(err);
          });
      });
  });
  it('/tickets (POST) create a ticket', (done) => {
    const agent = request(app.getHttpServer());
    agent.post('/events')
      .send({
        location: '台北大巨蛋',
        name: '江惠演唱會',
        startDate: '2024-10-01'
      })
      .set('Authorization', accessToken)
      .expect(201)
      .expect(({ body }) => {
        expect(body).toHaveProperty('id');
        expect(isUUID(body.id)).toBeTruthy();
        attendEventId = body.id;
      })
      .end((err, res) => {
        if (err) {
          return done(err)
        }
        agent
        .post('/auth/login')
        .send({
          email: 'yu@hotmail.com',
          password: '1@q#Abz%'
        })
        .expect(201)
        .expect(({ body }) => {
          expect(body).toHaveProperty('access_token');
          attendeeIdToken = body.access_token;
        })
        .end((err, res) => {
          if (err) {
            done(err)
            return
          }
          agent.post('/tickets')
          .send({
            eventId: attendEventId,
            userId: attendeeUserId
          })
          .set('Authorization', attendeeIdToken)
          .expect(201)
          .expect(({ body }) => {
            expect(body).toHaveProperty('id');
            expect(isUUID(body.id)).toBeTruthy();
            verifyTicketId = body.id;
          })
          .end((err, res) => {
            done(err);
          })
        });
      });
  });
  it('/tickets/:id (GET) get a ticket', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/tickets/${verifyTicketId}`)
      .set('Authorization', attendeeIdToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('entered', false);
      });
  });
  it('/tickets (PATCH) verify a ticket', (done) => {
    const agent = request(app.getHttpServer());
    agent.patch('/tickets')
      .send({
        id: verifyTicketId
      })
      .set('Authorization', accessToken)
      .expect(200)
      .end((err, res) => {
        if (err) {
          return done(err)
        }
        agent.get(`/tickets/${verifyTicketId}`)
          .set('Authorization', attendeeIdToken)
          .expect(200)
          .expect(({ body }) => {
            expect(body).toHaveProperty('entered', true);
          })
          .end((err, res) => {
            done(err)
          })
      })
  });
  // given refesh token with exist users
  it('/auth/refresh (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/refresh')
      .set('Authorization', refreshToken)
      .expect(201);
  });
});

定義 TicketsRepostiory 抽象介面

import { PageInfoRequestDto } from '../pagination.dto';
import { CreateTicketDto, TicketsCountResponseDto, TicketsResponse } from './dto/ticket.dto';
import { TicketEntity } from './schema/ticket.entity';

export abstract class TicketsRepository {
  abstract save(ticketInfo: CreateTicketDto): Promise<TicketEntity>;
  abstract findOne(criteria: Partial<TicketEntity>): Promise<TicketEntity>;
  abstract find(criteria: Partial<TicketEntity>, pageInfo: PageInfoRequestDto): Promise<TicketsResponse>;
  abstract update(criteria: Partial<TicketEntity>, data: Partial<TicketEntity>): Promise<TicketEntity>;
  abstract getCounts(criteria: Partial<TicketEntity>): Promise<TicketsCountResponseDto>;
}

實作 In Memory 的 TicketsStore

import { PageInfoRequestDto } from 'src/pagination.dto';
import { CreateTicketDto, TicketsCountResponseDto, TicketsResponse } from './dto/ticket.dto';
import { TicketEntity } from './schema/ticket.entity';
import { TicketsRepository } from './tickets.repository';
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class TicketsStore implements TicketsRepository {
  tickets: TicketEntity[] = new Array<TicketEntity>();
  async save(ticketInfo: CreateTicketDto): Promise<TicketEntity> {
    const ticket = new TicketEntity();
    ticket.id = crypto.randomUUID();
    ticket.userId = ticketInfo.userId;
    ticket.eventId = ticketInfo.eventId;
    if (ticketInfo.ticketNumber) {
      ticket.ticketNumber = ticketInfo.ticketNumber;
    }
    this.tickets.push(ticket);
    return ticket;
  }
  async findOne(criteria: Partial<TicketEntity>): Promise<TicketEntity> {
    const foundIdx = this.tickets.findIndex((item) => item.id == criteria.id);
    if (foundIdx == -1) {
      throw new NotFoundException(`ticket with id: ${criteria.id} not found`);
    }
    return this.tickets[foundIdx];
  }
  async find(criteria: Partial<TicketEntity>, pageInfo: PageInfoRequestDto): Promise<TicketsResponse> {
    let filteredTicket: TicketEntity[] = this.tickets.slice(pageInfo.offset, pageInfo.offset + pageInfo.limit);
    if (criteria.eventId) {
      filteredTicket = filteredTicket.filter((item) => item.eventId == criteria.eventId);
    }
    if (criteria.userId) {
      filteredTicket = filteredTicket.filter((item) => item.userId == criteria.userId);
    }
    return {
      tickets: filteredTicket,
      pageInfo: {
        limit: pageInfo.limit,
        offset: pageInfo.offset,
        total: filteredTicket.length
      }
    }
  }
  async update(criteria: Partial<TicketEntity>, data: Partial<TicketEntity>): Promise<TicketEntity> {
    const updateIdx = this.tickets.findIndex((item) => item.id == criteria.id);
    if (updateIdx == -1) {
      throw new NotFoundException(`update target with ticketid ${criteria.id} not found`);
    }
    if (data.entered) {
      this.tickets[updateIdx].entered = data.entered;
    }
    return this.tickets[updateIdx];
  }
  async getCounts(criteria: Partial<TicketEntity>): Promise<TicketsCountResponseDto> {
    const filteredTicket = this.tickets.filter((item) => item.eventId = criteria.eventId );
    let accumAttendee = 0;
    let accumTickets = 0;
    filteredTicket.forEach((item) => {
      accumTickets += item.ticketNumber;
      if (item.entered) {
        accumAttendee += item.ticketNumber;
      }
    });
    return {
      eventId: criteria.eventId,
      accumAttendee: accumAttendee,
      accumTickets: accumTickets 
    }
  } 
}

定義 EventCouterRepository 抽象介面

import { EventCounterEntity } from "./schema/event-counter.entity";

export abstract class EventCounterRespository {
  abstract get(eventId: string): Promise<EventCounterEntity>;
  abstract verifyIncr(eventId: string, ticketNumber: number, accumAttendee: number, accumTicket: number): Promise<EventCounterEntity>;
  abstract ticketIncr(eventId: string, ticketNumber: number, accumAttendee: number, accumTicket: number): Promise<EventCounterEntity>
}

實做 In Memory 的 EventCouterStore

import { Injectable, NotFoundException } from '@nestjs/common';
import { EventCounterRespository } from './event-counter.repository';
import { EventCounterEntity } from './schema/event-counter.entity';
@Injectable()
export class EventCounterStore implements EventCounterRespository {
  store: Map<string, EventCounterEntity> = new Map<string, EventCounterEntity>();
  async get(eventId: string): Promise<EventCounterEntity> {
    if (!this.store.has(eventId)) {
      throw new NotFoundException('event id not initial or not existed');
    }
    return this.store.get(eventId);
  }
  async verifyIncr(eventId: string, ticketNumber: number, accumAttendee: number, accumTicket: number): Promise<EventCounterEntity> {
    if (!this.store.has(eventId)) {
      const counter = new EventCounterEntity();
      counter.eventId = eventId;
      counter.totalTicketNumber = accumTicket;
      counter.attendeeNumber = accumAttendee + ticketNumber;
      this.store.set(eventId, counter);
    } else {
      this.store.get(eventId).attendeeNumber += ticketNumber;
    }
    return this.store.get(eventId);
  }
  async ticketIncr(eventId: string, ticketNumber: number, accumAttendee: number, accumTicket: number): Promise<EventCounterEntity> {
    if (!this.store.has(eventId)) {
      const counter = new EventCounterEntity();
      counter.eventId = eventId;
      counter.totalTicketNumber = accumTicket + ticketNumber;
      counter.attendeeNumber = accumAttendee;
      this.store.set(eventId, counter);
    } else {
      this.store.get(eventId).totalTicketNumber += ticketNumber;
    }
    return this.store.get(eventId);
  }
}

實作 TicketService

import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateTicketDto, GetTicketDto, GetTicketsDto, TicketCountDto, VerifyTicketDto } from './dto/ticket.dto';
import { TicketsRepository } from './tickets.repository';
import { TicketsStore } from './tickets.store';
import { CreateTicketEvent, VerifyTicketEvent } from './dto/ticket.event';
import { PageInfoRequestDto } from '../pagination.dto';

@Injectable()
export class TicketsService {
  constructor(private readonly eventEmitter: EventEmitter2, 
    @Inject(TicketsStore)
    private readonly ticketRepo: TicketsRepository, 
  ) {}
  async createTicket(ticketInfo: CreateTicketDto) {
    const result = await this.ticketRepo.save(ticketInfo);
    const createTicketEvent = new CreateTicketEvent();
    createTicketEvent.eventId = result.eventId;
    createTicketEvent.ticketNumber = result.ticketNumber;
    this.eventEmitter.emit('create-ticket-event', createTicketEvent);
    return {
      id: result.id
    }
  }
  async getTicket(ticketInfo: GetTicketDto) {
    return await this.ticketRepo.findOne(ticketInfo);
  }
  async getTickets(ticketsInfo: GetTicketsDto, pageInfo: PageInfoRequestDto) {
    return await this.ticketRepo.find(ticketsInfo, pageInfo);
  }
  async verifyTicket(ticketInfo: VerifyTicketDto) {
    const updateTicket = await this.ticketRepo.findOne(ticketInfo);
    if (updateTicket.entered == true) {
      throw new BadRequestException(`ticket has been verified`);
    }
    const updatedTicket = await this.ticketRepo.update(ticketInfo, { entered: true });
    const verifyTicketEvent = new VerifyTicketEvent();
    verifyTicketEvent.eventId = updatedTicket.eventId;
    verifyTicketEvent.ticketNumber = updatedTicket.ticketNumber;
    this.eventEmitter.emit('verify-ticket-event', verifyTicketEvent);
    return updatedTicket;
  }
  async getCount(ticketInfo: TicketCountDto) {
    return this.ticketRepo.getCounts(ticketInfo);
  }
}

實作 EventCouterService

import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { IncreaseAttendeeDto, IncreaseTicketDto, InitialCounterDto } from './dto/ticket.dto';
import { EventCounterStore } from './event-counter.store';
import { EventCounterRespository } from './event-counter.repository';
import { OnEvent } from '@nestjs/event-emitter';
import { CreateCounterEvent, CreateTicketEvent, VerifyTicketEvent } from './dto/ticket.event';

@Injectable()
export class EventsCounterService {
  constructor(
    @Inject(EventCounterStore)
    private readonly eventCounterRepo: EventCounterRespository,
  ) {}
  async initCounter(initRequestDto: InitialCounterDto) {
    const result = await this.eventCounterRepo.ticketIncr(initRequestDto.eventId,0,0,0);
    return result;
  }
  async increaseTicketCount(increaseTicketRequestDto: IncreaseTicketDto) {
    const currentResult = await this.eventCounterRepo.get(increaseTicketRequestDto.eventId);
    const result = await this.eventCounterRepo.ticketIncr(increaseTicketRequestDto.eventId, increaseTicketRequestDto.ticketNumber, currentResult.attendeeNumber, currentResult.totalTicketNumber);
    return result;
  }
  async increaseAttendeeCount(increaseAttendeeRequestDto: IncreaseAttendeeDto) {
    const currentResult = await this.eventCounterRepo.get(increaseAttendeeRequestDto.eventId);
    if (currentResult.totalTicketNumber < currentResult.attendeeNumber + increaseAttendeeRequestDto.ticketNumber) {
      throw new BadRequestException(`increase attendee number should not large than total ticket number`);
    }
    const result = await this.eventCounterRepo.verifyIncr(increaseAttendeeRequestDto.eventId, increaseAttendeeRequestDto.ticketNumber, currentResult.attendeeNumber, currentResult.totalTicketNumber);
    return result;
  }

  @OnEvent('create-counter-event')
  async handleCreateCounter(payload: CreateCounterEvent) {
    await this.initCounter(payload);
  }
  @OnEvent('create-ticket-event')
  async handleCreateTicket(payload: CreateTicketEvent) {
    await this.increaseTicketCount(payload);
  }
  @OnEvent('verify-ticket-event')
  async handleVerifyTicket(payload: VerifyTicketEvent) {
    await this.increaseAttendeeCount(payload);
  }
}

實作 TicketController

import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { CreateTicketDto, GetTicketsDto, VerifyTicketDto } from './dto/ticket.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PageInfoRequestDto } from '../pagination.dto';
import { PermissionGuard } from './guards/permission.guard';
import { RolesGuard } from '../auth/guards/role.guard';
import { Roles } from '../auth/roles.decorator';

@Controller('tickets')
export class TicketsController {
  constructor(
    private readonly ticketsService: TicketsService,
  ) {}
  @Post()
  @UseGuards(JwtAuthGuard)
  async createTicket(@Body() createTicketDto: CreateTicketDto) {
    return this.ticketsService.createTicket(createTicketDto);
  }
  @Get(':id')
  @UseGuards(JwtAuthGuard, PermissionGuard)
  async getTicket(@Param('id', ParseUUIDPipe) id: string) {
    return this.ticketsService.getTicket({ id: id });
  }
  @Get()
  @UseGuards(JwtAuthGuard, PermissionGuard)
  async getTickets(@Query() getTicketsDto: GetTicketsDto, @Query() pageInfo: PageInfoRequestDto) {
    return this.ticketsService.getTickets(getTicketsDto, pageInfo);
  }
  @Patch()
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(['admin'])
  async verifyTicket(@Body() verifyTicketDto: VerifyTicketDto) {
    return this.ticketsService.verifyTicket(verifyTicketDto);
  }
}

設定 TicketModule 的 Providers

import { Module } from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { TicketsController } from './tickets.controller';
import { EventsCounterService } from './events-counter.service';
import { TicketsStore } from './tickets.store';
import { EventCounterStore } from './event-counter.store';

@Module({
  providers: [TicketsService, EventsCounterService, TicketsStore, EventCounterStore],
  controllers: [TicketsController]
})
export class TicketsModule {}

加入 EventEmitterModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { validateSchema } from './validate.schema';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventsModule } from './events/events.module';
import { TicketsModule } from './tickets/tickets.module';
import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
  imports: [
    EventEmitterModule.forRoot(),
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: validateSchema,
    }),
    TypeOrmModule.forRootAsync({
      useFactory(configService: ConfigService) {
        const IS_DB_SSL_MODE = configService.get<string>('NODE_ENV', 'dev') == 'production';
        return {
          ssl: IS_DB_SSL_MODE,
          extra: {
            ssl: IS_DB_SSL_MODE ? { rejectUnauthorized: false } : null,
            poolSize: 5,
            idleTimeoutMillis: 3600000,
          },
          type: 'postgres',
          url: configService.getOrThrow<string>('DB_URI', ''),
          synchronize: false,
          autoLoadEntities: true,
        }
      },
      inject:[ConfigService]
    }),
    UsersModule, AuthModule, EventsModule, TicketsModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

執行 unit test 驗證行為

pnpm test:watch

image

執行 e2e 驗證

image

Postman 驗證

  1. create ticket

image

  1. get ticket
    image

  2. get tickets
    image

  3. verify tickets
    image

由於避免篇幅過長,下一篇會接續把今天的設計作探討,並且找出合適的改善之處。

結論

到目前為止,可以看出本篇的 TicketModule 會是整個系統的核心之處。因為訂票管理系統的價值就在於針對 Ticket Entity 的狀態管理。而 EventCounter 的部份其細部實做,需要考慮到併發的狀況下,如何正確的觸發 Counter 狀態。今天這實作在 local memory 最大的問題在於無法 scale 。當機器需要多台時,這個狀態就無法同步到另一個機器,因此透過外部 Memory Storage 如 redis 是比較好的作法。另外,透過 redis 的 lua script 。能夠保證,改變 counter 操作具備原子性。

event emitter 的設計主要是為了把 Counter 服務與 Ticket 服務作切分。讓兩個服務的耦合性降低。當然,如果希望能夠把這個 event 能夠拓展到外部服務能夠接收的話,也是可以透過類似 message broker 的方式來設計。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - Event Module part 2
下一篇
nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part2
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言