今天目標會是先處理, TicketModule 的關於資料儲存部份,與 Counter 同步的問題。
TicketService 的職責,主要是負責處理關於 Ticket 狀態的管理更新。目前是使用 TicketStore 這個來作狀態儲存。當伺服器一旦重新啟動,則原本的狀態就會消失了。
這邊會跟之前 EventModule 的 EventUsersService 類似,透過 Repository 這個對資料做操作的抽象介面,來替換真正存取資料庫的部份。
EventCounterService 的職責,主要是負責處理統計 Event 得票存狀態。這個部份,現在透過 InMemory 的 EventCounterStore 來作狀態存儲,並且以 Singleton 方式來確保整個 App 在單一個 nestjs 應用內只有單一來源。在這個實做中,假設遇到需要作 scale 時,就必須要想辦法廣播當下 Event 統計資料到多個 nestjs 應用上。以及限制併發存取只能有單一個 thread 去修改。
為了要能讓多個服務一同存取 Event 統計資料。因此,採取 Redis 作為狀態分享 storage 。而限制併發更新統計量也是透過 Redis 的 lua script 去達成。
但由於這部份內容比較多,所以今天先實做到 TypeOrm Entity 的部份。
當下需要關注的 Entity 主要有以下兩個:
然而,根據情境所述。 eventId 來自於 Event entity , userId 來自於 User entity 。 所以,其 Entity 關係圖如下:
今天會先處理的部份會,如下
redis 與 rabbitmq 的部份打算放到之後章節在繼續
import { IsBoolean, IsDate, IsNumber, IsOptional, IsPositive, IsUUID } from 'class-validator';
import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
@Entity('tickets', { schema: 'public' })
export class TicketEntity {
@PrimaryColumn({
type: 'uuid',
name: 'id'
})
@IsUUID()
id: string;
@Column({
type: 'uuid',
name: 'event_id',
nullable: false,
})
@IsUUID()
eventId: string;
@Column({
type: 'uuid',
name: 'user_id',
nullable: false
})
@IsUUID()
userId: string;
@Column({
type: 'boolean',
name: 'entered',
default: false,
nullable: false,
})
@IsBoolean()
@IsOptional()
entered?: boolean = false;
@Column({
type: 'bigint',
name: 'ticket_number',
default: 1,
nullable: false,
})
@IsPositive()
@IsNumber()
@IsOptional()
ticketNumber?: number = 1;
@CreateDateColumn({
type: 'timestamp without time zone',
name: 'created_at',
nullable: false,
default: 'now()',
})
@IsDate()
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp without time zone',
name: 'updated_at',
nullable: false,
default: 'now()',
})
@IsDate()
updatedAt: Date;
}
npm run typeorm:create-migration --name=TICKET
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class TICKET1726456107308 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'public',
name: 'tickets',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
},
{
name: 'event_id',
type: 'uuid',
isNullable: false,
},
{
name: 'user_id',
type: 'uuid',
isNullable: false,
},
{
name: 'entered',
type: 'boolean',
isNullable: false,
default: false,
},
{
name: 'ticket_number',
type: 'bigint',
isNullable: false,
default: 1,
},
{
name: 'created_at',
type: 'timestamp without time zone',
isNullable: false,
default: 'now()'
},
{
name: 'updated_at',
type: 'timestamp without time zone',
isNullable: false,
default: 'now()'
}
],
foreignKeys: [
{
name: 'event_id_reference',
columnNames: ['event_id'],
referencedColumnNames: ['id'],
referencedTableName: 'public.events',
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
{
name: 'user_id_reference',
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'public.users',
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
}
]
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('public.tickets', true, true, true);
}
}
import { PageInfoRequestDto } from 'src/pagination.dto';
import { CreateTicketDto, TicketsResponse, TicketsCountResponseDto } from './dto/ticket.dto';
import { TicketEntity } from './schema/ticket.entity';
import { TicketsRepository } from './tickets.repository';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
@Injectable()
export class TicketDbStore implements TicketsRepository {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(TicketEntity)
private readonly ticketRepo: Repository<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;
}
await this.ticketRepo.save(ticket);
return ticket;
}
async findOne(criteria: Partial<TicketEntity>): Promise<TicketEntity> {
const ticket = await this.ticketRepo.findOneBy(criteria);
if (!ticket) {
throw new NotFoundException('ticket not found');
}
return ticket;
}
async find(criteria: Partial<TicketEntity>, pageInfo: PageInfoRequestDto): Promise<TicketsResponse> {
let queryBuilder = this.ticketRepo.createQueryBuilder('tickets');
const offset = pageInfo.offset;
const limit = pageInfo.limit;
let whereCount = 0;
if (criteria.userId) {
queryBuilder = (whereCount == 0)?
queryBuilder.where('tickets.user_id = :userId', { userId: criteria.userId }):
queryBuilder.andWhere('tickets.user_id = :userId', { userId: criteria.userId })
whereCount++;
}
if (criteria.eventId) {
queryBuilder = (whereCount == 0)?
queryBuilder.where('tickets.event_id = : eventId', { eventId: criteria.eventId }):
queryBuilder.andWhere('tickets.event_id = : eventId', { eventId: criteria.eventId });
whereCount++;
}
queryBuilder = queryBuilder.offset(offset);
queryBuilder = queryBuilder.limit(limit);
const [tickets, total] = await queryBuilder.getManyAndCount();
return {
tickets,
pageInfo: {
total: total,
offset: pageInfo.offset,
limit: pageInfo.limit
}
}
}
async update(criteria: Partial<TicketEntity>, data: Partial<TicketEntity>): Promise<TicketEntity> {
const queryBuilder = this.ticketRepo.createQueryBuilder('tickets');
const result = await queryBuilder.update<TicketEntity>(TicketEntity, data)
.where(criteria).returning(['id', 'eventId', 'userId', 'entered', 'ticketNumber', 'createdAt', 'updatedAt'])
.updateEntity(true).execute();
const model: TicketEntity = new TicketEntity();
model.id = result.raw[0]['id'];
model.eventId = result.raw[0]['event_id'];
model.userId = result.raw[0]['user_id'];
model.entered = result.raw[0]['entered'];
model.createdAt = new Date(result.raw[0]['created_at']);
model.updatedAt = new Date(result.raw[0]['updated_at']);
return model;
}
async getCounts(criteria: Partial<TicketEntity>): Promise<TicketsCountResponseDto> {
const queryManager = this.dataSource.manager;
const [totalResult, attendeeResult] = await Promise.all([
queryManager.query<{total: number}>('SELECT COUNT(ticket_number) AS total FROM public.tickets where event_id=?', [criteria.eventId]),
queryManager.query<{total: number}>('SELECT COUNT(ticket_number) AS total FROM public.tickets where event_id=? and entered=false', [criteria.eventId]),
]);
const accumTickets = totalResult.total;
const accumAttendee = attendeeResult.total;
return {
eventId: criteria.eventId,
accumTickets: accumTickets,
accumAttendee: accumAttendee
}
}
}
import { Module } from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { TicketsController } from './tickets.controller';
import { EventsCounterService } from './events-counter.service';
import { EventCounterStore } from './event-counter.store';
import { TicketDbStore } from './ticket-db.store';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TicketEntity } from './schema/ticket.entity';
@Module({
imports: [ TypeOrmModule.forFeature([TicketEntity])],
providers: [TicketsService, EventsCounterService, EventCounterStore, TicketDbStore],
controllers: [TicketsController]
})
export class TicketsModule {}
pnpm run test:watch
pnpm run test:e2e
Ticket Module 是整個系統核心部份,包含的部份比較多。 Entity 與之前不同的是這邊的兩個 field userId 與 eventId 多了欄位限制。必須要是在另外兩個資料表存在的資訊才能,合法存入。
透過 Test container 這樣的技術,可以讓整個測試過程更加方便的來對系統作完整的測試。