Live Drops - Documentacao Completa
O sistema de Live Drops exibe em tempo real todos os itens dropados no site, criando engajamento e FOMO (Fear of Missing Out). Qualquer visitante -- autenticado ou nao -- pode ver os drops acontecendo.
Sumario
- Visao Geral da Arquitetura
- LiveDropsService
- Delays de Emissao por Fonte
- Evento live:drop - Payload Completo
- LiveDropsController - Endpoints HTTP
- GetRecentDropsUseCase
- Fontes de Dados (UNION Query)
- Filtragem de Bots
- Top Drops e isTopDrop
- LiveDropDto
- Metodo getRecentOpenings e N+1 Query
- Modulo LiveDrops
- Fluxo Completo por Fonte
- Debugging e Troubleshooting
Visao Geral da Arquitetura
O sistema de Live Drops opera em duas camadas complementares:
1. Camada WebSocket (Tempo Real)
Cada use case que gera um drop (abertura de caixa, upgrade, swap, batalha, evento) chama diretamente o metodo emitLiveDrop() do WebSocketGatewayService com um setTimeout proporcional a duracao da animacao no frontend.
Use Case -> setTimeout(delay) -> WebSocketGateway.emitLiveDrop() -> Broadcast global2. Camada HTTP (Historico)
Dois endpoints publicos permitem carregar drops recentes e top drops diretamente do banco de dados, usando uma UNION query que agrega dados de 5 tabelas diferentes.
GET /live-drops/recent -> GetRecentDropsUseCase -> CaseOpeningRepository.findAllRecentDrops()
GET /live-drops/top -> GetRecentDropsUseCase -> CaseOpeningRepository.findAllTopDrops()Diferenca entre as camadas
| Aspecto | WebSocket (Tempo Real) | HTTP (Historico) |
|---|---|---|
| Trigger | Use case emite apos acao | Cliente faz GET request |
| Dados | Objetos construidos no use case | UNION query em 5 tabelas |
| Delay | Sim (baseado na animacao) | Nao |
| Persistencia | Nao (evento efemero) | Sim (banco de dados) |
| Uso | Feed em tempo real no frontend | Carregamento inicial da pagina |
LiveDropsService
Arquivo: src/application/services/live-drops.service.ts
Servico intermediario que encapsula a emissao dos eventos case:opened e upgrade:completed via WebSocket. Nao e responsavel pelo live:drop -- esse e emitido diretamente pelos use cases.
Dependencias
@Injectable()
export class LiveDropsService {
constructor(
private readonly websocketGateway: WebSocketGatewayService,
private readonly moneyService: MoneyService,
) {}
}broadcastCaseOpening(data)
Emite o evento case:opened globalmente. Formata o valor do item usando MoneyService.format() (formato "R$ X.XXX,XX").
async broadcastCaseOpening(data: {
id: bigint;
userId: bigint;
username: string;
userAvatar: string;
caseName: string;
caseImage: string;
itemName: string;
itemImage: string;
itemRarity: string;
itemValueCents: bigint;
isFlip: boolean;
}) {
this.websocketGateway.emitCaseOpening({
id: data.id,
userId: data.userId,
username: data.username,
userAvatar: data.userAvatar,
caseName: data.caseName,
caseImage: data.caseImage,
itemName: data.itemName,
itemImage: data.itemImage,
itemRarity: data.itemRarity,
itemValue: this.moneyService.format(data.itemValueCents),
isFlip: data.isFlip,
timestamp: Date.now(),
});
}Observacao: O campo itemValue e formatado como "R$ 1.500,00" via MoneyService.format(). Este e um dos poucos pontos do sistema onde valores sao enviados formatados via WebSocket (diferente do balance:updated que envia centavos puros).
broadcastUpgrade(data)
Emite o evento upgrade:completed globalmente.
async broadcastUpgrade(data: {
id: bigint;
userId: bigint;
username: string;
success: boolean;
targetItemName: string;
targetItemImage: string;
targetItemRarity: string;
valueInvestedCents: bigint;
}) {
this.websocketGateway.emitUpgrade({
id: data.id,
userId: data.userId,
username: data.username,
success: data.success,
targetItem: {
name: data.targetItemName,
image: data.targetItemImage,
rarity: data.targetItemRarity,
},
valueInvested: this.moneyService.format(data.valueInvestedCents),
timestamp: Date.now(),
});
}getConnectedUsersCount(): number
Proxy para WebSocketGatewayService.getConnectedUsersCount().
getConnectedUsersCount(): number {
return this.websocketGateway.getConnectedUsersCount();
}Delays de Emissao por Fonte
O live:drop e emitido com delay via setTimeout em cada use case. O objetivo e garantir que o drop so aparece no feed global APOS a animacao do frontend terminar, evitando spoiler do resultado.
Tabela de Delays
| Fonte | Delay | Razao | Arquivo |
|---|---|---|---|
| Abertura de caixa (normal) | 12.000ms (12s) | Animacao da roleta (~10s) + revelacao (~2s) | open-case.use-case.ts |
| Abertura de caixa (FLIP) | 30.000ms (30s) | Primeira roleta (~10s) + animacao KNIFE (~8s) + segunda roleta (~10s) + revelacao (~2s) | open-case.use-case.ts |
| Upgrade (sucesso) | 12.000ms (12s) | Animacao do upgrade no frontend | perform-upgrade.use-case.ts |
| Batalha (normal) | 12.000ms (12s) | Animacao da roleta da rodada | execute-battle.use-case.ts |
| Batalha (FLIP) | 30.000ms (30s) | Mesma logica do FLIP de caixa | execute-battle.use-case.ts |
| Site Swap | 3.000ms (3s) | Animacao curta de troca | perform-site-swap.use-case.ts |
| Evento (Atire na Caixa) | 3.000ms (3s) | Animacao curta do tiro | perform-event-shoot.use-case.ts |
Logica no Codigo
Abertura de Caixa (delay dinamico baseado em FLIP):
const liveDropDelay = result.isFlip ? 30000 : 12000;
const user = await this.userRepository.findById(userId);
if (user) {
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: result.item.name,
itemImage: result.item.imageUrl,
itemValueCents: result.item.valueCentsBrl,
rarity: result.item.rarity,
source: 'case',
timestamp: new Date(),
dropChance: result.dropChance,
caseImage: caseData.imageUrl || undefined,
});
}, liveDropDelay);
}Batalha (delay dinamico + filtragem de bots):
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
const liveDropDelay = isFlip ? 30000 : 12000;
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: player.user.username,
userAvatar: player.user.avatarUrl || undefined,
itemName: wonItem.item.name,
itemImage: wonItem.item.imageUrl,
itemValueCents: wonItem.item.valueCentsBrl,
rarity: wonItem.item.rarity,
source: 'battle',
timestamp: new Date(),
dropChance: wonItem.dropChance,
});
}, liveDropDelay);
}Upgrade (delay fixo, apenas sucesso):
if (success) {
const user = await this.userRepository.findById(userId);
if (user) {
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: targetItem.name,
itemImage: targetItem.imageUrl,
itemValueCents: targetItem.valueCentsBrl,
rarity: targetItem.rarity,
source: 'upgrade',
timestamp: new Date(),
dropChance: chancePercent,
});
}, 12000);
}
}Site Swap (delay curto):
setTimeout(() => {
if (user) {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: targetItem!.name,
itemImage: targetItem!.imageUrl,
itemValueCents: BigInt(priceCentsBrl),
rarity: targetItem!.rarity,
source: 'swap',
timestamp: new Date(),
dropChance: 100,
});
}
}, 3000);Evento Shoot (delay curto):
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: wonItem.item.name,
itemImage: wonItem.item.imageUrl,
itemValueCents: wonItem.item.valueCentsBrl,
rarity: wonItem.item.rarity,
source: 'event',
timestamp: new Date(),
dropChance: wonItem.dropChance,
});
}, 3000);Condicoes de Emissao
| Fonte | Condicao para emitir | dropChance |
|---|---|---|
| Caixa | Sempre (se user existe) | result.dropChance (da CaseItem) |
| Upgrade | Apenas quando success = true | chancePercent do upgrade |
| Batalha | Apenas jogadores reais (!isBot) | wonItem.dropChance |
| Swap | Sempre (se user existe) | Fixo: 100 |
| Evento | Sempre | wonItem.dropChance |
Evento live:drop - Payload Completo
O evento live:drop e emitido pelo metodo emitLiveDrop() do WebSocketGatewayService e recebe os seguintes campos:
Entrada (parametro do metodo)
emitLiveDrop(drop: {
userName: string;
userAvatar?: string;
itemName: string;
itemImage: string;
itemValueCents: bigint | string;
rarity: string;
source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';
timestamp: Date;
dropChance: number;
caseImage?: string;
})Saida (payload emitido ao cliente)
{
userName: string; // Nome do usuario
userAvatar?: string; // URL do avatar Steam
itemName: string; // Nome do item (ex: "AWP | Dragon Lore")
itemImage: string; // URL da imagem do item
itemValueCents: string; // Valor em centavos BRL (convertido para string)
rarity: string; // Raridade do item (ex: "COVERT", "EXTRAORDINARY")
source: string; // Fonte: 'case', 'upgrade', 'swap', 'battle', 'event'
timestamp: Date; // Data/hora do drop
dropChance: number; // Chance de drop em porcentagem (ex: 0.05 = 0.05%)
isTopDrop: boolean; // Calculado: valueCents >= 100000 (R$1.000,00+)
caseImage?: string; // URL da imagem da caixa (apenas source='case')
}Calculo do isTopDrop
const valueCents =
typeof drop.itemValueCents === 'bigint' ? drop.itemValueCents : BigInt(drop.itemValueCents);
const isTopDrop = valueCents >= BigInt(100000);O campo isTopDrop e true quando o valor do item e maior ou igual a R$1.000,00 (100.000 centavos BRL). Este campo e calculado no gateway e nao precisa ser informado pelo caller.
LiveDropsController - Endpoints HTTP
Arquivo: src/presentation/controllers/live-drops.controller.ts
Controller publico que fornece endpoints para carregar drops do banco de dados.
@ApiTags('Live Drops')
@Controller('live-drops')
export class LiveDropsController {
constructor(private readonly getRecentDropsUseCase: GetRecentDropsUseCase) {}
}GET /live-drops/recent
Retorna os drops mais recentes de todas as fontes.
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
limit | Query (opcional) | 10 | Numero de drops a retornar |
Decorators:
@Public()-- Nao requer autenticacao@ApiOperation({ summary: 'Busca os drops mais recentes globalmente (publico)' })
@Public()
@Get('recent')
async getRecentDrops(@Query('limit') limit?: string): Promise<LiveDropDto[]> {
const limitNumber = limit ? parseInt(limit, 10) : 10;
return this.getRecentDropsUseCase.execute(limitNumber, false);
}Exemplo de request:
GET /live-drops/recent?limit=20Exemplo de response:
[
{
"userName": "hala",
"userAvatar": "https://avatars.steamstatic.com/...",
"itemName": "AWP | Dragon Lore",
"itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
"itemValueCents": "150000",
"rarity": "EXTRAORDINARY",
"source": "case",
"caseImage": "https://storage.procases.example/cases/emerald.webp",
"timestamp": "2025-12-02T18:30:00.000Z",
"dropChance": 0.05
},
{
"userName": "player2",
"itemName": "Glock-18 | Fade",
"itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
"itemValueCents": "85000",
"rarity": "COVERT",
"source": "upgrade",
"timestamp": "2025-12-02T18:29:45.000Z",
"dropChance": 15.5
}
]GET /live-drops/top
Retorna os top drops mais recentes. Filtra itens com value_cents_brl >= 100000 (R$1.000,00+).
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
limit | Query (opcional) | 10 | Numero de drops a retornar |
Decorators:
@Public()-- Nao requer autenticacao@ApiOperation({ summary: 'Busca os top drops mais recentes (chance <= 2%) (publico)' })
@Public()
@Get('top')
async getTopDrops(@Query('limit') limit?: string): Promise<LiveDropDto[]> {
const limitNumber = limit ? parseInt(limit, 10) : 10;
return this.getRecentDropsUseCase.execute(limitNumber, true);
}Observacao: Apesar da descricao da API mencionar "chance <= 2%", a implementacao real filtra por value_cents_brl >= minValueCents (default: BigInt(100000) = R$1.000,00). O filtro e por VALOR, nao por chance.
GetRecentDropsUseCase
Arquivo: src/application/use-cases/live-drops/get-recent-drops.use-case.ts
Use case que busca drops recentes ou top drops do banco de dados.
@Injectable()
export class GetRecentDropsUseCase {
constructor(private readonly caseOpeningRepository: CaseOpeningRepository) {}
async execute(limit: number = 10, topOnly: boolean = false): Promise<LiveDropDto[]> {
const recentDrops = topOnly
? await this.caseOpeningRepository.findAllTopDrops(limit, BigInt(100000))
: await this.caseOpeningRepository.findAllRecentDrops(limit);
return recentDrops.map((drop) => ({
userName: drop.user.username,
userAvatar: drop.user.avatarUrl ?? undefined,
itemName: drop.item.name,
itemImage: drop.item.imageUrl,
itemValueCents: drop.itemValueCents.toString(),
rarity: drop.item.rarity,
source: drop.source,
timestamp: drop.createdAt,
dropChance: drop.dropChance,
caseImage: drop.caseImageUrl ?? undefined,
}));
}
}Fluxo de decisao
Parametro topOnly | Metodo chamado | Filtro |
|---|---|---|
false | findAllRecentDrops(limit) | Nenhum (todos os drops) |
true | findAllTopDrops(limit, BigInt(100000)) | value_cents_brl >= 100000 |
Mapeamento de campos
| Campo do banco (raw query) | Campo do DTO |
|---|---|
username | userName |
avatarUrl | userAvatar |
itemName | itemName |
itemImageUrl | itemImage |
itemValueCents (bigint) | itemValueCents (string) |
itemRarity | rarity |
source | source |
createdAt | timestamp |
dropChance | dropChance |
caseImageUrl | caseImage |
Fontes de Dados (UNION Query)
Arquivo: src/infrastructure/database/repositories/case-opening.repository.ts
Os metodos findAllRecentDrops() e findAllTopDrops() usam uma raw SQL query com $queryRaw do Prisma que faz UNION ALL entre 5 tabelas.
Estrutura da Query
(SELECT 'case' as source, ... FROM case_openings co JOIN users u ... JOIN items i ... JOIN cases c ... LEFT JOIN case_items ci ...)
UNION ALL
(SELECT 'upgrade' as source, ... FROM upgrades up JOIN users u ... JOIN items i ... WHERE up.success = true)
UNION ALL
(SELECT 'battle' as source, ... FROM battle_rounds br JOIN battle_players bp ... JOIN users u ... JOIN items i ... WHERE u.steam_id NOT LIKE 'BOT_%')
UNION ALL
(SELECT 'swap' as source, ... FROM site_swaps ss JOIN users u ... JOIN items i ...)
UNION ALL
(SELECT 'event' as source, ... FROM event_shoot_openings eso JOIN users u ... JOIN items i ... LEFT JOIN event_shoot_items esi ...)
ORDER BY "createdAt" DESC
LIMIT ${limit}Detalhamento por Fonte
1. case_openings (source = 'case')
SELECT
'case' as source,
co.created_at as "createdAt",
co.user_id as "userId",
u.username,
u.avatar_url as "avatarUrl",
i.name as "itemName",
i.image_url as "itemImageUrl",
i.rarity::text as "itemRarity",
i.value_cents_brl as "itemValueCents",
COALESCE(ci.drop_chance, 0)::float as "dropChance",
c.image_url as "caseImageUrl"
FROM case_openings co
JOIN users u ON u.id = co.user_id
JOIN items i ON i.id = co.item_id
JOIN cases c ON c.id = co.case_id
LEFT JOIN case_items ci ON ci.case_id = co.case_id AND ci.item_id = co.item_idObservacoes:
dropChancevem do JOIN comcase_items(LEFT JOIN, pode ser null -> COALESCE para 0)caseImageUrlvem da tabelacases- Usa
value_cents_brl(BRL), naovalue_cents(USD)
2. upgrades (source = 'upgrade')
SELECT
'upgrade' as source,
up.created_at as "createdAt",
up.user_id as "userId",
u.username,
u.avatar_url as "avatarUrl",
i.name as "itemName",
i.image_url as "itemImageUrl",
i.rarity::text as "itemRarity",
i.value_cents_brl as "itemValueCents",
up.chance_percent::float as "dropChance",
NULL as "caseImageUrl"
FROM upgrades up
JOIN users u ON u.id = up.user_id
JOIN items i ON i.id = up.target_item_id
WHERE up.success = trueObservacoes:
WHERE up.success = true: Apenas upgrades bem-sucedidos aparecem nos live dropsdropChancevem dechance_percentdo upgrade (nao de uma tabela de itens)caseImageUrle sempre NULL (upgrades nao tem caixa associada)
3. battle_rounds (source = 'battle')
SELECT
'battle' as source,
br.created_at as "createdAt",
bp.user_id as "userId",
u.username,
u.avatar_url as "avatarUrl",
br.item_name as "itemName",
br.item_image as "itemImageUrl",
br.item_rarity::text as "itemRarity",
i.value_cents_brl as "itemValueCents",
0::float as "dropChance",
NULL as "caseImageUrl"
FROM battle_rounds br
JOIN battle_players bp ON bp.id = br.player_id
JOIN users u ON u.id = bp.user_id
JOIN items i ON i.id = br.item_id
WHERE u.steam_id NOT LIKE 'BOT_%'Observacoes:
WHERE u.steam_id NOT LIKE 'BOT_%': Filtra bots (veja secao "Filtragem de Bots")dropChancee fixo em0(batalhas nao tem conceito de chance individual por item)item_name,item_image,item_rarityvem debattle_rounds(colunas desnormalizadas)itemValueCentsvem deitems.value_cents_brlvia JOIN
4. site_swaps (source = 'swap')
SELECT
'swap' as source,
ss.created_at as "createdAt",
ss.user_id as "userId",
u.username,
u.avatar_url as "avatarUrl",
i.name as "itemName",
i.image_url as "itemImageUrl",
i.rarity::text as "itemRarity",
i.value_cents_brl as "itemValueCents",
0::float as "dropChance",
NULL as "caseImageUrl"
FROM site_swaps ss
JOIN users u ON u.id = ss.user_id
JOIN items i ON i.id = ss.target_item_idObservacoes:
dropChancee fixo em0(swaps sao deterministas, nao tem chance)target_item_ide o item que o usuario RECEBEU no swap
5. event_shoot_openings (source = 'event')
SELECT
'event' as source,
eso.created_at as "createdAt",
eso.user_id as "userId",
u.username,
u.avatar_url as "avatarUrl",
i.name as "itemName",
i.image_url as "itemImageUrl",
i.rarity::text as "itemRarity",
i.value_cents_brl as "itemValueCents",
COALESCE(esi.drop_chance, 0)::float as "dropChance",
NULL as "caseImageUrl"
FROM event_shoot_openings eso
JOIN users u ON u.id = eso.user_id
JOIN items i ON i.id = eso.item_id
LEFT JOIN event_shoot_items esi ON esi.id = eso.event_shoot_item_idObservacoes:
dropChancevem deevent_shoot_itemsvia LEFT JOIN- Evento "Atire na Caixa" tem seus proprios itens com probabilidades independentes
Resultado Mapeado
Ambos os metodos (findAllRecentDrops e findAllTopDrops) mapeiam o resultado raw para a seguinte estrutura:
return drops.map((drop) => ({
source: drop.source as 'case' | 'upgrade' | 'battle' | 'swap' | 'event',
createdAt: drop.createdAt,
user: {
id: drop.userId,
username: drop.username,
avatarUrl: drop.avatarUrl,
},
item: {
name: drop.itemName,
imageUrl: drop.itemImageUrl,
rarity: drop.itemRarity,
},
itemValueCents: drop.itemValueCents,
dropChance: drop.dropChance,
caseImageUrl: drop.caseImageUrl,
}));Diferenca entre findAllRecentDrops e findAllTopDrops
| Metodo | Filtro WHERE adicional |
|---|---|
findAllRecentDrops(limit) | Nenhum |
findAllTopDrops(limit, minValueCents) | WHERE i.value_cents_brl >= ${minValueCents} em cada sub-query |
O findAllTopDrops adiciona o filtro WHERE i.value_cents_brl >= ${minValueCents} em CADA uma das 5 sub-queries do UNION, garantindo que apenas itens com valor acima do threshold aparecem.
Filtragem de Bots
Bots (jogadores artificiais em batalhas) NUNCA aparecem nos live drops. A filtragem acontece em dois pontos:
1. Emissao WebSocket (execute-battle.use-case.ts)
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
const liveDropDelay = isFlip ? 30000 : 12000;
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({ ... });
}, liveDropDelay);
}O emitLiveDrop so e chamado se o jogador NAO e um bot (steamId nao comeca com BOT_).
2. Query SQL (case-opening.repository.ts)
FROM battle_rounds br
JOIN battle_players bp ON bp.id = br.player_id
JOIN users u ON u.id = bp.user_id
JOIN items i ON i.id = br.item_id
WHERE u.steam_id NOT LIKE 'BOT_%'A clausula WHERE u.steam_id NOT LIKE 'BOT_%' garante que drops de bots nunca aparecem nos endpoints HTTP de historico.
Convencao de nomeacao de bots
Todos os bots tem steam_id com prefixo BOT_. Exemplos: BOT_001, BOT_ALPHA, BOT_EASY.
Top Drops e isTopDrop
isTopDrop (WebSocket)
O campo isTopDrop e calculado no emitLiveDrop() do gateway:
const isTopDrop = valueCents >= BigInt(100000); // >= R$1.000,00 (100.000 centavos BRL)Este campo e adicionado ao payload automaticamente e pode ser usado pelo frontend para destacar drops de alto valor com animacoes especiais, efeitos visuais ou notificacoes.
Top Drops Endpoint (HTTP)
O endpoint GET /live-drops/top chama findAllTopDrops() com minValueCents = BigInt(100000):
await this.caseOpeningRepository.findAllTopDrops(limit, BigInt(100000));Isso filtra apenas itens com value_cents_brl >= 100000 em todas as 5 fontes de dados.
Threshold
| Constante | Valor | Significado |
|---|---|---|
BigInt(100000) | 100.000 centavos | R$ 1.000,00 BRL |
LiveDropDto
Arquivo: src/application/dto/live-drop.dto.ts
DTO com decorators Swagger para documentacao automatica da API.
export class LiveDropDto {
@ApiProperty({ example: 'hala', description: 'Nome do usuario' })
userName: string;
@ApiProperty({
example: 'https://avatars.steamstatic.com/...',
description: 'Avatar do usuario',
required: false,
})
userAvatar?: string;
@ApiProperty({ example: 'AWP | Dragon Lore', description: 'Nome do item' })
itemName: string;
@ApiProperty({
example: 'https://community.cloudflare.steamstatic.com/economy/image/...',
description: 'Imagem do item',
})
itemImage: string;
@ApiProperty({ example: '150000', description: 'Valor do item em centavos' })
itemValueCents: string;
@ApiProperty({ example: 'legendary', description: 'Raridade do item' })
rarity: string;
@ApiProperty({
example: 'case',
description: 'Origem do drop',
enum: ['case', 'upgrade', 'swap', 'battle', 'event'],
})
source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';
@ApiProperty({
example: 'https://storage.procases.example/cases/case1.webp',
description: 'Imagem da caixa (apenas quando source=case)',
required: false,
})
caseImage?: string;
@ApiProperty({ example: '2025-12-02T18:30:00Z', description: 'Timestamp do drop' })
timestamp: Date;
@ApiProperty({ example: 0.5, description: 'Chance de drop em porcentagem' })
dropChance: number;
}Campos opcionais
| Campo | Quando e null/undefined |
|---|---|
userAvatar | Quando o usuario nao tem avatar configurado |
caseImage | Quando source nao e 'case' (upgrades, swaps, batalhas, eventos) |
Tipos dos campos
| Campo | Tipo TypeScript | Tipo real no payload |
|---|---|---|
itemValueCents | string | String de inteiro (centavos BRL). Ex: "150000" |
dropChance | number | Float. Ex: 0.05 para 0.05%, 100 para swap |
timestamp | Date | ISO 8601 string quando serializado |
Metodo getRecentOpenings e N+1 Query
Arquivo: src/infrastructure/database/repositories/case-opening.repository.ts
Alem dos metodos findAllRecentDrops e findAllTopDrops (que usam raw UNION queries), existe o metodo getRecentOpenings que sofre de um problema de N+1 queries.
O problema
async getRecentOpenings(limit: number) {
const openings = await this.prisma.caseOpening.findMany({
include: { user: { ... }, case: { ... }, item: true },
orderBy: { createdAt: 'desc' },
take: limit,
});
const openingsWithDropChance = await Promise.all(
openings.map(async (opening) => {
const caseItem = await this.prisma.caseItem.findUnique({
where: {
caseId_itemId: {
caseId: opening.caseId,
itemId: opening.itemId,
},
},
select: { dropChance: true },
});
return { ...opening, dropChance: caseItem?.dropChance ?? 0 };
}),
);
return openingsWithDropChance;
}Por que e N+1
Para cada abertura de caixa retornada (N resultados), uma query adicional e executada para buscar o dropChance na tabela case_items. Se limit = 20, sao executadas 1 + 20 = 21 queries.
Comparacao com findAllRecentDrops
| Metodo | Queries | Fontes | Performance |
|---|---|---|---|
getRecentOpenings(limit) | 1 + N (N+1 problem) | Apenas case_openings | Lenta para limits altos |
findAllRecentDrops(limit) | 1 (raw UNION) | 5 tabelas | Rapida e otimizada |
O findAllRecentDrops resolve o N+1 usando LEFT JOIN diretamente na raw query SQL, buscando o dropChance dentro da mesma query.
O mesmo problema existe em findRecentTopDrops, que tambem usa Promise.all com queries individuais.
Modulo LiveDrops
Arquivo: src/presentation/modules/live-drops.module.ts
@Module({
imports: [DatabaseModule],
controllers: [LiveDropsController],
providers: [GetRecentDropsUseCase, CaseOpeningRepository],
exports: [GetRecentDropsUseCase],
})
export class LiveDropsModule {}Dependencias
| Provider | Descricao |
|---|---|
GetRecentDropsUseCase | Use case para buscar drops recentes |
CaseOpeningRepository | Repositorio com as UNION queries |
Imports
| Modulo | Descricao |
|---|---|
DatabaseModule | Fornece o PrismaClient |
Fluxo Completo por Fonte
Abertura de Caixa
1. Usuario clica "Abrir Caixa"
2. Frontend: POST /cases/:id/open
3. Backend: OpenCaseUseCase executa
4. Backend: Calcula resultado (Provably Fair)
5. Backend: Emite balance:updated imediatamente
6. Backend: Retorna resultado ao frontend
7. Frontend: Inicia animacao da roleta (~10s normal / ~28s FLIP)
8. Backend: setTimeout(12000 ou 30000) -> emitLiveDrop(source='case')
9. Todos os clientes WebSocket recebem live:drop
10. Feed de live drops atualiza em todas as telasUpgrade
1. Usuario seleciona itens e item alvo
2. Frontend: POST /upgrades
3. Backend: PerformUpgradeUseCase executa
4. Backend: Calcula sucesso/falha (Provably Fair)
5. Backend: Emite balance:updated se houve debito de saldo
6. Backend: Se sucesso -> setTimeout(12000) -> emitLiveDrop(source='upgrade')
7. Se falha -> nao emite live:drop
8. Todos os clientes WebSocket recebem live:drop (apenas sucessos)Batalha
1. Batalha em andamento
2. Backend: ExecuteBattleUseCase processa cada rodada
3. Para cada jogador na rodada:
a. Calcula resultado (Provably Fair)
b. Se jogador NAO e bot:
setTimeout(12000 ou 30000) -> emitLiveDrop(source='battle')
c. Se jogador e bot: nao emite
4. Emite battle:round_result para sala battle:{id}
5. Live drops aparecem gradualmente no feed globalSite Swap
1. Usuario seleciona item do inventario + item alvo
2. Frontend: POST /swap
3. Backend: PerformSiteSwapUseCase executa
4. Backend: Realiza troca
5. Backend: setTimeout(3000) -> emitLiveDrop(source='swap', dropChance=100)
6. Todos os clientes WebSocket recebem live:dropEvento (Atire na Caixa)
1. Usuario usa tickets para atirar
2. Frontend: POST /events/shoot
3. Backend: PerformEventShootUseCase executa
4. Backend: Calcula resultado
5. Backend: Emite eventTickets:updated imediatamente
6. Backend: setTimeout(3000) -> emitLiveDrop(source='event')
7. Todos os clientes WebSocket recebem live:dropDebugging e Troubleshooting
Drop Nao Aparece no Feed em Tempo Real
Verificar se o use case emitiu o live drop:
- O gateway loga:
Emitting live drop: {userName} got {itemName} ({dropChance}%) - isTopDrop: {boolean} - Se este log nao aparece, o
setTimeoutainda nao disparou OU o use case nao chamouemitLiveDrop
- O gateway loga:
Verificar se o delay esta correto:
- Normal: 12.000ms
- FLIP: 30.000ms
- Swap/Event: 3.000ms
- Se o drop aparece muito cedo, o delay pode estar errado
Verificar se o cliente WebSocket esta conectado:
- Log de conexao deve aparecer no backend
live:drope broadcast global, entao QUALQUER cliente conectado deve receber
Verificar se e um bot (batalha):
- Bots nunca emitem live drops
- Log de filtragem:
isBot = truequandosteamId.startsWith('BOT_')
Drop Nao Aparece no Endpoint HTTP
Verificar se o drop esta no banco de dados:
- A UNION query busca dados diretamente das tabelas transacionais
- Se o registro nao existe na tabela de origem, nao aparecera
Verificar o filtro de bots (batalhas):
- A query tem
WHERE u.steam_id NOT LIKE 'BOT_%' - Drops de bots nunca aparecem no endpoint
- A query tem
Verificar o filtro de sucesso (upgrades):
- A query tem
WHERE up.success = true - Upgrades que falharam nunca aparecem
- A query tem
Verificar o valor (endpoint /top):
- O endpoint
/topfiltravalue_cents_brl >= 100000 - Itens abaixo de R$1.000,00 nao aparecem neste endpoint
- O endpoint
Valores Exibidos Incorretamente
Verificar qual campo de valor esta sendo usado:
- A UNION query usa
i.value_cents_brl(BRL) - Se estiver usando
i.value_cents(USD), os valores estarao ~5x menores
- A UNION query usa
Verificar a conversao no frontend:
itemValueCentse uma string de inteiro em centavos- Para exibir:
parseInt(itemValueCents, 10) / 100 - Para formatar:
(value / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2 })
Performance Lenta no Endpoint /recent
Verificar se esta usando
findAllRecentDrops(UNION) ougetRecentOpenings(N+1):- O controller usa
GetRecentDropsUseCaseque chamafindAllRecentDrops(UNION) -- correto - Se alguem trocar para
getRecentOpenings, havera N+1 queries
- O controller usa
Verificar o limit:
- Valores muito altos de limit podem deixar a UNION query lenta
- Default e 10, recomendado maximo de 100
Verificar indices:
- As tabelas devem ter indice em
created_at DESC - A tabela
itemsdeve ter indice emvalue_cents_brlpara o endpoint/top
- As tabelas devem ter indice em
Arquivos Relacionados
| Arquivo | Descricao |
|---|---|
src/infrastructure/websocket/websocket.gateway.ts | Metodo emitLiveDrop() que faz broadcast |
src/application/services/live-drops.service.ts | Servico intermediario para case:opened e upgrade:completed |
src/presentation/controllers/live-drops.controller.ts | Endpoints HTTP publicos |
src/application/use-cases/live-drops/get-recent-drops.use-case.ts | Use case de busca |
src/application/dto/live-drop.dto.ts | DTO com decorators Swagger |
src/infrastructure/database/repositories/case-opening.repository.ts | UNION queries SQL |
src/presentation/modules/live-drops.module.ts | Modulo NestJS |
src/application/use-cases/case-opening/open-case.use-case.ts | Caller: abertura de caixa |
src/application/use-cases/upgrade/perform-upgrade.use-case.ts | Caller: upgrade |
src/application/use-cases/battle/execute-battle.use-case.ts | Caller: batalha |
src/application/use-cases/swap/perform-site-swap.use-case.ts | Caller: swap |
src/application/use-cases/event-shoot/perform-event-shoot.use-case.ts | Caller: evento shoot |
