Skip to content

Como Construir um Sistema de Flash Sale com NENE2

Este guia percorre a construção de um sistema de flash sale com tempo limitado e quantidade restrita, onde usuários podem comprar um produto com desconto durante uma janela de venda.

Field Trial: FT140
Versão do NENE2: ^1.5
Tópicos abordados: validação de janela de tempo, contagem de estoque com COUNT(*), constraint UNIQUE para uma compra por usuário, expressão match para status, teste de ataque com mentalidade de cracker


O que estamos construindo

  • POST /products — criar um produto
  • POST /sales — criar uma flash sale (product_id, price, quantity, starts_at, ends_at)
  • GET /sales/{saleId} — visualizar detalhes da venda com contagem restante e status
  • POST /sales/{saleId}/purchase — comprar durante janela ativa (uma por usuário)
  • GET /sales/{saleId}/purchases — listar todos os compradores

Schema do banco de dados

sql
CREATE TABLE flash_sales (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id INTEGER NOT NULL,
    price      INTEGER NOT NULL,
    quantity   INTEGER NOT NULL,
    starts_at  TEXT    NOT NULL,
    ends_at    TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    CHECK (quantity > 0),
    CHECK (price >= 0),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

CREATE TABLE purchases (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    sale_id      INTEGER NOT NULL,
    user_id      INTEGER NOT NULL,
    purchased_at TEXT    NOT NULL,
    UNIQUE (sale_id, user_id),
    FOREIGN KEY (sale_id) REFERENCES flash_sales(id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

UNIQUE (sale_id, user_id) previne que um usuário compre a mesma venda duas vezes, mesmo sob requisições concorrentes.


Validação de janela de tempo

php
$now = date('c');

if ($now < $sale['starts_at']) {
    return $this->responseFactory->create(['error' => 'sale has not started yet'], 422);
}

if ($now > $sale['ends_at']) {
    return $this->responseFactory->create(['error' => 'sale has ended'], 422);
}

Armazene starts_at / ends_at como strings ISO 8601. A comparação de strings funciona corretamente para ISO 8601 porque o formato é ordenado lexicograficamente.


Contagem de estoque com COUNT(*)

Em vez de manter uma coluna remaining mutável, contar as compras reais:

php
public function countPurchases(int $saleId): int
{
    $row = $this->executor->fetchOne(
        'SELECT COUNT(*) as cnt FROM purchases WHERE sale_id = ?',
        [$saleId],
    );

    return isset($row['cnt']) ? (int) $row['cnt'] : 0;
}

Então verificar:

php
$purchased = $this->repository->countPurchases($saleId);

if ($purchased >= $sale['quantity']) {
    return $this->responseFactory->create(['error' => 'sold out'], 422);
}

remaining é derivado no momento da leitura: $sale['quantity'] - $purchased. Limitar a max(0, $remaining) para evitar exibição negativa.


Uma compra por usuário — constraint UNIQUE

UNIQUE (sale_id, user_id) previne duplicatas no nível do BD. DatabaseConstraintException mapeia para 409:

php
public function purchase(int $saleId, int $userId, string $now): bool
{
    try {
        $this->executor->execute(
            'INSERT INTO purchases (sale_id, user_id, purchased_at) VALUES (?, ?, ?)',
            [$saleId, $userId, $now],
        );

        return true;
    } catch (DatabaseConstraintException) {
        return false;
    }
}

O handler retorna 409 quando purchase() retorna false.


Status da venda com expressão match

php
$status = match (true) {
    $now < $sale['starts_at'] => 'upcoming',
    $now > $sale['ends_at']   => 'ended',
    default                   => 'active',
};

Três estados: upcoming, active, ended. A expressão match é exaustiva porque default cobre todos os outros casos.


Resultados do teste de ataque de cracker (FT140)

IDAtaqueEsperadoResultado
ATK-01SQL injection no nome do produto201 (armazenado verbatim)Pass
ATK-02Compra sem X-User-Id400Pass
ATK-03X-User-Id não numériconão 201Pass
ATK-04saleId negativo na URLnão 201Pass
ATK-05Comprar antes do início da venda422Pass
ATK-06Comprar após encerramento da venda422Pass
ATK-07Compra dupla da mesma venda409 na segundaPass
ATK-08Esgotar estoque e tentar comprar422 esgotadoPass
ATK-09Criar venda com quantity=0422Pass
ATK-10Criar venda com preço negativo422Pass
ATK-11Comprar como usuário inexistente404Pass
ATK-12ends_at antes de starts_at422Pass

Todos os 12 testes de ataque passam.


Armadilhas comuns

ArmadilhaCorreção
Coluna remaining mutável deriva sob concorrênciaContar da tabela purchases, derivar remaining no momento da leitura
Permitir quantity=0 via APIValidar $quantity > 0 no handler; também CHECK (quantity > 0) no schema
Preço negativo passa pela verificaçãoValidar $price >= 0; também CHECK (price >= 0) no schema
Usuário comprando a mesma venda duas vezesUNIQUE (sale_id, user_id) + DatabaseConstraintException → 409
Comparação de tempo em strings não-ISOUsar ISO 8601 (ex.: date('c')) — ordenação lexicográfica está correta
ends_at invertido com starts_atValidar $starts_at < $ends_at antes do INSERT

Publicado sob a licença MIT.