今天目標會是先規劃, TicketModule 的部份。
這邊所關注的 Ticket ,主要職責是用來紀錄每個使用者與活動的關係,並且紀錄其狀態。舉例來說: 當某一個使用者,看到某一個活動,就可以透過對這個活動執行買入的動作。當買入動作執行後,系統會透過使用者的識別子與活動的識別子當做參數,生成一個叫作票卷的紀錄到系統裡,也就是所謂『 Ticket 』。
Ticket 也就是票卷,基本就是用來紀錄使用者與活動之間的關係。管理票卷就是處理使用者參與活動的紀錄。針對票卷基本上有以下狀態轉換。
可以從使用情境了解到,使用者與活動以及票卷這三個資訊之間具有相依關係。票券會透過活動與使用者資訊組成。
根據之前分析的 Entity 關係圖如下
活動參加人數與活動票卷數目,其實都是可以從 Ticket 的紀錄來推導出來。因此,如果額外設計出一個一個 entity 來做紀錄,就必須要考慮到狀態一致性的問題。
但如果每次都需要去查詢整個活動的 Ticket 紀錄來做計數,又會讓效能變差。
兩者取得平衡的方式是建立一個 Read Model 專門來,處理當下總數,而每次建立或是 修改 Ticket 時,就透過事件的方事件更新這個 Read Model 總數。
讀取 event Entity 時,則需要新增加這個 EventMeta 的 Entity 的資料。
nest g mo tickets
nest g s tickets
nest g co tickets
nest g s event-counter tickets --flat
為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫
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);
});
});
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);
});
});
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);
});
});
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);
});
});
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>;
}
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
}
}
}
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>
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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 {}
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 {}
pnpm test:watch
get ticket
get tickets
verify tickets
由於避免篇幅過長,下一篇會接續把今天的設計作探討,並且找出合適的改善之處。
到目前為止,可以看出本篇的 TicketModule 會是整個系統的核心之處。因為訂票管理系統的價值就在於針對 Ticket Entity 的狀態管理。而 EventCounter 的部份其細部實做,需要考慮到併發的狀況下,如何正確的觸發 Counter 狀態。今天這實作在 local memory 最大的問題在於無法 scale 。當機器需要多台時,這個狀態就無法同步到另一個機器,因此透過外部 Memory Storage 如 redis 是比較好的作法。另外,透過 redis 的 lua script 。能夠保證,改變 counter 操作具備原子性。
event emitter 的設計主要是為了把 Counter 服務與 Ticket 服務作切分。讓兩個服務的耦合性降低。當然,如果希望能夠把這個 event 能夠拓展到外部服務能夠接收的話,也是可以透過類似 message broker 的方式來設計。