Raffles - Sistema de Sorteios
Sistema de sorteios onde usuarios gastam tickets (moeda virtual) para concorrer a premios (skins/itens), com multiplos premios por sorteio e resultados verificaveis via Provably Fair. Cada premio e sorteado individualmente com um roll independente, e um usuario so pode ganhar um premio por sorteio (regra one-per-user com reroll).
Conceito de Tickets (Moeda Virtual)
Tickets NAO sao saldo em BRL. Sao uma moeda virtual separada armazenada no campo User.tickets (tipo Int).
Como o usuario obtem tickets
1. Depositos (fonte principal)
Arquivo: src/application/services/deposit.service.ts
const ticketsToAdd = Math.floor(Number(deposit.amountCents) / 3000);
if (ticketsToAdd > 0) {
const user = await this.userRepository.findById(deposit.userId);
if (user) {
await this.userRepository.update(deposit.userId, {
tickets: user.tickets + ticketsToAdd,
});
}
}Regra: a cada R$30,00 (3000 centavos) depositados, o usuario recebe 1 ticket. Depositos menores que R$30 nao geram tickets. O calculo usa Math.floor, ou seja, R$59,99 gera 1 ticket e R$60,00 gera 2 tickets.
2. Recompensas de Eventos (Battle Pass)
Arquivo: src/application/use-cases/event/claim-event-reward.use-case.ts
case RewardType.TICKETS:
if (eventLevel.rewardTickets) {
await this.userRepository.incrementTickets(userId, eventLevel.rewardTickets);
}
break;Niveis do evento podem recompensar com tickets. A quantidade e definida no campo EventLevel.rewardTickets do schema Prisma.
Armazenamento no User
model User {
tickets Int @default(0)
eventTickets Int @default(0) @map("event_tickets")
}tickets: saldo de tickets de raffle (gasto ao comprar tickets de sorteio)eventTickets: saldo separado para o sistema de eventos (NAO usado em raffles)
Operacoes de tickets no UserRepository
Arquivo: src/infrastructure/database/repositories/user.repository.ts
incrementTickets:
async incrementTickets(id: bigint, amount: number): Promise<User> {
return this.prisma.user.update({
where: { id },
data: {
tickets: {
increment: amount,
},
},
});
}decrementTickets (com validacao atomica):
async decrementTickets(id: bigint, amount: number): Promise<User> {
const user = await this.prisma.user.findUnique({
where: { id },
select: { tickets: true },
});
if (!user || user.tickets < amount) {
throw new Error('Insufficient tickets');
}
return this.prisma.user.update({
where: {
id,
tickets: { gte: amount },
},
data: {
tickets: {
decrement: amount,
},
},
});
}O decrementTickets usa uma condicao WHERE tickets >= amount no update para prevenir race conditions de saldo negativo.
Modelo de Dados (Prisma Schema)
Arquivo: prisma/schema.prisma
RaffleStatus
enum RaffleStatus {
PENDING
ACTIVE
DRAWING
COMPLETED
CANCELLED
}| Status | Descricao |
|---|---|
PENDING | Sorteio criado mas ainda nao atingiu startsAt. Nao aceita compra de tickets. |
ACTIVE | Sorteio em andamento. Aceita compra de tickets. |
DRAWING | Sorteio em execucao. Nao aceita mais compras. Transicao temporaria. |
COMPLETED | Sorteio finalizado. Todos os premios entregues. |
CANCELLED | Sorteio cancelado (nenhum ticket vendido quando o draw foi acionado). |
Transicoes de status
PENDING -> ACTIVE (startsAt atingido via activateScheduledRaffles)
ACTIVE -> DRAWING (draw iniciado via DrawRaffleUseCase)
ACTIVE -> CANCELLED (draw iniciado mas soldTickets == 0)
DRAWING -> COMPLETED (todos os premios sorteados com sucesso)Raffle
model Raffle {
id BigInt @id
ticketPrice Int @default(1) @map("ticket_price")
maxTickets Int @map("max_tickets")
minTicketsPerUser Int? @map("min_tickets_per_user")
maxTicketsPerUser Int? @map("max_tickets_per_user")
status RaffleStatus @default(PENDING)
startsAt DateTime @map("starts_at")
endsAt DateTime @map("ends_at")
drawnAt DateTime? @map("drawn_at")
createdAt DateTime @default(now()) @map("created_at")
prizes RafflePrize[]
tickets RaffleTicket[]
@@map("raffles")
@@index([status, endsAt])
@@index([startsAt])
}| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado pelo SnowflakeService |
ticketPrice | Int | Custo em tickets por unidade comprada. Default 1. Ex: se ticketPrice=2 e o usuario compra 5, gasta 10 tickets. |
maxTickets | Int | Total maximo de tickets (numeros) que podem ser vendidos no sorteio. |
minTicketsPerUser | Int? | Minimo de tickets na primeira compra. Validado apenas quando userTicketCount === 0. Opcional. |
maxTicketsPerUser | Int? | Maximo de tickets por usuario no sorteio inteiro. Opcional. |
status | RaffleStatus | Status atual. Comeca como PENDING. |
startsAt | DateTime | Quando o sorteio fica ativo para compras. |
endsAt | DateTime | Deadline. Compras apos essa data sao rejeitadas. |
drawnAt | DateTime? | Momento em que o sorteio foi executado. Preenchido apos COMPLETED. |
RafflePrize
model RafflePrize {
id BigInt @id
raffleId BigInt @map("raffle_id")
itemId BigInt @map("item_id")
valueCents BigInt @map("value_cents")
valueCentsBrl BigInt @map("value_cents_brl")
displayOrder Int @default(0) @map("display_order")
winnerId BigInt? @map("winner_id")
winningTicketNumber Int? @map("winning_ticket_number")
serverSeed String? @map("server_seed")
serverSeedHash String? @map("server_seed_hash")
clientSeed String? @map("client_seed")
nonce Int?
roll Int?
drawnAt DateTime? @map("drawn_at")
createdAt DateTime @default(now()) @map("created_at")
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id])
winner User? @relation("RafflePrizeWinner", fields: [winnerId], references: [id])
@@map("raffle_prizes")
@@index([raffleId])
@@index([winnerId])
}| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID |
raffleId | BigInt | FK para o sorteio pai |
itemId | BigInt | FK para o item que e o premio |
valueCents | BigInt | Valor do item em centavos USD |
valueCentsBrl | BigInt | Valor do item em centavos BRL (usado para exibicao) |
displayOrder | Int | Ordem de exibicao e de sorteio. Premios sao sorteados em ordem crescente de displayOrder. |
winnerId | BigInt? | ID do usuario vencedor. Preenchido apos o draw. |
winningTicketNumber | Int? | Numero do ticket vencedor. Preenchido apos o draw. |
serverSeed | String? | Server seed usado neste prize especifico (revelado apos draw). |
serverSeedHash | String? | Hash do server seed (visivel antes do draw para verificacao). |
clientSeed | String? | Client seed usado (mesmo para todos os prizes do sorteio). |
nonce | Int? | Nonce usado: displayOrder + 1 |
roll | Int? | Resultado do roll Provably Fair. |
drawnAt | DateTime? | Momento em que este premio foi sorteado. |
Cada prize tem seus PROPRIOS dados de Provably Fair (serverSeed, serverSeedHash, clientSeed, nonce, roll). O serverSeed e gerado individualmente por prize no momento do draw. O clientSeed e o mesmo para todos os prizes (passado como parametro do draw).
RaffleTicket
model RaffleTicket {
id BigInt @id
raffleId BigInt @map("raffle_id")
userId BigInt @map("user_id")
ticketNumber Int @map("ticket_number")
purchasedAt DateTime @default(now()) @map("purchased_at")
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([raffleId, ticketNumber])
@@map("raffle_tickets")
@@index([raffleId])
@@index([userId])
@@index([raffleId, userId])
}| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID |
raffleId | BigInt | FK para o sorteio |
userId | BigInt | FK para o usuario que comprou |
ticketNumber | Int | Numero sequencial do ticket. Comeca em 0. Unique por raffle. |
purchasedAt | DateTime | Momento da compra |
O constraint @@unique([raffleId, ticketNumber]) garante que nao existam tickets duplicados dentro do mesmo sorteio.
Fluxo de Compra de Tickets
Arquivo: src/application/use-cases/raffle/buy-raffle-tickets.use-case.ts
Codigo completo do use case
@Injectable()
export class BuyRaffleTicketsUseCase {
constructor(
@Inject('IRaffleRepository')
private readonly raffleRepository: IRaffleRepository,
@Inject('IUserRepository')
private readonly userRepository: IUserRepository,
private readonly lockService: LockService,
private readonly prisma: PrismaService,
private readonly webSocketGateway: WebSocketGatewayService,
private readonly drawRaffleUseCase: DrawRaffleUseCase,
private readonly auditService: AuditService,
private readonly transactionService: TransactionService,
) {}
async execute(userId: bigint, raffleId: bigint, quantity: number) {
if (quantity < 1 || quantity > RAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE) {
throw new BadRequestException(
`Compre entre 1 e ${RAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE} tickets por vez`,
);
}
const lockResult = await this.lockService.withLock(`raffle:buy:${raffleId}`, async () => {
return this.lockService.withUserBalanceLock(userId, async () => {
const raffle = await this.raffleRepository.findById(raffleId);
if (!raffle) {
throw new NotFoundException('Sorteio nao encontrado');
}
if (raffle.status !== RaffleStatus.ACTIVE) {
throw new BadRequestException('Sorteio nao esta ativo');
}
if (raffle.endsAt < new Date()) {
throw new BadRequestException('Sorteio encerrado');
}
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('Usuario nao encontrado');
}
const totalTicketsSold = await this.raffleRepository.getTotalTicketsSold(raffleId);
if (totalTicketsSold + quantity > raffle.maxTickets) {
throw new BadRequestException('Nao ha tickets suficientes disponiveis');
}
const userTicketCount = await this.raffleRepository.getUserTicketCount(raffleId, userId);
if (
raffle.minTicketsPerUser &&
userTicketCount === 0 &&
quantity < raffle.minTicketsPerUser
) {
throw new BadRequestException(
`Minimo de ${raffle.minTicketsPerUser} tickets para participar deste sorteio`,
);
}
if (raffle.maxTicketsPerUser && userTicketCount + quantity > raffle.maxTicketsPerUser) {
throw new BadRequestException(
`Maximo de ${raffle.maxTicketsPerUser} tickets por pessoa neste sorteio`,
);
}
const totalCost = quantity * raffle.ticketPrice;
if (user.tickets < totalCost) {
throw new BadRequestException('Tickets insuficientes');
}
const result = await this.prisma.$transaction(async () => {
await this.userRepository.decrementTickets(userId, totalCost);
const freshTotalSold = await this.raffleRepository.getTotalTicketsSold(raffleId);
const tickets = [];
for (let i = 0; i < quantity; i++) {
const ticketNumber = freshTotalSold + i;
const ticket = await this.raffleRepository.addTicket({
raffleId,
userId,
ticketNumber,
});
tickets.push(ticket);
}
return {
success: true,
tickets,
remainingTickets: user.tickets - totalCost,
totalTicketsSold: freshTotalSold + quantity,
maxTickets: raffle.maxTickets,
};
});
this.webSocketGateway.emitRaffleTicketPurchased(raffleId, {
userId: userId.toString(),
username: user.username,
avatarUrl: user.avatarUrl || '',
quantity,
totalTicketsSold: result.totalTicketsSold,
maxTickets: raffle.maxTickets,
});
// Registro contabil: nao move saldo BRL, apenas tickets sao gastos
await this.transactionService.logOnly(
userId,
BigInt(0),
`Sorteio: Comprou ${quantity} ticket(s) no sorteio #${raffleId}`,
{
action: 'RAFFLE_TICKET_PURCHASE',
raffleId: raffleId.toString(),
quantity,
totalCost,
ticketPrice: raffle.ticketPrice,
ticketsSpent: totalCost,
},
);
await this.auditService.logUserAction(
userId,
'RAFFLE_TICKETS_PURCHASED',
'Raffle',
raffleId.toString(),
{
quantity,
totalCost,
totalTicketsSold: result.totalTicketsSold,
remainingTickets: result.remainingTickets,
},
);
return result;
});
}, 10000);
if (lockResult.totalTicketsSold >= lockResult.maxTickets) {
const clientSeed = `auto-draw-${Date.now()}`;
this.drawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW').catch((err) => {
console.error(`[Raffle] Auto-draw failed for raffle ${raffleId}:`, err);
});
}
return lockResult;
}
}Fluxo passo a passo
Validacao de quantidade:
quantitydeve estar entre 1 eRAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE(500) — validacao do DTO com@IsInt() @Min(1) @Max(500)e validacao adicional no use case. Feita ANTES de qualquer lock.Locks distribuidos aninhados: primeiro
lockService.withLock("raffle:buy:{raffleId}", ..., 10000)(serializa compras concorrentes no MESMO sorteio), e dentro delewithUserBalanceLock(userId)(serializa compras do mesmo usuario). Todas as leituras e writes abaixo acontecem dentro desses dois locks.Busca do raffle:
raffleRepository.findById(raffleId). Retorna 404 se nao encontrado.Validacao de status: O raffle DEVE ter status
ACTIVE. Qualquer outro status rejeita com 400.Validacao de prazo:
raffle.endsAtdeve ser no futuro. Se ja passou, rejeita com 400.Busca do usuario:
userRepository.findById(userId). Retorna 404 se nao encontrado.Validacao de disponibilidade:
getTotalTicketsSold(raffleId)conta tickets no banco. SetotalTicketsSold + quantity > maxTickets, rejeita.Validacao de minTicketsPerUser: Se
raffle.minTicketsPerUseresta definido E o usuario ainda nao tem tickets (userTicketCount === 0) Equantity < minTicketsPerUser, rejeita. Essa validacao so se aplica na primeira compra do usuario.Validacao de maxTicketsPerUser: Se
raffle.maxTicketsPerUseresta definido EuserTicketCount + quantity > maxTicketsPerUser, rejeita. Aplica-se em todas as compras.Calculo de custo:
totalCost = quantity * raffle.ticketPrice. O custo e em tickets (moeda virtual), NAO em centavos BRL.Validacao de saldo de tickets:
user.tickets < totalCostrejeita com "Tickets insuficientes".Transacao atomica (
prisma.$transaction):- Debita tickets do usuario via
userRepository.decrementTickets(userId, totalCost) - Re-le a contagem dentro da transacao (
freshTotalSold = getTotalTicketsSold(raffleId)) e cria os tickets com numeracao sequencial:ticketNumber = freshTotalSold + i(comecando em 0) - Retorna resultado com tickets criados, saldo restante e contagem atualizada
- Debita tickets do usuario via
Broadcast WebSocket: Emite
raffle:ticket_purchasedpara todos os clientes conectados.Registro contabil (logOnly) + auditoria:
transactionService.logOnly(userId, BigInt(0), ..., { action: 'RAFFLE_TICKET_PURCHASE', ... })registra a compra SEM mover saldo BRL (apenas tickets sao gastos), eauditService.logUserAction(userId, 'RAFFLE_TICKETS_PURCHASED', ...)audita a operacao. Ambos dentro dos locks.Auto-draw (apos liberar os locks): Se
lockResult.totalTicketsSold >= lockResult.maxTickets, dispara o sorteio automaticamente viadrawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW')comclientSeed = "auto-draw-{timestamp}". Execucao fire-and-forget (.catch()apenas loga erro). NAO bloqueia a resposta da compra.
Numeracao dos tickets
Os tickets sao numerados sequencialmente comecando em 0:
Primeira compra (3 tickets): ticketNumber = 0, 1, 2
Segunda compra (2 tickets): ticketNumber = 3, 4
Terceira compra (1 ticket): ticketNumber = 5A numeracao e baseada na contagem re-lida dentro da transacao (freshTotalSold), garantida pelos locks distribuidos (raffle:buy:{raffleId} + user:balance:{userId}).
Fluxo de Sorteio (Draw)
Arquivo: src/application/use-cases/raffle/draw-raffle.use-case.ts
Constantes
const ANIMATION_DURATION_MS = 8000;Cada premio e animado no frontend por 8 segundos. O campo animationDelayMs de cada prize result e calculado como sequenceIndex * 8000.
Codigo completo do use case
@Injectable()
export class DrawRaffleUseCase {
constructor(
@Inject('IRaffleRepository')
private readonly raffleRepository: IRaffleRepository,
@Inject('IUserInventoryRepository')
private readonly inventoryRepository: IUserInventoryRepository,
@Inject('IUserRepository')
private readonly userRepository: IUserRepository,
private readonly provablyFairService: ProvablyFairService,
private readonly notificationService: NotificationService,
private readonly webSocketGateway: WebSocketGatewayService,
private readonly lockService: LockService,
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
private readonly transactionService: TransactionService,
) {}
async execute(raffleId: bigint, clientSeed: string, triggeredByUserId: bigint | 'AUTO_DRAW') {
return this.lockService.withLock(
`raffle:draw:${raffleId}`,
async () => {
const raffle = await this.raffleRepository.findById(raffleId);
if (!raffle) {
throw new NotFoundException('Sorteio nao encontrado');
}
if (raffle.status !== RaffleStatus.ACTIVE) {
throw new BadRequestException('Sorteio nao esta ativo');
}
const allTickets = await this.raffleRepository.getTicketsByRaffleId(raffleId);
if (allTickets.length === 0) {
await this.raffleRepository.updateStatus(raffleId, RaffleStatus.CANCELLED);
throw new BadRequestException('Nenhum ticket vendido, sorteio cancelado');
}
await this.raffleRepository.updateStatus(raffleId, RaffleStatus.DRAWING);
this.webSocketGateway.emitRaffleDrawing(raffleId);
const prizes = (raffle.prizes || []).sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
const excludedUserIds = new Set<string>();
const prizeResults: { /* ... */ }[] = [];
for (let i = 0; i < prizes.length; i++) {
const prize = prizes[i];
let eligibleTickets = allTickets.filter(
(t: any) => !excludedUserIds.has(t.userId.toString()),
);
if (eligibleTickets.length === 0) {
eligibleTickets = allTickets;
}
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const nonce = prize.displayOrder + 1;
const roll = this.provablyFairService.calculateRoll(
serverSeed,
clientSeed,
nonce,
eligibleTickets.length,
);
const winningIndex = roll % eligibleTickets.length;
const winningTicket = eligibleTickets[winningIndex];
await this.prisma.$transaction(async () => {
await this.raffleRepository.setPrizeWinner(
prize.id,
winningTicket.userId,
winningTicket.ticketNumber,
serverSeed,
serverSeedHash,
clientSeed,
nonce,
roll,
);
await this.inventoryRepository.create({
userId: winningTicket.userId,
itemId: prize.itemId,
acquiredFrom: 'Sorteio',
acquiredValueCents: prize.valueCentsBrl,
});
});
excludedUserIds.add(winningTicket.userId.toString());
const winner = await this.userRepository.findById(winningTicket.userId);
await this.notificationService.create({
userId: winningTicket.userId,
type: NotificationType.SYSTEM,
title: 'Voce ganhou no sorteio!',
message: `Parabens! Voce ganhou: ${prize.item?.name || 'Item'}`,
metadata: {
raffleId: raffleId.toString(),
prizeId: prize.id.toString(),
winningTicketNumber: winningTicket.ticketNumber,
},
});
await this.transactionService.logOnly(
winningTicket.userId,
prize.valueCentsBrl,
`Sorteio: Ganhou ${prize.item?.name || 'Item'} no sorteio #${raffleId}`,
{
action: 'RAFFLE_PRIZE_WON',
raffleId: raffleId.toString(),
wonItemId: prize.itemId.toString(),
wonItemName: prize.item?.name || '',
wonItemRarity: prize.item?.rarity || '',
wonItemValueCents: prize.valueCentsBrl.toString(),
winningTicketNumber: winningTicket.ticketNumber,
},
);
prizeResults.push({
prizeId: prize.id.toString(),
displayOrder: prize.displayOrder,
itemId: prize.itemId.toString(),
itemName: prize.item?.name || '',
itemImageUrl: prize.item?.imageUrl || '',
itemRarity: prize.item?.rarity || '',
valueCentsBrl: prize.valueCentsBrl.toString(),
winnerId: winningTicket.userId.toString(),
winnerUsername: winner?.username || '',
winnerAvatarUrl: winner?.avatarUrl || '',
winningTicketNumber: winningTicket.ticketNumber,
serverSeedHash,
sequenceIndex: i,
animationDelayMs: i * ANIMATION_DURATION_MS,
});
}
await this.prisma.raffle.update({
where: { id: raffleId },
data: {
status: RaffleStatus.COMPLETED,
drawnAt: new Date(),
},
});
this.webSocketGateway.emitRaffleResults(raffleId, prizeResults);
this.webSocketGateway.emitRaffleCompleted(raffleId);
const auditMetadata = {
triggeredBy: triggeredByUserId === 'AUTO_DRAW' ? 'AUTO_DRAW' : triggeredByUserId.toString(),
prizesCount: prizes.length,
winnersCount: prizeResults.length,
ticketsSold: allTickets.length,
maxTickets: raffle.maxTickets,
};
if (triggeredByUserId === 'AUTO_DRAW') {
await this.auditService.logSystemAction(
'RAFFLE_DRAWN', 'Raffle', raffleId.toString(), auditMetadata,
);
} else {
await this.auditService.logUserAction(
triggeredByUserId, 'RAFFLE_DRAWN', 'Raffle', raffleId.toString(), auditMetadata,
);
}
return this.raffleRepository.findById(raffleId);
},
30000,
);
}
}Fluxo passo a passo
Lock distribuido:
lockService.withLock("raffle:draw:{raffleId}", ..., 30000)adquire lock com TTL de 30 segundos. Previne draws concorrentes.Validacao: Status DEVE ser
ACTIVE. Se nao, rejeita com 400.Busca de todos os tickets:
getTicketsByRaffleId(raffleId)retorna todos os tickets ordenados porticketNumber ASC.Cancelamento automatico: Se
allTickets.length === 0, muda status paraCANCELLEDe lanca excecao.Transicao para DRAWING: Atualiza status e emite evento WebSocket
raffle:drawing.Ordenacao de premios: Premios sao ordenados por
displayOrder ASC. O premio com menor displayOrder e sorteado primeiro.Loop de sorteio por premio: Para cada prize, executa:
Algoritmo de selecao de vencedor (por premio)
Para cada prize (em ordem de displayOrder):
1. Filtrar eligibleTickets = tickets cujo userId NAO esta em excludedUserIds
2. Se eligibleTickets esta vazio (todos os participantes ja ganharam):
-> Reset: eligibleTickets = allTickets (todos voltam a ser elegiveis)
3. Gerar serverSeed = randomBytes(32).toString('hex')
4. Calcular serverSeedHash = HMAC-SHA256(serverSeed)
5. nonce = prize.displayOrder + 1
6. roll = provablyFairService.calculateRoll(serverSeed, clientSeed, nonce, eligibleTickets.length)
7. winningIndex = roll % eligibleTickets.length
8. winningTicket = eligibleTickets[winningIndex]
9. Salvar dados do prize winner (transacao atomica)
10. Adicionar item ao inventario do vencedor (acquiredFrom: 'Sorteio', acquiredValueCents: prize.valueCentsBrl)
11. Adicionar userId ao excludedUserIds (one-per-user)
12. Criar notificacao para o vencedor
13. Registrar logOnly (RAFFLE_PRIZE_WON) sem mover saldoRegra one-per-user
Cada usuario so pode ganhar UM premio por sorteio. Apos ganhar, seu userId e adicionado ao excludedUserIds (Set). Seus tickets sao excluidos dos tickets elegiveis para os premios seguintes.
Reset quando todos ja ganharam: Se, ao filtrar, nenhum ticket elegivel resta (ex: 3 participantes e 4 premios), o excludedUserIds e efetivamente ignorado e eligibleTickets = allTickets, permitindo que usuarios ganhem mais de um premio. Este e o unico caso em que a regra one-per-user nao se aplica.
Provably Fair no Draw
O calculateRoll no contexto do raffle usa max = eligibleTickets.length (NAO o default de 10.000.000):
const roll = this.provablyFairService.calculateRoll(
serverSeed,
clientSeed,
nonce,
eligibleTickets.length, // max = quantidade de tickets elegiveis
);Arquivo: src/application/services/provably-fair.service.ts
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const hashPrefix = hash.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
let result = decimal;
if (result >= maxSafeValue) {
const hmac2 = createHmac('sha256', serverSeed);
hmac2.update(`${clientSeed}:${nonce}:retry`);
const hash2 = hmac2.digest('hex');
result = parseInt(hash2.substring(0, 13), 16);
}
const roll = (result % max) + 1;
return roll;
}O roll retorna um valor entre 1 e max (inclusive). O winningIndex e calculado como roll % eligibleTickets.length, resultando em um indice entre 0 e eligibleTickets.length - 1.
Cada prize tem:
- Seu proprio
serverSeed(gerado na hora) - Seu proprio
serverSeedHash - O mesmo
clientSeed(compartilhado entre todos os prizes) nonce = displayOrder + 1
Entrega de premios
Todos os premios sao itens (skins). A entrega e feita adicionando o item ao inventario do usuario:
await this.inventoryRepository.create({
userId: winningTicket.userId,
itemId: prize.itemId,
acquiredFrom: 'Sorteio',
acquiredValueCents: prize.valueCentsBrl,
});NAO ha movimentacao de saldo BRL: o premio e sempre um item do tipo Item referenciado por prize.itemId. Uma entrada logOnly (RAFFLE_PRIZE_WON) registra o valor ganho (prize.valueCentsBrl) sem alterar o saldo.
Notificacao ao vencedor
Para cada premio sorteado, uma notificacao e criada:
await this.notificationService.create({
userId: winningTicket.userId,
type: NotificationType.SYSTEM,
title: 'Voce ganhou no sorteio!',
message: `Parabens! Voce ganhou: ${prize.item?.name || 'Item'}`,
metadata: {
raffleId: raffleId.toString(),
prizeId: prize.id.toString(),
winningTicketNumber: winningTicket.ticketNumber,
},
});A notificacao usa o tipo NotificationType.SYSTEM e inclui metadata com raffleId, prizeId e winningTicketNumber para rastreabilidade.
Timing de animacao
Cada prize result inclui campos para controle de animacao no frontend:
sequenceIndex: i, // 0, 1, 2, 3...
animationDelayMs: i * ANIMATION_DURATION_MS, // 0, 8000, 16000, 24000...ANIMATION_DURATION_MS = 8000 (8 segundos por premio).
Para um sorteio com 3 premios, a timeline no frontend e:
- Premio 1: inicia em 0ms, termina em 8000ms
- Premio 2: inicia em 8000ms, termina em 16000ms
- Premio 3: inicia em 16000ms, termina em 24000ms
- Duracao total:
prizes.length * 8000ms
Finalizacao
Apos sortear todos os premios:
- Atualiza o raffle para
status: COMPLETEDedrawnAt: new Date() - Emite
raffle:resultscom todos osprizeResults - Emite
raffle:completed - Retorna o raffle atualizado do banco
Repositorio
Arquivo: src/infrastructure/database/repositories/raffle.repository.ts
PRIZES_INCLUDE (reutilizavel)
const PRIZES_INCLUDE = {
prizes: {
include: {
item: true,
winner: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
},
orderBy: { displayOrder: 'asc' as const },
},
};Todas as queries que retornam raffles incluem os prizes com item data e winner data, ordenados por displayOrder ASC.
Metodos do repositorio
| Metodo | Descricao |
|---|---|
create(data) | Cria raffle com prizes. IDs gerados via Snowflake. Status inicial PENDING. |
findById(id) | Busca raffle com prizes, item, winner e tickets (com user data). |
findActive() | Busca raffles com status ACTIVE e endsAt > now(). Inclui _count.tickets. Ordenado por endsAt ASC. |
findCompleted(limit) | Busca raffles COMPLETED. Ordenado por drawnAt DESC. Default limit 10. |
updateStatus(id, status) | Atualiza apenas o status. |
setPrizeWinner(prizeId, winnerId, ...) | Atualiza o RafflePrize com dados do vencedor e Provably Fair. |
getTicketsByRaffleId(raffleId) | Retorna todos os tickets ordenados por ticketNumber ASC. |
addTicket(data) | Cria ticket com Snowflake ID. |
getUserTickets(raffleId, userId) | Retorna tickets do usuario no raffle, ordenados por ticketNumber ASC. |
getUserTicketCount(raffleId, userId) | Conta tickets do usuario no raffle. |
getTotalTicketsSold(raffleId) | Conta total de tickets vendidos no raffle. |
activateScheduledRaffles() | Muda status de PENDING para ACTIVE onde startsAt <= now(). Retorna count. |
completeExpiredRaffles() | Busca raffles ACTIVE com endsAt <= now(). Retorna para processamento. |
Interface do repositorio
Arquivo: src/domain/repositories/raffle.repository.interface.ts
export interface IRaffleRepository {
create(data: {
ticketPrice: number;
maxTickets: number;
minTicketsPerUser?: number;
maxTicketsPerUser?: number;
startsAt: Date;
endsAt: Date;
prizes: Array<{
itemId: bigint;
valueCents: bigint;
valueCentsBrl: bigint;
displayOrder: number;
}>;
}): Promise<any>;
findById(id: bigint): Promise<any>;
findActive(): Promise<any[]>;
findCompleted(limit?: number): Promise<any[]>;
updateStatus(id: bigint, status: RaffleStatus): Promise<any>;
setPrizeWinner(
prizeId: bigint,
winnerId: bigint,
winningTicketNumber: number,
serverSeed: string,
serverSeedHash: string,
clientSeed: string,
nonce: number,
roll: number,
): Promise<any>;
getTicketsByRaffleId(raffleId: bigint): Promise<any[]>;
addTicket(data: { raffleId: bigint; userId: bigint; ticketNumber: number }): Promise<any>;
getUserTickets(raffleId: bigint, userId: bigint): Promise<any[]>;
getUserTicketCount(raffleId: bigint, userId: bigint): Promise<number>;
getTotalTicketsSold(raffleId: bigint): Promise<number>;
activateScheduledRaffles(): Promise<number>;
completeExpiredRaffles(): Promise<any[]>;
}Use Cases de Leitura
GetActiveRafflesUseCase
Arquivo: src/application/use-cases/raffle/get-active-raffles.use-case.ts
@Injectable()
export class GetActiveRafflesUseCase {
constructor(
@Inject('IRaffleRepository')
private readonly raffleRepository: IRaffleRepository,
) {}
async execute() {
return this.raffleRepository.findActive();
}
}Retorna raffles com status ACTIVE e endsAt > now(), ordenados por endsAt ASC.
GetCompletedRafflesUseCase
Arquivo: src/application/use-cases/raffle/get-completed-raffles.use-case.ts
@Injectable()
export class GetCompletedRafflesUseCase {
constructor(
@Inject('IRaffleRepository')
private readonly raffleRepository: IRaffleRepository,
) {}
async execute(limit: number = 10) {
return this.raffleRepository.findCompleted(limit);
}
}Retorna raffles com status COMPLETED, ordenados por drawnAt DESC, com limite configuravel (default 10).
GetRaffleDetailsUseCase
Arquivo: src/application/use-cases/raffle/get-raffle-details.use-case.ts
@Injectable()
export class GetRaffleDetailsUseCase {
constructor(
@Inject('IRaffleRepository')
private readonly raffleRepository: IRaffleRepository,
) {}
async execute(raffleId: bigint, userId?: bigint) {
const raffle = await this.raffleRepository.findById(raffleId);
if (!raffle) {
throw new NotFoundException('Raffle not found');
}
const totalTicketsSold = await this.raffleRepository.getTotalTicketsSold(raffleId);
let userTickets = [];
let userTicketCount = 0;
if (userId) {
userTickets = await this.raffleRepository.getUserTickets(raffleId, userId);
userTicketCount = userTickets.length;
}
return {
...raffle,
totalTicketsSold,
userTickets,
userTicketCount,
availableTickets: raffle.maxTickets - totalTicketsSold,
};
}
}Aceita userId opcional (via OptionalAuthGuard). Se o usuario esta autenticado, retorna seus tickets e contagem. Calcula availableTickets como maxTickets - totalTicketsSold.
Endpoints da API
Arquivo: src/presentation/controllers/raffle.controller.ts
DTOs de entrada
class BuyTicketsDto {
@IsInt()
@Min(1)
@Max(500)
quantity: number;
}
class DrawRaffleDto {
@IsString()
clientSeed: string;
}Metodo auxiliar formatRaffle
Todas as respostas passam pelo formatRaffle() que normaliza os dados:
private formatRaffle(raffle: any) {
const prizes = (raffle.prizes || []).map((prize: any) => ({
id: prize.id.toString(),
itemId: prize.itemId.toString(),
name: prize.item?.name || '',
imageUrl: prize.item?.imageUrl || '',
rarity: prize.item?.rarity || '',
valueCentsBrl: prize.valueCentsBrl.toString(),
valueFormatted: this.moneyService.format(prize.valueCentsBrl),
displayOrder: prize.displayOrder,
winner: prize.winner
? {
id: prize.winner.id.toString(),
username: prize.winner.username,
avatarUrl: prize.winner.avatarUrl,
}
: null,
winningTicketNumber: prize.winningTicketNumber ?? null,
serverSeedHash: prize.serverSeedHash ?? null,
}));
const totalPrizeCentsBrl = (raffle.prizes || []).reduce(
(sum: bigint, p: any) => sum + BigInt(p.valueCentsBrl),
BigInt(0),
);
return {
id: raffle.id.toString(),
ticketPrice: raffle.ticketPrice,
maxTickets: raffle.maxTickets,
minTicketsPerUser: raffle.minTicketsPerUser,
maxTicketsPerUser: raffle.maxTicketsPerUser,
status: raffle.status,
startsAt: raffle.startsAt,
endsAt: raffle.endsAt,
drawnAt: raffle.drawnAt,
createdAt: raffle.createdAt,
prizes,
totalPrizeCentsBrl: totalPrizeCentsBrl.toString(),
totalPrizeFormatted: this.moneyService.format(totalPrizeCentsBrl),
ticketsSold: raffle._count?.tickets ?? raffle.tickets?.length ?? 0,
};
}O MoneyService.format() formata centavos BRL para string brasileira (ex: 70765n -> "R$ 707,65").
GET /raffle/active
Autenticacao: Publica (@Public())
Descricao: Lista todos os sorteios ativos.
Response (200):
[
{
"id": "1234567890123",
"ticketPrice": 1,
"maxTickets": 500,
"minTicketsPerUser": null,
"maxTicketsPerUser": 50,
"status": "ACTIVE",
"startsAt": "2026-02-10T00:00:00.000Z",
"endsAt": "2026-02-15T00:00:00.000Z",
"drawnAt": null,
"createdAt": "2026-02-09T20:00:00.000Z",
"prizes": [
{
"id": "9876543210123",
"itemId": "5555555555555",
"name": "AWP | Dragon Lore",
"imageUrl": "https://...",
"rarity": "EXTRAORDINARY",
"valueCentsBrl": "70765",
"valueFormatted": "R$ 707,65",
"displayOrder": 0,
"winner": null,
"winningTicketNumber": null,
"serverSeedHash": null
}
],
"totalPrizeCentsBrl": "70765",
"totalPrizeFormatted": "R$ 707,65",
"ticketsSold": 123
}
]GET /raffle/completed
Autenticacao: Publica (@Public())
Query params:
| Param | Tipo | Default | Descricao |
|---|---|---|---|
limit | string (convertido para int) | 10 | Quantidade maxima de resultados |
Response (200): Mesmo formato que GET /raffle/active, porem com premios que contem dados de vencedores preenchidos.
[
{
"id": "1234567890123",
"status": "COMPLETED",
"drawnAt": "2026-02-12T15:30:00.000Z",
"prizes": [
{
"id": "9876543210123",
"name": "AWP | Dragon Lore",
"winner": {
"id": "1111111111111",
"username": "PlayerOne",
"avatarUrl": "https://..."
},
"winningTicketNumber": 42,
"serverSeedHash": "abc123..."
}
]
}
]GET /raffle/:id
Autenticacao: Publica com autenticacao opcional (@UseGuards(OptionalAuthGuard) + @Public())
Params:
| Param | Tipo | Descricao |
|---|---|---|
id | string (convertido para BigInt) | ID do raffle |
Response (200):
{
"id": "1234567890123",
"ticketPrice": 1,
"maxTickets": 500,
"minTicketsPerUser": null,
"maxTicketsPerUser": 50,
"status": "ACTIVE",
"startsAt": "2026-02-10T00:00:00.000Z",
"endsAt": "2026-02-15T00:00:00.000Z",
"drawnAt": null,
"createdAt": "2026-02-09T20:00:00.000Z",
"prizes": [],
"totalPrizeCentsBrl": "70765",
"totalPrizeFormatted": "R$ 707,65",
"ticketsSold": 123,
"totalTicketsSold": 123,
"availableTickets": 377,
"userTicketCount": 5,
"userTickets": [
{ "ticketNumber": 10 },
{ "ticketNumber": 11 },
{ "ticketNumber": 12 },
{ "ticketNumber": 13 },
{ "ticketNumber": 14 }
],
"participants": [
{
"userId": "1111111111111",
"username": "PlayerOne",
"avatarUrl": "https://...",
"ticketNumber": 0
},
{
"userId": "2222222222222",
"username": "PlayerTwo",
"avatarUrl": "https://...",
"ticketNumber": 1
}
]
}Campos exclusivos deste endpoint:
totalTicketsSold: contagem real de tickets vendidosavailableTickets:maxTickets - totalTicketsSolduserTicketCount: quantidade de tickets do usuario autenticado (0 se nao autenticado)userTickets: lista de tickets do usuario comticketNumberparticipants: lista de TODOS os tickets com dados do usuario (userId, username, avatarUrl, ticketNumber)
Erros:
| Status | Condicao |
|---|---|
| 404 | Raffle nao encontrado |
POST /raffle/:id/buy
Autenticacao: Obrigatoria (@UseGuards(AuthGuard))
Params:
| Param | Tipo | Descricao |
|---|---|---|
id | string (convertido para BigInt) | ID do raffle |
Body:
{
"quantity": 5
}| Campo | Tipo | Validacao | Descricao |
|---|---|---|---|
quantity | number | @IsInt() @Min(1) @Max(500) | Quantidade de tickets a comprar |
Response (200):
{
"success": true,
"tickets": [
{
"id": "7777777777777",
"raffleId": "1234567890123",
"userId": "1111111111111",
"ticketNumber": 123
},
{
"id": "7777777777778",
"raffleId": "1234567890123",
"userId": "1111111111111",
"ticketNumber": 124
}
],
"remainingTickets": 45,
"totalTicketsSold": 250,
"maxTickets": 500
}Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 400 | "Compre entre 1 e 500 tickets por vez" | quantity < 1 ou quantity > 500 |
| 404 | "Sorteio nao encontrado" | Raffle inexistente |
| 400 | "Sorteio nao esta ativo" | status !== ACTIVE |
| 400 | "Sorteio encerrado" | endsAt < now() |
| 404 | "Usuario nao encontrado" | User inexistente |
| 400 | "Nao ha tickets suficientes disponiveis" | totalTicketsSold + quantity > maxTickets |
| 400 | "Minimo de X tickets para participar deste sorteio" | Primeira compra com quantity < minTicketsPerUser |
| 400 | "Maximo de X tickets por pessoa neste sorteio" | userTicketCount + quantity > maxTicketsPerUser |
| 400 | "Tickets insuficientes" | user.tickets < totalCost |
POST /raffle/:id/draw
Autenticacao: Obrigatoria (@UseGuards(AuthGuard)) -- usado pelo admin para trigger manual
Params:
| Param | Tipo | Descricao |
|---|---|---|
id | string (convertido para BigInt) | ID do raffle |
Body:
{
"clientSeed": "seed-fornecido-pelo-admin"
}| Campo | Tipo | Validacao | Descricao |
|---|---|---|---|
clientSeed | string | @IsString() | Client seed para o Provably Fair |
Response (200): Mesmo formato de formatRaffle() com todos os prizes atualizados com winners.
Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 404 | "Sorteio nao encontrado" | Raffle inexistente |
| 400 | "Sorteio nao esta ativo" | status !== ACTIVE |
| 400 | "Nenhum ticket vendido, sorteio cancelado" | 0 tickets vendidos (status muda para CANCELLED) |
Eventos WebSocket
Arquivo: src/infrastructure/websocket/websocket.gateway.ts
Todos os eventos de raffle sao broadcasts globais (this.server.emit()), ou seja, todos os clientes conectados recebem, independente de autenticacao.
raffle:ticket_purchased
Emitido apos cada compra de tickets bem sucedida.
Momento de emissao: Apos a transacao atomica de compra e ANTES da verificacao de auto-draw.
emitRaffleTicketPurchased(
raffleId: bigint,
data: {
userId: string;
username: string;
avatarUrl: string;
quantity: number;
totalTicketsSold: number;
maxTickets: number;
},
)Payload emitido:
{
"raffleId": "1234567890123",
"userId": "1111111111111",
"username": "PlayerOne",
"avatarUrl": "https://...",
"quantity": 5,
"totalTicketsSold": 250,
"maxTickets": 500
}raffle:drawing
Emitido quando o sorteio comeca (transicao ACTIVE -> DRAWING).
emitRaffleDrawing(raffleId: bigint)Payload emitido:
{
"raffleId": "1234567890123"
}raffle:results
Emitido apos todos os premios serem sorteados, com o resultado completo de cada premio.
emitRaffleResults(
raffleId: bigint,
prizeResults: {
prizeId: string;
displayOrder: number;
itemId: string;
itemName: string;
itemImageUrl: string;
itemRarity: string;
valueCentsBrl: string;
winnerId: string;
winnerUsername: string;
winnerAvatarUrl: string;
winningTicketNumber: number;
serverSeedHash: string;
sequenceIndex: number;
animationDelayMs: number;
}[],
)Payload emitido:
{
"raffleId": "1234567890123",
"prizeResults": [
{
"prizeId": "9876543210123",
"displayOrder": 0,
"itemId": "5555555555555",
"itemName": "AWP | Dragon Lore",
"itemImageUrl": "https://...",
"itemRarity": "EXTRAORDINARY",
"valueCentsBrl": "70765",
"winnerId": "1111111111111",
"winnerUsername": "PlayerOne",
"winnerAvatarUrl": "https://...",
"winningTicketNumber": 42,
"serverSeedHash": "abc123def456...",
"sequenceIndex": 0,
"animationDelayMs": 0
},
{
"prizeId": "9876543210124",
"displayOrder": 1,
"itemId": "5555555555556",
"itemName": "M4A4 | Howl",
"itemImageUrl": "https://...",
"itemRarity": "EXTRAORDINARY",
"valueCentsBrl": "125000",
"winnerId": "2222222222222",
"winnerUsername": "PlayerTwo",
"winnerAvatarUrl": "https://...",
"winningTicketNumber": 87,
"serverSeedHash": "def456abc789...",
"sequenceIndex": 1,
"animationDelayMs": 8000
}
]
}raffle:completed
Emitido imediatamente apos raffle:results, sinalizando que o sorteio terminou.
emitRaffleCompleted(raffleId: bigint)Payload emitido:
{
"raffleId": "1234567890123"
}Ordem de emissao no draw
1. raffle:drawing (status mudou para DRAWING)
2. [premios sorteados]
3. raffle:results (todos os resultados de uma vez)
4. raffle:completed (finalizacao)Hook Frontend: useRaffleSocket
Arquivo: client/app/lib/use-raffle-socket.tsx
Interfaces TypeScript
export interface RaffleTicketPurchasedData {
raffleId: string;
userId: string;
username: string;
avatarUrl: string;
quantity: number;
totalTicketsSold: number;
maxTickets: number;
}
export interface RaffleDrawingData {
raffleId: string;
}
export interface RafflePrizeResult {
prizeId: string;
displayOrder: number;
itemId: string;
itemName: string;
itemImageUrl: string;
itemRarity: string;
valueCentsBrl: string;
winnerId: string;
winnerUsername: string;
winnerAvatarUrl: string;
winningTicketNumber: number;
serverSeedHash: string;
sequenceIndex: number;
animationDelayMs: number;
}
export interface RaffleResultsData {
raffleId: string;
prizeResults: RafflePrizeResult[];
}
export interface RaffleCompletedData {
raffleId: string;
}
export interface RaffleSocketEvents {
onTicketPurchased?: (data: RaffleTicketPurchasedData) => void;
onDrawing?: (data: RaffleDrawingData) => void;
onResults?: (data: RaffleResultsData) => void;
onCompleted?: (data: RaffleCompletedData) => void;
}Uso do hook
export function useRaffleSocket(raffleId: string | null, events: RaffleSocketEvents = {})raffleId: Se fornecido, filtra eventos apenas para esse raffle. Senull, recebe eventos de todos os raffles.events: Callbacks opcionais para cada tipo de evento.- Retorna
{ isConnected }.
O hook filtra eventos por raffleId automaticamente:
const handleTicketPurchased = (data: RaffleTicketPurchasedData) => {
if (raffleId && data.raffleId !== raffleId) return;
eventsRef.current.onTicketPurchased?.(data);
};Eventos com raffleId diferente do informado sao ignorados. Se raffleId e null, todos os eventos passam.
Modulo NestJS
Arquivo: src/presentation/modules/raffle.module.ts
@Module({
imports: [DatabaseModule, RedisModule, LockModule, AuthModule, WebSocketModule],
controllers: [RaffleController],
providers: [
BuyRaffleTicketsUseCase,
GetActiveRafflesUseCase,
GetRaffleDetailsUseCase,
GetCompletedRafflesUseCase,
DrawRaffleUseCase,
ProvablyFairService,
NotificationService,
{
provide: 'IRaffleRepository',
useClass: RaffleRepository,
},
{
provide: 'IUserInventoryRepository',
useClass: UserInventoryRepository,
},
TransactionService,
TransactionRepository,
UserRepository,
],
exports: [],
})
export class RaffleModule {}Dependencias:
DatabaseModule: PrismaService, SnowflakeServiceRedisModule: Conexao RedisLockModule: LockService (Redlock)AuthModule: AuthGuard, OptionalAuthGuardWebSocketModule: WebSocketGatewayService- Providers locais adicionais:
TransactionService,TransactionRepository,UserRepository(usados porlogOnlye leitura do usuario)
Locks Distribuidos
Compra de tickets
this.lockService.withLock(`raffle:buy:${raffleId}`, async () => {
return this.lockService.withUserBalanceLock(userId, async () => { ... });
}, 10000);Dois locks aninhados: o externo raffle:buy:{raffleId} (TTL 10s) serializa compras concorrentes no MESMO sorteio (protege a contagem e a numeracao sequencial global); o interno user:balance:{userId} (TTL 10s) serializa compras do mesmo usuario. Juntos previnem race conditions no saldo de tickets e na numeracao dos tickets.
Draw do sorteio
this.lockService.withLock(`raffle:draw:${raffleId}`, async () => { ... }, 30000);Chave: raffle:draw:{raffleId}, TTL: 30 segundos.
Previne draws concorrentes do mesmo sorteio (ex: auto-draw disparado por duas compras simultaneas ou auto-draw + draw manual).
Triggers de Draw
Auto-draw (apos compra de tickets)
Quando totalTicketsSold >= maxTickets apos uma compra:
if (lockResult.totalTicketsSold >= lockResult.maxTickets) {
const clientSeed = `auto-draw-${Date.now()}`;
this.drawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW').catch((err) => {
console.error(`[Raffle] Auto-draw failed for raffle ${raffleId}:`, err);
});
}- Executado fire-and-forget (NAO bloqueia a resposta da compra)
clientSeedautomatico:"auto-draw-{timestamp}"- Se falhar, loga erro e pode ser retriggerado manualmente
Draw manual (admin)
Via endpoint POST /raffle/:id/draw com clientSeed fornecido pelo admin.
Draw por expiracao (scheduler)
Via completeExpiredRaffles() que busca raffles ACTIVE com endsAt <= now(). O scheduler externo chama o draw para cada raffle expirado.
Edge Cases
Todos os tickets vendidos durante multiplas compras simultaneas
O lock withUserBalanceLock serializa compras do mesmo usuario. A verificacao totalTicketsSold + quantity > maxTickets dentro do lock previne over-selling. Usuarios diferentes podem comprar concorrentemente (locks diferentes por userId), mas a contagem getTotalTicketsSold e a numeracao sequencial sao protegidas pela transacao atomica do Prisma.
Auto-draw disparado por duas compras simultaneas
O lock raffle:draw:{raffleId} no DrawRaffleUseCase garante que apenas um draw execute. O segundo draw que tentar adquirir o lock ira aguardar. Ao adquirir, encontrara status !== ACTIVE (ja mudou para DRAWING ou COMPLETED) e lancara excecao.
Sorteio com 0 tickets vendidos
O draw muda o status para CANCELLED e lanca BadRequestException('Nenhum ticket vendido, sorteio cancelado').
Mais premios que participantes unicos
Se ha 4 premios e apenas 3 participantes unicos:
- Premios 1, 2, 3: cada participante ganha um premio (regra one-per-user)
- Premio 4:
eligibleTicketsfica vazio apos filtrar excludedUserIds. Reset:eligibleTickets = allTickets. Um dos 3 participantes ganhara um segundo premio.
minTicketsPerUser na primeira compra
Validado apenas quando userTicketCount === 0. Se o usuario ja comprou tickets antes, pode comprar qualquer quantidade (respeitando o max).
ticketPrice diferente de 1
Se ticketPrice = 2 e o usuario compra quantity = 5, o custo total e 5 * 2 = 10 tickets debitados do user.tickets. O usuario recebe 5 RaffleTickets numerados sequencialmente.
Tickets comecam em 0
O primeiro ticket vendido no sorteio tem ticketNumber = 0, nao 1. A numeracao e baseada em totalTicketsSold no momento da compra: se 0 tickets foram vendidos, o primeiro e ticketNumber = 0.
Verificacao Provably Fair
Cada premio pode ser verificado independentemente:
- Antes do draw: O
serverSeedHashde cada prize e visivel (apos o draw ser executado e salvo no banco) - Apos o draw: O
serverSeede revelado
Como verificar um premio
1. Verificar serverSeedHash:
HMAC-SHA256(serverSeed) === serverSeedHash
2. Recalcular o roll:
hmac = HMAC-SHA256(serverSeed)
hmac.update(clientSeed + ":" + nonce)
hash = hmac.digest('hex')
hashPrefix = hash.substring(0, 13)
decimal = parseInt(hashPrefix, 16)
roll = (decimal % eligibleTickets.length) + 1
3. Verificar o vencedor:
winningIndex = roll % eligibleTickets.length
eligibleTickets[winningIndex].userId === winnerIdPara verificar corretamente, e necessario saber quais tickets eram elegiveis naquele momento (excluindo usuarios que ja ganharam premios anteriores na mesma sequencia).
Debugging
Backend
// Verificar status do raffle
const raffle = await raffleRepository.findById(id);
console.log('Status:', raffle.status);
console.log('Tickets vendidos:', await raffleRepository.getTotalTicketsSold(id));
console.log('Max tickets:', raffle.maxTickets);
console.log('Premios:', raffle.prizes.length);
// Verificar tickets de um usuario
const userTickets = await raffleRepository.getUserTickets(raffleId, userId);
console.log('User tickets:', userTickets.map(t => t.ticketNumber));
// Verificar saldo de tickets do usuario
const user = await userRepository.findById(userId);
console.log('User tickets balance:', user.tickets);Logs do WebSocket
[Raffle] Ticket purchased: 5 tickets for raffle 123 by PlayerOne (250/500)
[Raffle] Drawing started for raffle 123
[Raffle] Results for raffle 123: 3 prizes drawn
[Raffle] Completed raffle 123Auto-draw nao executou
- Verificar se
totalTicketsSold >= maxTickets - Verificar logs de erro:
[Raffle] Auto-draw failed for raffle {id} - Se falhou, executar draw manual via
POST /raffle/:id/draw
Ticket nao creditado apos deposito
- Verificar
deposit.amountCents: deve ser >= 3000 (R$30) para gerar 1 ticket - Formula:
Math.floor(amountCents / 3000) = ticketsToAdd - Verificar se o
userRepository.updatefoi executado com sucesso
Arquivos Relacionados
| Arquivo | Descricao |
|---|---|
prisma/schema.prisma | Models Raffle, RafflePrize, RaffleTicket, enum RaffleStatus |
src/domain/repositories/raffle.repository.interface.ts | Interface do repositorio |
src/infrastructure/database/repositories/raffle.repository.ts | Implementacao Prisma do repositorio |
src/application/use-cases/raffle/buy-raffle-tickets.use-case.ts | Compra de tickets |
src/application/use-cases/raffle/draw-raffle.use-case.ts | Execucao do sorteio |
src/application/use-cases/raffle/get-active-raffles.use-case.ts | Listagem de ativos |
src/application/use-cases/raffle/get-completed-raffles.use-case.ts | Listagem de finalizados |
src/application/use-cases/raffle/get-raffle-details.use-case.ts | Detalhes com tickets do usuario |
src/presentation/controllers/raffle.controller.ts | Controller HTTP com formatacao |
src/presentation/modules/raffle.module.ts | Modulo NestJS |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket |
src/infrastructure/notification/notification.service.ts | Notificacao ao vencedor |
src/infrastructure/locks/lock.service.ts | Locks distribuidos (Redlock) |
src/application/services/provably-fair.service.ts | Calculo de roll |
src/application/services/transaction.service.ts | logOnly (RAFFLE_TICKET_PURCHASE, RAFFLE_PRIZE_WON) |
src/infrastructure/audit/audit.service.ts | Auditoria (RAFFLE_TICKETS_PURCHASED, RAFFLE_DRAWN) |
src/application/services/money.service.ts | Formatacao de valores BRL |
src/application/services/deposit.service.ts | Conversao deposito -> tickets |
src/infrastructure/database/repositories/user.repository.ts | incrementTickets/decrementTickets |
client/app/lib/use-raffle-socket.tsx | Hook frontend para eventos WebSocket |
