Modelagem de Banco de Dados de um Marketplace de Carros Usados: Tabelas, Dados de Exemplo, Transações e Maquetes do App, 100 Exemplos do Zero ao Profissional

Modelagem de Banco de Dados de um Marketplace de Carros Usados: 100 Exemplos do Zero ao Profissional

Um marketplace de carros usados conecta compradores, vendedores e revendas em torno de um bem caro, único e que precisa ser transferido legalmente. Todo carro tem um número de chassi (VIN), um histórico de quilometragem, um laudo de vistoria e um documento de registro que precisa mudar de dono corretamente. Este artigo modela o banco de dados de um marketplace assim, do esquema vazio até uma versão pensada para produção, em 100 exemplos. Você vai listar as regras de negócio, criar as tabelas de usuários e revendas, um catálogo de carros com marcas, modelos e versões, anúncios com fotos, vistorias e leituras de quilometragem, índices de busca, ofertas e contraofertas, pedidos com um fluxo de status claro, pagamentos registrados como livro-razão, as etapas da transferência de propriedade com documentos, financiamento e garantias, avaliações e denúncias de fraude, e as transações que mantêm tudo consistente sob uso concorrente. Cada parte inclui dados de exemplo, o que acontece dentro da transação quando ela roda, e uma maquete da tela do app que a utiliza. Nota de transparência: o MySQL não estava instalado no computador usado para preparar este artigo, então nenhum dos 100 exemplos foi executado aqui. O SQL segue a sintaxe do MySQL 8.4, mas execute cada parte você mesmo em um banco de testes antes de confiar nela.

O que você precisa

MySQL 8.4 (ou Docker para executá-lo em um contêiner), um cliente SQL como o MySQL Workbench ou o utilitário de linha de comando mysql, e entendimento básico de tabelas, chaves e junções. Use nomes de pessoas, e-mails e VINs fictícios, nunca dados reais de clientes, e nunca cole senhas, chaves de API ou dados bancários reais nos scripts.

Como usar um exemplo

Os exemplos em SQL rodam no MySQL 8.4 em um banco de testes, na ordem indicada. Os exemplos em HTML são maquetes de tela que abrem no navegador. Os exemplos em JSON mostram o formato dos dados da API, e os exemplos em bash usam os comandos mysql e curl.

Tópico 1: Regras de Negócio e Configuração

Comece pelas regras, não pelas tabelas. Quem pode anunciar um carro, quem pode comprá-lo, o que precisa ser verdade antes de o anúncio entrar no ar e o que nunca pode acontecer, como vender o mesmo carro duas vezes. O banco de dados impõe essas regras para que um erro na aplicação não consiga quebrá-las.

Atores e o que precisam

Compradores pesquisam, salvam favoritos e fazem ofertas. Vendedores anunciam carros e respondem às ofertas. Revendas anunciam muitos carros e têm um selo de verificação. Inspetores avaliam carros, e administradores analisam denúncias. Cada papel precisa de suas próprias permissões e telas.

Regras que o banco de dados precisa garantir

Um carro só pode ser vendido a um comprador por vez. Um anúncio só vai ao ar depois de uma vistoria aprovada. O sinal fica retido até a transferência de propriedade ser concluída. Toda mudança de preço e de status é registrada. Escreva essas regras primeiro e depois decida quais serão impostas por restrições CHECK, chaves estrangeiras e transações.

Conjunto de caracteres e banco de dados

Crie o banco com utf8mb4 para que textos em tailandês, emojis e nomes sejam guardados exatamente como digitados. Use o mesmo conjunto de caracteres em todas as tabelas, porque codificações misturadas causam textos corrompidos e erros de comparação.

Ilustrações

O que acontece quando um comprador adquire um carro usado: a oferta é aceita e o carro é reservado, o sinal é registrado no livro-razão, as etapas da transferência são concluídas com os documentos e só então o carro é marcado como vendido
O que acontece quando um comprador adquire um carro usado: a oferta é aceita e o carro é reservado, o sinal é registrado no livro-razão, as etapas da transferência são concluídas com os documentos e só então o carro é marcado como vendido

Exemplo 1: Escreva primeiro as regras do negócio

Quem faz o quê e quais regras o banco deve garantir.

{
  "buyers": "search, favorite, make an offer, pay a deposit, buy",
  "sellers": "list a car with photos and a price, accept or reject offers",
  "dealers": "list many cars, get a verified badge after a document check",
  "rules": [
    "a car can be sold to one buyer only",
    "a deposit is held until the ownership transfer is done",
    "every price change and status change is recorded"
  ]
}

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 2: Criar o banco de dados

Use utf8mb4 para guardar corretamente texto em tailandês e emojis.

CREATE DATABASE IF NOT EXISTS carmarket
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;
USE carmarket;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 3: Uma tabela de usuários

Uma tabela de contas para compradores, vendedores, revendas e equipe.

CREATE TABLE users (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email         VARCHAR(190) NOT NULL UNIQUE,
  phone         VARCHAR(20)  NULL,
  password_hash CHAR(60)     NOT NULL,
  full_name     VARCHAR(120) NOT NULL,
  role          ENUM('buyer','seller','dealer','inspector','admin') NOT NULL DEFAULT 'buyer',
  is_active     TINYINT(1)   NOT NULL DEFAULT 1,
  created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 4: Usuários de exemplo

Contas fictícias com hashes de senha de exemplo.

INSERT INTO users (email, phone, password_hash, full_name, role) VALUES
  ('[email protected]', '0810000001', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Somchai Example', 'seller'),
  ('[email protected]',   '0810000002', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Malee Example',   'buyer'),
  ('[email protected]','0810000003', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Prime Auto Example', 'dealer'),
  ('[email protected]','0810000004','$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Inspector Example', 'inspector');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 5: Uma tabela de revendas

Dados da empresa separados da conta de login.

CREATE TABLE dealers (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id       BIGINT UNSIGNED NOT NULL UNIQUE,
  business_name VARCHAR(160) NOT NULL,
  tax_id        CHAR(13)     NOT NULL UNIQUE,
  is_verified   TINYINT(1)   NOT NULL DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 6: Uma revenda de exemplo

Vincule uma empresa verificada à sua conta de usuário.

INSERT INTO dealers (user_id, business_name, tax_id, is_verified)
SELECT id, 'Prime Auto Example Co., Ltd.', '0105500000001', 1
FROM users WHERE email = '[email protected]';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 2: Usuários, Revendas e Endereços

Todo mundo que faz login tem uma linha em users, com um papel. Revendas guardam dados empresariais extras na própria tabela, e os endereços permitem que a busca filtre carros por província. Separar essas tabelas mantém cada uma pequena e cada regra fácil de verificar.

Uma tabela de contas com papéis

A tabela users guarda o e-mail, o hash da senha e o papel. Um papel como vendedor, comprador, revenda, inspetor ou administrador define quais telas e ações são permitidas. Nunca armazene senhas em texto puro. Guarde apenas o hash bcrypt.

Revendas como tabela separada

Uma revenda é um usuário com empresa registrada, então o nome empresarial e o CNPJ ficam em dealers e apontam para users por chave estrangeira. A flag is_verified só é ativada depois que a equipe confere os documentos, e só revendas verificadas exibem o selo.

Endereços e preferências

A província é guardada em cada endereço e em cada anúncio, com um índice para que a busca por uma província seja rápida. As preferências do comprador, como orçamento e tipo de combustível, ficam separadas para poderem mudar sem tocar na conta.

Exemplo 7: Endereços

Guarde os locais para a busca filtrar por província.

CREATE TABLE addresses (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id      BIGINT UNSIGNED NOT NULL,
  line1        VARCHAR(200) NOT NULL,
  district     VARCHAR(100) NOT NULL,
  province     VARCHAR(100) NOT NULL,
  postal_code  CHAR(5)      NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_addresses_province (province)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 8: Um endereço de exemplo

Endereço de um vendedor em Chonburi.

INSERT INTO addresses (user_id, line1, district, province, postal_code)
SELECT id, '99/1 Example Road', 'Mueang', 'Chonburi', '20000'
FROM users WHERE email = '[email protected]';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 9: Preferências do comprador

Guarde o orçamento e o tipo de combustível preferido.

CREATE TABLE buyer_preferences (
  user_id      BIGINT UNSIGNED PRIMARY KEY,
  max_budget   DECIMAL(12,2) NULL,
  preferred_fuel ENUM('petrol','diesel','hybrid','ev','lpg','cng') NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 10: Sessões de login

Faça as sessões expirarem e apague-as junto com o usuário.

CREATE TABLE sessions (
  id           CHAR(64) PRIMARY KEY,
  user_id      BIGINT UNSIGNED NOT NULL,
  expires_at   DATETIME NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_sessions_expires (expires_at)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 11: Marcas de carros

Uma linha por marca, com nome único.

CREATE TABLE car_makes (
  id    SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name  VARCHAR(60) NOT NULL UNIQUE
) ENGINE=InnoDB;
INSERT INTO car_makes (name) VALUES ('Toyota'), ('Honda'), ('Mazda'), ('Isuzu'), ('Mitsubishi');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 12: Modelos de carros

Cada modelo pertence a exatamente uma marca.

CREATE TABLE car_models (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  make_id      SMALLINT UNSIGNED NOT NULL,
  name         VARCHAR(80) NOT NULL,
  body_type    ENUM('sedan','hatchback','suv','pickup','van') NOT NULL,
  UNIQUE KEY uq_model (make_id, name),
  FOREIGN KEY (make_id) REFERENCES car_makes(id)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 13: Modelos de exemplo

Insira modelos com junção à tabela de marcas.

INSERT INTO car_models (make_id, name, body_type)
SELECT m.id, x.name, x.body
FROM car_makes m
JOIN (SELECT 'Toyota' AS make, 'Camry' AS name, 'sedan' AS body
      UNION ALL SELECT 'Toyota', 'Yaris Ativ', 'sedan'
      UNION ALL SELECT 'Honda', 'City', 'sedan'
      UNION ALL SELECT 'Honda', 'CR-V', 'suv'
      UNION ALL SELECT 'Mazda', 'CX-5', 'suv'
      UNION ALL SELECT 'Isuzu', 'D-Max', 'pickup') x ON x.make = m.name;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 14: Versões (trims)

O mesmo modelo vem em versões com motores diferentes.

CREATE TABLE car_trims (
  id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  model_id      INT UNSIGNED NOT NULL,
  name          VARCHAR(80) NOT NULL,
  year_from     SMALLINT UNSIGNED NOT NULL,
  year_to       SMALLINT UNSIGNED NULL,
  engine_cc     SMALLINT UNSIGNED NOT NULL,
  fuel          ENUM('petrol','diesel','hybrid','ev','lpg','cng') NOT NULL,
  transmission  ENUM('auto','manual','cvt') NOT NULL,
  seats         TINYINT UNSIGNED NOT NULL DEFAULT 5,
  FOREIGN KEY (model_id) REFERENCES car_models(id),
  INDEX idx_trims_model_fuel (model_id, fuel)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 3: O Catálogo de Carros: Marcas, Modelos e Versões

Um catálogo guarda os fatos sobre um modelo uma única vez, em vez de repeti-los em cada anúncio. Uma marca tem muitos modelos, um modelo tem muitas versões, e uma versão tem o motor, o combustível e o câmbio que o comprador realmente compara.

Marca, modelo, versão

Uma marca como Toyota tem modelos como o Camry. Uma versão como G 2.5 é uma variante desse modelo com motor e câmbio próprios. O mesmo modelo pode ter várias versões em anos diferentes, então as faixas de ano pertencem à versão.

Opcionais e relações muitos para muitos

Opcionais como teto solar ou câmera de ré não existem em todas as versões. Uma tabela de ligação conecta opcionais a versões, então um mesmo opcional pode aparecer em várias versões e uma versão pode ter vários opcionais.

Por que o catálogo importa

Quando o vendedor escolhe uma versão, o motor, o combustível e o número de lugares vêm do catálogo. O vendedor não consegue digitá-los errado, e a busca por combustível ou câmbio funciona com confiança, porque os valores são guardados como códigos, não como texto livre.

Exemplo 15: Uma versão do catálogo

Versão a gasolina automática para uma faixa de anos.

INSERT INTO car_trims (model_id, name, year_from, year_to, engine_cc, fuel, transmission)
SELECT id, 'G 2.5', 2018, 2023, 2487, 'petrol', 'auto'
FROM car_models WHERE name = 'Camry';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 16: Uma versão híbrida

Versão híbrida com câmbio CVT e sem ano final.

INSERT INTO car_trims (model_id, name, year_from, year_to, engine_cc, fuel, transmission)
SELECT id, 'Hybrid Premium', 2019, NULL, 1798, 'hybrid', 'cvt'
FROM car_models WHERE name = 'Camry';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 17: Opcionais

Teto solar, câmera, assistente de faixa e bancos de couro como códigos.

CREATE TABLE car_options (
  id    SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  code  VARCHAR(40) NOT NULL UNIQUE,
  label VARCHAR(80) NOT NULL
) ENGINE=InnoDB;
INSERT INTO car_options (code, label) VALUES
  ('sunroof', 'Sunroof'), ('rear_camera', 'Rear camera'), ('lane_assist', 'Lane assist'), ('leather', 'Leather seats');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 18: Opcionais por versão

Vincule os opcionais às versões com uma tabela muitos para muitos.

CREATE TABLE trim_options (
  trim_id   INT UNSIGNED NOT NULL,
  option_id SMALLINT UNSIGNED NOT NULL,
  PRIMARY KEY (trim_id, option_id),
  FOREIGN KEY (trim_id) REFERENCES car_trims(id) ON DELETE CASCADE,
  FOREIGN KEY (option_id) REFERENCES car_options(id)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 19: Anúncios de venda

Uma linha por carro físico à venda, com regras CHECK.

CREATE TABLE car_listings (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  seller_id     BIGINT UNSIGNED NOT NULL,
  trim_id       INT UNSIGNED NOT NULL,
  vin           CHAR(17) NOT NULL UNIQUE,
  plate         VARCHAR(20) NULL,
  year          SMALLINT UNSIGNED NOT NULL,
  mileage_km    INT UNSIGNED NOT NULL,
  color         VARCHAR(40) NOT NULL,
  asking_price  DECIMAL(12,2) NOT NULL,
  status        ENUM('draft','pending_review','active','reserved','sold','removed') NOT NULL DEFAULT 'draft',
  province      VARCHAR(100) NOT NULL,
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (seller_id) REFERENCES users(id),
  FOREIGN KEY (trim_id) REFERENCES car_trims(id),
  CHECK (asking_price > 0),
  CHECK (mileage_km >= 0)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 20: Anúncio ativo de exemplo

Um Camry 2019 à venda em Chonburi.

INSERT INTO car_listings (seller_id, trim_id, vin, plate, year, mileage_km, color, asking_price, status, province)
SELECT u.id, t.id, 'JTNB11HK103000001', 'กข 1234', 2019, 48250, 'White', 765000.00, 'active', 'Chonburi'
FROM users u, car_trims t
WHERE u.email = '[email protected]' AND t.name = 'G 2.5' AND t.year_from = 2018
LIMIT 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 21: Fotos do carro

Guarde as URLs das fotos com uma ordem de exibição fixa.

CREATE TABLE car_images (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  url          VARCHAR(500) NOT NULL,
  position     TINYINT UNSIGNED NOT NULL DEFAULT 0,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id) ON DELETE CASCADE,
  UNIQUE KEY uq_image_position (listing_id, position)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 22: Fotos de exemplo

Três fotos do primeiro anúncio.

INSERT INTO car_images (listing_id, url, position) VALUES
  (1, 'https://cdn.example.com/cars/1/front.jpg', 0),
  (1, 'https://cdn.example.com/cars/1/side.jpg', 1),
  (1, 'https://cdn.example.com/cars/1/interior.jpg', 2);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 4: Anúncios e Fotos

Um anúncio é um carro físico à venda. Ele tem seu próprio VIN, quilometragem, cor, preço e status. As fotos ficam em uma tabela separada com ordem fixa, então a primeira foto é sempre a dianteira e a galeria carrega na mesma ordem toda vez.

Uma linha por carro físico

Cada carro tem um VIN único, então o mesmo carro não pode ser anunciado duas vezes. O status passa de rascunho para aguardando análise, depois para ativo, reservado, vendido ou removido. Regras CHECK impedem que um preço ou uma quilometragem negativos sejam guardados.

Alterar um anúncio com segurança

Só o vendedor pode alterar o preço. Coloque a verificação do vendedor na mesma instrução UPDATE que faz a mudança, para que não exista uma brecha entre a verificação e a gravação. Mude o status apenas pelas etapas permitidas e registre cada alteração.

Fotos e a página de detalhes

Guarde a URL da foto e a posição, não o arquivo de imagem. A página de detalhes carrega o carro, o vendedor e as fotos em uma única consulta, e a maquete mostra como o resultado se organiza no celular e no desktop.

Exemplo 23: Anúncio em rascunho

O vendedor cria o carro antes de publicá-lo.

INSERT INTO car_listings (seller_id, trim_id, vin, year, mileage_km, color, asking_price, status, province)
SELECT u.id, t.id, 'JTNB11HK103000002', 2020, 31000, 'Silver', 720000.00, 'draft', 'Bangkok'
FROM users u, car_trims t
WHERE u.email = '[email protected]' AND t.name = 'G 2.5'
LIMIT 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 24: Mudar o preço como dono

Verifique o vendedor na mesma instrução.

UPDATE car_listings
SET asking_price = 745000.00
WHERE id = 2 AND seller_id = (SELECT id FROM users WHERE email = '[email protected]');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 25: Avançar o status do anúncio

Permita a mudança de status apenas a partir da etapa anterior.

UPDATE car_listings
SET status = 'pending_review'
WHERE id = 2 AND status = 'draft';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 26: Anúncios ativos em uma província

Junte o catálogo para mostrar marca, modelo e versão.

SELECT l.id, l.year, l.mileage_km, l.asking_price, t.name AS trim, m.name AS model, mk.name AS make
FROM car_listings l
JOIN car_trims t  ON t.id = l.trim_id
JOIN car_models m ON m.id = t.model_id
JOIN car_makes mk ON mk.id = m.make_id
WHERE l.status = 'active' AND l.province = 'Chonburi'
ORDER BY l.asking_price;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 27: A página de detalhes do anúncio

Carregue o carro, as fotos e o vendedor em uma consulta.

SELECT l.*, u.full_name AS seller_name, GROUP_CONCAT(i.url ORDER BY i.position) AS photos
FROM car_listings l
JOIN users u ON u.id = l.seller_id
LEFT JOIN car_images i ON i.listing_id = l.id
WHERE l.id = 1
GROUP BY l.id;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 28: Maquete: o cartão de resultado

Cartão do carro com preço, especificações e botão de oferta.

<article class="car-card">
  <img src="https://cdn.example.com/cars/1/front.jpg" alt="Toyota Camry 2019 white">
  <h3>Toyota Camry G 2.5 (2019)</h3>
  <p class="meta">48,250 km · Auto · Petrol · Chonburi</p>
  <p class="price">฿765,000</p>
  <button>Make an offer</button>
  <button class="icon" aria-label="Save to favorites">♡</button>
</article>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 29: Maquete: a página de detalhes

Galeria, resumo e ações em uma só tela.

<main class="detail">
  <section class="gallery">
    <img src="front.jpg" alt="Front view">
    <div class="thumbs"><img src="side.jpg" alt="Side view"><img src="interior.jpg" alt="Interior"></div>
  </section>
  <aside class="summary">
    <h1>Toyota Camry G 2.5 (2019)</h1>
    <p class="price">฿765,000</p>
    <ul>
      <li>Mileage: 48,250 km</li>
      <li>Color: White</li>
      <li>Province: Chonburi</li>
      <li>Inspection score: <strong>4.5 / 5</strong></li>
    </ul>
    <button class="primary">Make an offer</button>
    <button>Ask the seller</button>
  </aside>
</main>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 30: Maquete: cores e espaçamento

Tokens de design como variáveis CSS.

<style>
  :root {
    --brand: #1f5fbf;
    --ink: #1b1f2a;
    --muted: #6b7280;
    --radius: 12px;
    --gap: 16px;
  }
  .car-card { border: 1px solid #e5e7eb; border-radius: var(--radius); padding: var(--gap); }
  .price { color: var(--brand); font-size: 1.25rem; font-weight: 700; }
  .meta { color: var(--muted); }
</style>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 5: Vistoria e Estado do Carro

Um carro usado só vale o que está em bom estado. Um inspetor dá nota ao carro, classifica cada peça e anexa um laudo. O anúncio só entra no ar se a vistoria for aprovada, então o comprador vê os mesmos fatos que o inspetor registrou.

A vistoria e seus itens

Uma vistoria tem uma nota geral de 0 a 5 e um laudo em PDF. Cada peça, como motor, freios ou pneus, tem sua própria linha com uma classificação de bom, regular, reparar ou trocar. As classificações explicam a nota, então o comprador vê onde estão os problemas.

Leituras de quilometragem

Guarde cada leitura de quilometragem do vendedor, do inspetor e do registro de manutenção. Se uma nova leitura for menor que uma anterior, o odômetro pode ter sido adulterado, então o sistema marca o anúncio para revisão em vez de mostrar um número errado.

Publicar só depois da vistoria

O anúncio passa para ativo somente quando existe uma vistoria com nota de pelo menos 3,0. A regra fica no banco de dados e também na aplicação, então nenhum caminho de código consegue publicar um carro não vistoriado.

Exemplo 31: Vistorias

Um vistoriador certificado dá nota a cada carro.

CREATE TABLE inspections (
  id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id     BIGINT UNSIGNED NOT NULL,
  inspector_id   BIGINT UNSIGNED NOT NULL,
  overall_score  DECIMAL(2,1) NOT NULL,
  report_url     VARCHAR(500) NULL,
  inspected_at   DATETIME NOT NULL,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (inspector_id) REFERENCES users(id),
  CHECK (overall_score BETWEEN 0 AND 5)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 32: Uma vistoria de exemplo

Nota 4,5 de 5 com laudo em PDF.

INSERT INTO inspections (listing_id, inspector_id, overall_score, report_url, inspected_at)
SELECT 1, u.id, 4.5, 'https://cdn.example.com/reports/1.pdf', NOW()
FROM users u WHERE u.email = '[email protected]';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 33: Itens da vistoria

Classifique cada peça: boa, regular, reparar ou trocar.

CREATE TABLE inspection_items (
  id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  inspection_id  BIGINT UNSIGNED NOT NULL,
  part           VARCHAR(60) NOT NULL,
  grade          ENUM('good','fair','repair','replace') NOT NULL,
  note           VARCHAR(255) NULL,
  FOREIGN KEY (inspection_id) REFERENCES inspections(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 34: Itens de vistoria de exemplo

Pastilhas de freio com 40 por cento e um arranhão na porta.

INSERT INTO inspection_items (inspection_id, part, grade, note) VALUES
  (1, 'engine', 'good', 'No leaks'),
  (1, 'brakes', 'fair', 'Front pads at 40 percent'),
  (1, 'tyres',  'good', 'Above 4 mm tread'),
  (1, 'paint',  'repair', 'Small scratch on the rear door');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 35: Encontrar as peças que precisam de reparo

Conte os problemas por anúncio.

SELECT i.listing_id, COUNT(*) AS problems
FROM inspection_items ii
JOIN inspections i ON i.id = ii.inspection_id
WHERE ii.grade IN ('repair','replace')
GROUP BY i.listing_id;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 36: Publicar somente após a vistoria

O anúncio só vai ao ar com nota aprovada.

UPDATE car_listings l
SET l.status = 'active'
WHERE l.id = 2
  AND l.status = 'pending_review'
  AND EXISTS (SELECT 1 FROM inspections i WHERE i.listing_id = l.id AND i.overall_score >= 3.0);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 37: Maquete: o laudo da vistoria

As classificações exibidas na página do anúncio.

<section class="inspection">
  <h2>Inspection report</h2>
  <p>Overall score <strong>4.5 / 5</strong> · Inspected 12 Oct 2026</p>
  <ul class="grades">
    <li class="good">Engine: good</li>
    <li class="fair">Brakes: fair, front pads at 40%</li>
    <li class="good">Tyres: good</li>
    <li class="repair">Paint: small scratch on the rear door</li>
  </ul>
  <a href="https://cdn.example.com/reports/1.pdf">Download the full PDF</a>
</section>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 38: Leituras de quilometragem

Guarde cada leitura para detectar odômetro adulterado.

CREATE TABLE mileage_readings (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  reading_km   INT UNSIGNED NOT NULL,
  source       ENUM('seller','inspector','service_record') NOT NULL,
  read_at      DATETIME NOT NULL,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id) ON DELETE CASCADE,
  INDEX idx_mileage_listing_time (listing_id, read_at)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 6: Busca, Filtros e Desempenho

Compradores pesquisam por província, preço, ano, combustível e câmbio, e esperam resultados em bem menos de um segundo. Índices nas colunas que aparecem em WHERE e ORDER BY tornam isso possível, e a paginação por chave mantém as páginas profundas rápidas.

Índices que combinam com a consulta

Um índice composto em status, província, preço e ano atende a busca comum. A ordem das colunas importa: coloque as colunas de igualdade primeiro e a coluna de intervalo por último. O EXPLAIN mostra se o índice é usado, e o EXPLAIN ANALYZE mostra o tempo real.

Busca textual e filtros com junção

Um índice FULLTEXT na descrição permite procurar palavras como teto solar ou couro. Combustível e câmbio ficam na tabela de versões, então a busca faz junção com essa tabela, e a junção usa o índice da versão.

Paginação por chave e buscas salvas

O OFFSET lê e descarta todas as linhas anteriores, então páginas mais profundas ficam mais lentas. A paginação por chave lembra a última data de criação e o último id e continua a partir deles. Uma busca salva guarda os filtros em JSON para que o comprador receba os novos anúncios que combinam.

Exemplo 39: Um índice de busca

Indexe status, província, preço e ano juntos.

CREATE INDEX idx_listings_search
  ON car_listings (status, province, asking_price, year);
EXPLAIN SELECT id, asking_price FROM car_listings
WHERE status = 'active' AND province = 'Chonburi' AND asking_price BETWEEN 500000 AND 900000;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 40: Busca em texto completo

Pesquise a descrição com MATCH AGAINST.

ALTER TABLE car_listings ADD COLUMN description TEXT NULL,
  ADD FULLTEXT INDEX ft_listings_description (description);
SELECT id, asking_price FROM car_listings
WHERE status = 'active' AND MATCH(description) AGAINST ('sunroof leather' IN NATURAL LANGUAGE MODE);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 41: Filtrar por combustível e câmbio

Junte a tabela de versões para achar carros híbridos CVT.

SELECT l.id, l.asking_price, t.fuel, t.transmission
FROM car_listings l
JOIN car_trims t ON t.id = l.trim_id
WHERE l.status = 'active' AND t.fuel = 'hybrid' AND t.transmission = 'cvt'
ORDER BY l.asking_price
LIMIT 20;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 42: Paginação por chave (keyset)

Percorra os resultados sem o OFFSET caro.

SELECT id, asking_price, created_at
FROM car_listings
WHERE status = 'active'
  AND (created_at, id) < ('2026-10-01 10:00:00', 1000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 43: Buscas salvas

Guarde os filtros escolhidos pelo comprador em JSON.

CREATE TABLE saved_searches (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL,
  filters     JSON NOT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
INSERT INTO saved_searches (user_id, filters)
SELECT id, JSON_OBJECT('make','Toyota','fuel','hybrid','max_price',900000)
FROM users WHERE email = '[email protected]';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 44: Maquete: a barra lateral de filtros

Filtros de marca, preço, ano e combustível com botão de exibir.

<aside class="filters">
  <h3>Filters</h3>
  <label>Make <select><option>Toyota</option><option>Honda</option></select></label>
  <label>Price (THB) <input type="range" min="100000" max="2000000"></label>
  <label>Year from <input type="number" value="2018"></label>
  <fieldset>
    <legend>Fuel</legend>
    <label><input type="checkbox" checked> Hybrid</label>
    <label><input type="checkbox"> Petrol</label>
  </fieldset>
  <button>Show 24 cars</button>
</aside>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 45: Contar carros por marca

Mostre os números ao lado de cada opção.

SELECT mk.name AS make, COUNT(*) AS cars
FROM car_listings l
JOIN car_trims t ON t.id = l.trim_id
JOIN car_models m ON m.id = t.model_id
JOIN car_makes mk ON mk.id = m.make_id
WHERE l.status = 'active'
GROUP BY mk.name
ORDER BY cars DESC;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 46: Verificar o plano da consulta

Use EXPLAIN ANALYZE para ver se o índice é usado.

EXPLAIN ANALYZE
SELECT id FROM car_listings
WHERE status = 'active' AND province = 'Chonburi'
ORDER BY asking_price
LIMIT 20;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 7: Ofertas e Negociação

Uma oferta é a proposta de um comprador por um carro, com prazo de validade. O preço pedido permanece o mesmo, então vários compradores podem fazer ofertas, e o vendedor pode aceitar uma, recusar as outras ou fazer uma contraproposta com um novo preço.

Ofertas em tabela própria

Uma oferta é uma linha com o anúncio, o comprador, o valor, o status e a data de expiração. A contraproposta do vendedor é uma linha nova que aponta para a oferta do comprador, então toda a negociação continua visível no histórico.

Preço mínimo e verificações

O vendedor pode definir um preço mínimo. Compare a oferta com o mínimo antes de mostrá-la ao vendedor como aceitável. Guarde a decisão com o motivo, para que disputas possam ser respondidas depois.

Expiração e a maquete

Um trabalho agendado marca as ofertas antigas como expiradas. O diálogo de fazer oferta mostra a data de expiração e explica o que o vendedor pode fazer, para que o comprador saiba o que acontece em seguida.

Exemplo 47: Ofertas

O comprador propõe um preço; o preço anunciado não muda.

CREATE TABLE offers (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  buyer_id     BIGINT UNSIGNED NOT NULL,
  amount       DECIMAL(12,2) NOT NULL,
  status       ENUM('open','accepted','rejected','withdrawn','expired') NOT NULL DEFAULT 'open',
  expires_at   DATETIME NOT NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (buyer_id) REFERENCES users(id),
  INDEX idx_offers_listing_status (listing_id, status),
  CHECK (amount > 0)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 48: Uma oferta de exemplo

O comprador oferece 720.000 baht por 48 horas.

INSERT INTO offers (listing_id, buyer_id, amount, expires_at)
SELECT 1, u.id, 720000.00, DATE_ADD(NOW(), INTERVAL 48 HOUR)
FROM users u WHERE u.email = '[email protected]';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 49: Maquete: o diálogo de oferta

Campo de valor, aviso de validade e botão de envio.

<dialog id="offer">
  <form method="dialog">
    <h2>Make an offer</h2>
    <p>Asking price: ฿765,000</p>
    <label>Your offer (THB)
      <input type="number" min="1" step="1000" value="720000" required>
    </label>
    <p class="hint">The offer expires in 48 hours. The seller can accept, reject or counter.</p>
    <button value="cancel">Cancel</button>
    <button value="send" class="primary">Send offer</button>
  </form>
</dialog>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 50: Recusar uma oferta baixa

Compare a oferta com o mínimo que o vendedor aceita.

ALTER TABLE car_listings ADD COLUMN min_offer DECIMAL(12,2) NULL;
UPDATE car_listings SET min_offer = 700000.00 WHERE id = 1;
SELECT CASE WHEN 650000.00 < min_offer THEN 'reject: below minimum' ELSE 'accept for review' END AS decision
FROM car_listings WHERE id = 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 51: Aceitar uma oferta em uma transação

Trave o carro, aceite a oferta e reserve o anúncio juntos.

START TRANSACTION;
SELECT id, status FROM car_listings WHERE id = 1 FOR UPDATE;
UPDATE offers SET status = 'accepted' WHERE id = 1 AND status = 'open';
UPDATE car_listings SET status = 'reserved' WHERE id = 1 AND status = 'active';
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 52: Recusar as outras ofertas

Quando uma oferta vence, as outras do mesmo carro são encerradas.

UPDATE offers
SET status = 'rejected'
WHERE listing_id = 1 AND status = 'open' AND id <> 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 53: Expirar ofertas antigas

Um trabalho agendado encerra as ofertas que passaram do prazo.

UPDATE offers
SET status = 'expired'
WHERE status = 'open' AND expires_at < NOW();

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 54: Uma contraproposta do vendedor

Guarde a contraproposta como nova oferta ligada à do comprador.

ALTER TABLE offers ADD COLUMN parent_offer_id BIGINT UNSIGNED NULL,
  ADD COLUMN made_by ENUM('buyer','seller') NOT NULL DEFAULT 'buyer';
INSERT INTO offers (listing_id, buyer_id, amount, expires_at, parent_offer_id, made_by)
SELECT 1, buyer_id, 735000.00, DATE_ADD(NOW(), INTERVAL 24 HOUR), id, 'seller'
FROM offers WHERE id = 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 8: Pedidos e o Fluxo de Status

Quando um vendedor aceita uma oferta, um pedido é criado. O pedido passa por um conjunto fixo de status, e cada mudança é registrada, para que a equipe de suporte sempre veja como uma venda chegou ao estado atual.

Um pedido confirmado por carro

A coluna listing_id da tabela orders tem uma restrição UNIQUE, então o banco recusa um segundo pedido para o mesmo carro, mesmo que dois pedidos cheguem ao mesmo tempo. O sinal devido é calculado a partir do preço final, aqui 10 por cento.

Criar o pedido na mesma transação

Aceitar a oferta, reservar o anúncio e inserir o pedido acontecem dentro de uma única transação. Se qualquer etapa falhar, todas são revertidas, então um carro nunca fica reservado sem pedido, nem existe pedido sem carro reservado.

Histórico de status e etapas permitidas

Cada mudança no pedido é gravada em uma tabela de histórico com o status antigo, o novo status, quem fez e a hora. O UPDATE verifica o status atual, então um pedido não pode pular de aguardando sinal direto para concluído.

Exemplo 55: Uma tabela de pedidos

Uma compra confirmada por carro, com preço final e sinal.

CREATE TABLE orders (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id    BIGINT UNSIGNED NOT NULL UNIQUE,
  buyer_id      BIGINT UNSIGNED NOT NULL,
  seller_id     BIGINT UNSIGNED NOT NULL,
  final_price   DECIMAL(12,2) NOT NULL,
  deposit_due   DECIMAL(12,2) NOT NULL,
  status        ENUM('pending_deposit','deposit_paid','documents_pending','completed','cancelled','refunded') NOT NULL DEFAULT 'pending_deposit',
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (buyer_id) REFERENCES users(id),
  FOREIGN KEY (seller_id) REFERENCES users(id),
  CHECK (final_price > 0),
  CHECK (deposit_due <= final_price)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 56: Criar o pedido a partir da oferta aceita

Insira o pedido na mesma transação da aceitação.

START TRANSACTION;
SELECT status FROM car_listings WHERE id = 1 FOR UPDATE;
INSERT INTO orders (listing_id, buyer_id, seller_id, final_price, deposit_due)
SELECT o.listing_id, o.buyer_id, l.seller_id, o.amount, ROUND(o.amount * 0.10, 2)
FROM offers o JOIN car_listings l ON l.id = o.listing_id
WHERE o.id = 1 AND o.status = 'accepted';
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 57: Histórico de status do pedido

Registre cada mudança de status, com quem a fez e quando.

CREATE TABLE order_status_history (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id    BIGINT UNSIGNED NOT NULL,
  from_status VARCHAR(30) NULL,
  to_status   VARCHAR(30) NOT NULL,
  actor_id    BIGINT UNSIGNED NULL,
  changed_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
  INDEX idx_history_order (order_id, changed_at)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 58: Avançar o pedido uma etapa

Mude o status somente a partir da etapa esperada.

UPDATE orders
SET status = 'deposit_paid'
WHERE id = 1 AND status = 'pending_deposit';
INSERT INTO order_status_history (order_id, from_status, to_status, actor_id)
VALUES (1, 'pending_deposit', 'deposit_paid', 2);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 59: Cancelar um pedido e liberar o carro

Devolva o anúncio para ativo quando o pedido for cancelado cedo.

START TRANSACTION;
UPDATE orders SET status = 'cancelled' WHERE id = 1 AND status IN ('pending_deposit','deposit_paid');
UPDATE car_listings SET status = 'active' WHERE id = 1 AND status = 'reserved';
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 60: Maquete: a página de andamento do pedido

Etapas desde a oferta aceita até a venda concluída.

<section class="order">
  <h2>Order #1 · Toyota Camry G 2.5 (2019)</h2>
  <ol class="steps">
    <li class="done">Offer accepted</li>
    <li class="done">Deposit paid ฿76,500</li>
    <li class="current">Documents in progress</li>
    <li>Ownership transferred</li>
    <li>Completed</li>
  </ol>
  <button>Cancel order</button>
  <p class="hint">You can cancel until the documents step starts.</p>
</section>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 9: Pagamentos como Livro-Razão

O dinheiro nunca é guardado como um único número que vai sendo editado. Cada pagamento, estorno e saldo é uma linha em um livro-razão. Os totais são calculados a partir das linhas, então o histórico fica sempre completo e os números sempre batem.

Uma linha por movimentação de dinheiro

Um pagamento tem um tipo, como sinal, saldo ou estorno, um valor, uma referência do provedor e um status. A referência do provedor é UNIQUE, então o mesmo pagamento não pode ser registrado duas vezes, e a linha se liga ao registro do banco ou do próprio provedor.

Webhooks que chegam duas vezes

Provedores de pagamento podem enviar a mesma notificação mais de uma vez. A atualização só muda o status quando a linha ainda está pendente, então a segunda mensagem não altera nada. Isso torna o tratador seguro para novas tentativas.

Estornos e o valor líquido pago

Um estorno é uma linha nova, não um pagamento negativo que sobrescreve o antigo. O valor líquido pago de um pedido é a soma dos pagamentos bem-sucedidos menos os estornos bem-sucedidos. Uma verificação antes da publicação interrompe a liberação se o valor líquido pago passar do preço final.

Exemplo 61: Uma tabela de pagamentos

Cada movimentação de dinheiro é uma linha com referência do provedor.

CREATE TABLE payments (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT UNSIGNED NOT NULL,
  kind          ENUM('deposit','balance','refund') NOT NULL,
  amount        DECIMAL(12,2) NOT NULL,
  provider      VARCHAR(40) NOT NULL,
  provider_ref  VARCHAR(100) NOT NULL UNIQUE,
  status        ENUM('pending','succeeded','failed') NOT NULL DEFAULT 'pending',
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (amount > 0)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 62: Registrar o sinal antes da chamada de pagamento

Guarde o pagamento como pendente primeiro e depois atualize.

INSERT INTO payments (order_id, kind, amount, provider, provider_ref, status)
VALUES (1, 'deposit', 76500.00, 'example-pay', 'ref-0001', 'pending');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 63: Tratar um webhook de pagamento repetido

Atualize apenas uma vez, para que a mensagem duplicada não mude nada.

UPDATE payments
SET status = 'succeeded'
WHERE provider_ref = 'ref-0001' AND status = 'pending';
-- 0 rows changed on the second call, so the order is not moved twice
UPDATE orders SET status = 'deposit_paid'
WHERE id = 1 AND status = 'pending_deposit'
  AND (SELECT status FROM payments WHERE provider_ref = 'ref-0001') = 'succeeded';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 64: Total líquido pago por pedido

Some sinal e saldo e subtraia reembolsos, a partir do livro-razão.

SELECT o.id,
  o.final_price,
  COALESCE(SUM(CASE WHEN p.kind IN ('deposit','balance') AND p.status = 'succeeded' THEN p.amount END), 0)
  - COALESCE(SUM(CASE WHEN p.kind = 'refund' AND p.status = 'succeeded' THEN p.amount END), 0) AS net_paid
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.id = 1
GROUP BY o.id, o.final_price;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 65: Reembolsar o sinal

Insira uma linha de reembolso ligada ao sinal original.

INSERT INTO payments (order_id, kind, amount, provider, provider_ref, status)
SELECT order_id, 'refund', amount, provider, CONCAT('refund-', provider_ref), 'pending'
FROM payments
WHERE order_id = 1 AND kind = 'deposit' AND status = 'succeeded';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 66: Maquete: a tela de pagamento do sinal

Valor do sinal, aviso de retenção e botão de pagar.

<form class="payment">
  <h2>Pay the deposit</h2>
  <p>Deposit (10%): <strong>฿76,500</strong></p>
  <p class="hint">Held until the ownership transfer is done. Refunded if the sale fails.</p>
  <label>Card number <input name="card" autocomplete="cc-number" placeholder="•••• •••• •••• ••••"></label>
  <button type="submit" class="primary">Pay ฿76,500</button>
</form>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 10: Transferência de Propriedade e Documentos

Em muitos países, um carro só troca de dono no órgão de trânsito, com papéis assinados. O banco de dados modela isso como uma lista de etapas, cada uma com seus documentos, para que o comprador veja exatamente o que falta e a equipe verifique cada documento antes de o carro ser marcado como vendido.

Etapas como linhas

Cada etapa da transferência tem um número, um nome e um status: a fazer, em andamento, concluída ou bloqueada. O pedido só é concluído quando todas as etapas estiverem prontas, o que é verificado na mesma transação que marca o carro como vendido.

Documentos e verificação

Cada documento tem um tipo, como o contrato de compra e venda ou o certificado de registro, uma URL do arquivo, quem enviou e a data em que foi verificado. Uma etapa só pode ser marcada como concluída quando o documento dela foi verificado, então uma foto não verificada de um formulário não basta.

A lista de verificação para o comprador

A maquete mostra ao comprador uma lista com marcações e um botão de envio. A lista é gerada a partir da tabela de etapas, então a tela e o banco de dados sempre concordam sobre o que já foi concluído.

Exemplo 67: Etapas da transferência

Uma linha por etapa da mudança de propriedade.

CREATE TABLE transfer_steps (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id    BIGINT UNSIGNED NOT NULL,
  step_no     TINYINT UNSIGNED NOT NULL,
  name        VARCHAR(80) NOT NULL,
  status      ENUM('todo','doing','done','blocked') NOT NULL DEFAULT 'todo',
  done_at     DATETIME NULL,
  UNIQUE KEY uq_step (order_id, step_no),
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 68: Etapas padrão de um pedido

Cinco etapas, do contrato de venda à entrega das chaves.

INSERT INTO transfer_steps (order_id, step_no, name) VALUES
  (1, 1, 'Seller signs the sale agreement'),
  (1, 2, 'Buyer checks the registration book'),
  (1, 3, 'Land Transport Office appointment booked'),
  (1, 4, 'Ownership changed at the office'),
  (1, 5, 'Balance paid and keys handed over');

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 69: Documentos

Guarde cada documento enviado com seu tipo e a data da verificação.

CREATE TABLE documents (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT UNSIGNED NOT NULL,
  doc_type      ENUM('sale_agreement','registration_book','id_card','tax_receipt','transfer_form') NOT NULL,
  file_url      VARCHAR(500) NOT NULL,
  uploaded_by   BIGINT UNSIGNED NOT NULL,
  verified_at   DATETIME NULL,
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
  FOREIGN KEY (uploaded_by) REFERENCES users(id)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 70: Concluir a etapa só com documento verificado

Confira o documento antes de marcar a etapa como feita.

UPDATE transfer_steps s
SET s.status = 'done', s.done_at = NOW()
WHERE s.order_id = 1 AND s.step_no = 1
  AND EXISTS (SELECT 1 FROM documents d
              WHERE d.order_id = s.order_id AND d.doc_type = 'sale_agreement' AND d.verified_at IS NOT NULL);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 71: Maquete: a lista de verificação da transferência

Caixas de seleção para cada etapa com botão de envio.

<ul class="checklist">
  <li><input type="checkbox" checked disabled> Seller signed the sale agreement</li>
  <li><input type="checkbox" checked disabled> Registration book checked</li>
  <li><input type="checkbox"> Book an appointment at the office</li>
  <li><input type="checkbox"> Ownership changed</li>
  <li><input type="checkbox"> Balance paid</li>
</ul>
<button>Upload a document</button>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 72: Mudar a propriedade no fim

Conclua o pedido somente quando todas as etapas estiverem prontas.

START TRANSACTION;
UPDATE transfer_steps SET status = 'done', done_at = NOW()
WHERE order_id = 1 AND step_no = 4 AND status = 'doing';
UPDATE orders SET status = 'completed'
WHERE id = 1 AND status = 'documents_pending'
  AND NOT EXISTS (SELECT 1 FROM transfer_steps WHERE order_id = 1 AND status <> 'done');
UPDATE car_listings SET status = 'sold' WHERE id = 1 AND status = 'reserved';
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 11: Financiamento e Garantias

A maioria dos compradores não paga o valor total à vista. Um financiamento bancário é uma solicitação ligada ao pedido, e uma garantia protege o comprador por um período de meses ou quilômetros após a venda.

Solicitações de financiamento

Uma solicitação de financiamento pertence a um pedido e registra o banco, o valor, o prazo e o status: enviada, aprovada, recusada ou retirada. O prazo é limitado por uma regra CHECK, como 6 a 84 meses, então empréstimos impossíveis não podem ser cadastrados.

Garantias a partir da data da venda

A garantia só é criada quando o pedido é concluído. Ela guarda a quantidade de meses, o limite de quilômetros e a data de início. Verificar se ela ainda vale compara a data e a quilometragem ao mesmo tempo, então o comprador recebe a resposta correta.

Mostrar a oferta com clareza

A página do pedido mostra a parcela mensal como estimativa e as condições da garantia em linguagem simples. Informações claras reduzem disputas depois da venda, o que é mais barato do que tratar reclamações mais tarde.

Exemplo 73: Pedidos de financiamento

O comprador pede um empréstimo ao banco para a compra.

CREATE TABLE financing_applications (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id        BIGINT UNSIGNED NOT NULL,
  bank            VARCHAR(80) NOT NULL,
  amount          DECIMAL(12,2) NOT NULL,
  term_months     SMALLINT UNSIGNED NOT NULL,
  status          ENUM('submitted','approved','declined','withdrawn') NOT NULL DEFAULT 'submitted',
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (term_months BETWEEN 6 AND 84)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 74: Um pedido de exemplo

Pedido de empréstimo de 60 meses de 600.000 baht.

INSERT INTO financing_applications (order_id, bank, amount, term_months)
VALUES (1, 'Example Bank', 600000.00, 60);

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 75: Garantias

Uma garantia por pedido, com meses e quilômetros.

CREATE TABLE warranties (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id     BIGINT UNSIGNED NOT NULL UNIQUE,
  months       SMALLINT UNSIGNED NOT NULL,
  km_limit     INT UNSIGNED NOT NULL,
  starts_on    DATE NOT NULL,
  FOREIGN KEY (order_id) REFERENCES orders(id)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 76: Iniciar a garantia quando a venda for concluída

Crie a garantia a partir do pedido concluído.

INSERT INTO warranties (order_id, months, km_limit, starts_on)
SELECT id, 6, 10000, CURDATE() FROM orders WHERE id = 1 AND status = 'completed';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 77: Verificar se a garantia ainda vale

Compare a data e a quilometragem com os limites.

SELECT w.id,
  (CURDATE() < DATE_ADD(w.starts_on, INTERVAL w.months MONTH)) AND (48250 + 1500 <= 48250 + w.km_limit) AS still_valid
FROM warranties w WHERE w.order_id = 1;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 78: Maquete: a caixa de empréstimo e garantia

Parcela estimada e condições da garantia na página do pedido.

<aside class="box">
  <h3>Pay with a loan</h3>
  <p>Example Bank · 60 months</p>
  <p>Monthly payment is an estimate: <strong>฿11,800</strong></p>
  <p class="status">Status: Submitted</p>
  <h3>Warranty</h3>
  <p>6 months or 10,000 km, whichever comes first</p>
</aside>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 12: Avaliações, Denúncias e Confiança

Um marketplace só funciona se as pessoas confiarem nele. As avaliações chegam depois de uma venda concluída, as denúncias permitem que os usuários sinalizem problemas, e as regras que escondem anúncios precisam ser claras e justas, para que um único usuário irritado não consiga esconder um bom carro.

Avaliações depois da venda

Uma avaliação só é permitida para um pedido concluído, e cada pessoa pode avaliar uma vez por pedido. As notas ficam limitadas a 1 a 5 por uma regra CHECK. A média só aparece depois de três avaliações, para que uma única avaliação não faça um vendedor parecer perfeito ou péssimo.

Denúncias de várias pessoas

Uma denúncia diz o que está errado, como um preço falso ou uma quilometragem incorreta. Conte os denunciantes distintos antes de esconder um anúncio, para que uma pessoa sozinha não consiga esconder um carro. Anúncios escondidos vão para uma fila de análise da equipe.

Maquete das ações de avaliação e denúncia

O formulário de avaliação pede estrelas e um comentário, e o link de denúncia fica visível mas sem atrapalhar. Um bom design torna a denúncia fácil e a publicação acidental de uma avaliação difícil.

Exemplo 79: Avaliações após a venda concluída

Uma avaliação por pessoa por pedido, nota de 1 a 5.

CREATE TABLE reviews (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id     BIGINT UNSIGNED NOT NULL,
  reviewer_id  BIGINT UNSIGNED NOT NULL,
  reviewee_id  BIGINT UNSIGNED NOT NULL,
  rating       TINYINT UNSIGNED NOT NULL,
  comment      VARCHAR(1000) NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_review (order_id, reviewer_id),
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (rating BETWEEN 1 AND 5)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 80: Adicionar avaliação a um pedido concluído

Somente pedidos concluídos podem receber avaliação.

INSERT INTO reviews (order_id, reviewer_id, reviewee_id, rating, comment)
SELECT o.id, o.buyer_id, o.seller_id, 5, 'Car as described, smooth transfer.'
FROM orders o WHERE o.id = 1 AND o.status = 'completed';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 81: Nota média de cada vendedor

Mostre a nota do vendedor somente depois de três avaliações.

SELECT r.reviewee_id, ROUND(AVG(r.rating), 2) AS avg_rating, COUNT(*) AS reviews
FROM reviews r
GROUP BY r.reviewee_id
HAVING COUNT(*) >= 3;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 82: Denúncias de anúncios

Compradores e vendedores podem denunciar um anúncio suspeito.

CREATE TABLE listing_reports (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  reporter_id  BIGINT UNSIGNED NOT NULL,
  reason       ENUM('fake_price','stolen','wrong_mileage','duplicate','other') NOT NULL,
  status       ENUM('open','investigating','closed') NOT NULL DEFAULT 'open',
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (reporter_id) REFERENCES users(id)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 83: Ocultar um anúncio após várias denúncias

Conte os denunciantes distintos antes de agir.

SELECT listing_id, COUNT(DISTINCT reporter_id) AS reporters
FROM listing_reports
WHERE status = 'open'
GROUP BY listing_id
HAVING COUNT(DISTINCT reporter_id) >= 3;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 84: Maquete: o formulário de avaliação e o botão de denúncia

Nota em estrelas, caixa de comentário e link de denúncia.

<form class="review">
  <h3>Rate your purchase</h3>
  <div class="stars" role="radiogroup" aria-label="Rating">
    <label><input type="radio" name="rating" value="1"> 1</label>
    <label><input type="radio" name="rating" value="5" checked> 5</label>
  </div>
  <textarea maxlength="1000" placeholder="How was the car and the transfer?"></textarea>
  <button>Post review</button>
  <button type="button" class="link">Report this listing</button>
</form>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 13: Transações na Prática: Condições de Corrida

Dois compradores podem apertar Aceitar no mesmo instante. Sem cuidado, as duas requisições leem que o carro ainda está ativo e ambas o vendem. Este tema mostra como transações, travas de linha, a ordem das travas e chaves de idempotência evitam esse problema e outras falhas de um sistema real.

O que acontece sem trava

A sessão A lê o carro como ativo. A sessão B lê o mesmo status. As duas atualizam para reservado e as duas criam um pedido. O carro é vendido duas vezes. Uma trava na linha do carro, obtida com SELECT FOR UPDATE, faz a segunda sessão esperar até a primeira confirmar, e então a segunda vê reservado e para.

Verificar o número de linhas alteradas

Um UPDATE com WHERE status = active altera uma linha para o vencedor e zero linhas para o perdedor. A aplicação lê a contagem de linhas afetadas e devolve 409 Conflict ao perdedor, então o comprador vê uma mensagem clara em vez de um falso sucesso.

Deadlocks, reversões e novas tentativas

Duas transações que travam as mesmas linhas em ordens diferentes podem sofrer deadlock, e o MySQL reverte uma delas. Trave o carro primeiro, depois as ofertas e depois os pedidos, em todos os caminhos de código. Quando algo falha no meio, o ROLLBACK desfaz todas as mudanças dessa transação, e quem chamou pode tentar de novo com segurança usando a mesma chave de idempotência.

Exemplo 85: Dois compradores aceitam ao mesmo tempo

Trave a linha para que apenas uma transação vença.

-- Session A
START TRANSACTION;
SELECT status FROM car_listings WHERE id = 1 FOR UPDATE;
-- Session B waits here until A commits, then reads 'reserved' and stops.
UPDATE car_listings SET status = 'reserved' WHERE id = 1 AND status = 'active';
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 86: Verificar o número de linhas alteradas

A contagem 1 significa que esta requisição venceu; 0 significa que perdeu.

UPDATE car_listings SET status = 'reserved'
WHERE id = 1 AND status = 'active';
-- Application code: if ROW_COUNT() = 0, return 409 Conflict to the second buyer.
SELECT ROW_COUNT() AS won;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 87: Travar em uma ordem fixa

Todo caminho de código trava o carro antes das ofertas para evitar deadlocks.

-- Every code path locks in this order: car_listings, then offers, then orders
START TRANSACTION;
SELECT id FROM car_listings WHERE id = 1 FOR UPDATE;
SELECT id FROM offers WHERE listing_id = 1 FOR UPDATE;
COMMIT;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 88: Reverter quando o carro for tomado

Desfaça o pedido se a reserva falhar no meio do caminho.

START TRANSACTION;
UPDATE car_listings SET status = 'reserved' WHERE id = 2 AND status = 'active';
-- If the car was not reserved, nothing else should happen.
-- Application code: when ROW_COUNT() = 0, run ROLLBACK instead of inserting the order.
ROLLBACK;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 89: Chaves de idempotência

Uma requisição repetida com a mesma chave devolve o primeiro resultado.

CREATE TABLE idempotency_keys (
  key_value   CHAR(36) PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL,
  response    JSON NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB;
INSERT IGNORE INTO idempotency_keys (key_value, user_id)
VALUES ('5f3c2a10-0000-4000-8000-000000000001', 2);
-- ROW_COUNT() = 0 means the key already exists: return the stored response.

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 90: Um log de auditoria

Guarde quem alterou o quê, com os valores antigos e novos.

CREATE TABLE audit_log (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  actor_id     BIGINT UNSIGNED NULL,
  action       VARCHAR(60) NOT NULL,
  entity       VARCHAR(40) NOT NULL,
  entity_id    BIGINT UNSIGNED NOT NULL,
  detail       JSON NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_audit_entity (entity, entity_id, created_at)
) ENGINE=InnoDB;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 14: Relatórios e Análises

O negócio precisa de números: vendas por dia, valor de estoque por revenda e o funil de anúncio até a venda. Os relatórios leem as mesmas tabelas, então precisam usar os status corretos e nunca contar um pedido cancelado como receita.

Vendas diárias a partir de pedidos concluídos

Conte apenas pedidos concluídos como receita. Agrupe pelo dia em que o pedido foi criado, ou pelo dia em que foi concluído, se a contabilidade precisar disso. Explique a escolha no relatório, para que duas pessoas que leem o mesmo número concordem sobre o que ele significa.

Visões para revendas

Uma view entrega à revenda a quantidade de estoque, o valor do estoque e a média de dias anunciados em um só lugar. A view esconde as junções, então a aplicação consulta a view como uma tabela, e as regras ficam em uma única definição.

O funil de conversão e o painel

Conte os carros anunciados, os carros com pelo menos uma oferta e os pedidos concluídos. O painel mostra os números principais no topo e uma tabela de carros ativos embaixo, para que o vendedor veja primeiro o que precisa de atenção.

Exemplo 91: Um relatório diário de vendas

Valor bruto dos pedidos concluídos por dia.

SELECT DATE(o.created_at) AS day,
  COUNT(*) AS orders,
  SUM(o.final_price) AS gross_value
FROM orders o
WHERE o.status = 'completed'
GROUP BY DATE(o.created_at)
ORDER BY day DESC;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 92: Uma visão do estoque da revenda

Carros, valor do estoque e dias anunciados para cada revenda.

CREATE VIEW v_dealer_stock AS
SELECT d.id AS dealer_id,
  COUNT(l.id) AS cars,
  SUM(l.asking_price) AS stock_value,
  AVG(DATEDIFF(NOW(), l.created_at)) AS avg_days_listed
FROM dealers d
JOIN car_listings l ON l.seller_id = d.user_id
WHERE l.status = 'active'
GROUP BY d.id;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 93: Um funil de conversão

Conte carros anunciados, carros com ofertas e pedidos concluídos.

SELECT
  (SELECT COUNT(*) FROM car_listings WHERE status IN ('active','reserved','sold')) AS listed,
  (SELECT COUNT(DISTINCT listing_id) FROM offers) AS got_offers,
  (SELECT COUNT(*) FROM orders WHERE status = 'completed') AS completed;

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 94: Maquete: o painel do vendedor

Números principais e uma tabela de carros ativos.

<section class="dashboard">
  <div class="kpi"><span>Active cars</span><strong>12</strong></div>
  <div class="kpi"><span>Open offers</span><strong>3</strong></div>
  <div class="kpi"><span>Sold this month</span><strong>2</strong></div>
  <table>
    <tr><th>Car</th><th>Price</th><th>Offers</th><th>Days listed</th></tr>
    <tr><td>Camry 2019</td><td>฿765,000</td><td>2</td><td>14</td></tr>
  </table>
</section>

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Tópico 15: Produção: API, Segurança, Backup e Lançamento

Um banco de dados que funciona no notebook ainda não é um marketplace. A produção precisa de um contrato de API limpo, usuários de banco com privilégio mínimo, backups que possam ser restaurados, mudanças de esquema seguras e uma lista de verificação de lançamento que impeça uma versão ruim de chegar aos compradores.

Contrato da API e idempotência

Devolva os anúncios em um formato JSON estável, com um cursor para a próxima página. Exija o cabeçalho Idempotency-Key para ofertas e pedidos, para que uma nova tentativa após um tempo limite não crie um segundo pedido. Documente os valores de status, porque o app móvel e o app web precisam lê-los do mesmo jeito.

Privilégio mínimo, backup e migração

O usuário da aplicação pode ler, inserir e atualizar o banco de carros, mas não pode excluir pagamentos nem registros de auditoria. Faça o backup com um dump em transação única para não bloquear as gravações, e teste a restauração em outra máquina. Altere o esquema em pequenas etapas: adicione uma coluna que aceite NULL, preencha em lotes e só então aperte a regra em um lançamento posterior.

A lista de verificação de lançamento

Antes de cada lançamento, rode as verificações de negócio: nenhum carro ativo sem vistoria aprovada, e nenhum pedido em que o valor líquido pago seja maior que o preço final. Se alguma verificação falhar, o script de lançamento para, e uma regra quebrada nunca chega ao comprador.

Exemplo 95: A API pública de anúncios

O formato JSON da página de anúncios com cursor para a próxima página.

{
  "method": "GET",
  "path": "/api/v1/listings?province=Chonburi&max_price=900000&page_size=20",
  "response": {
    "items": [
      { "id": 1, "make": "Toyota", "model": "Camry", "trim": "G 2.5", "year": 2019, "mileage_km": 48250, "asking_price": 765000, "province": "Chonburi" }
    ],
    "next_cursor": "eyJpZCI6MX0"
  }
}

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 96: Fazer uma oferta pela API

Envie uma chave de idempotência para que as tentativas sejam seguras.

curl -X POST https://api.example.com/api/v1/listings/1/offers \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: 5f3c2a10-0000-4000-8000-000000000002" \
  -H "Content-Type: application/json" \
  -d '{"amount": 720000}'

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 97: Privilégio mínimo para a aplicação

O usuário da aplicação não pode excluir pagamentos nem registros de auditoria.

CREATE USER IF NOT EXISTS 'carmarket_app'@'%' IDENTIFIED BY '<strong-password>';
GRANT SELECT, INSERT, UPDATE ON carmarket.* TO 'carmarket_app'@'%';
-- No DELETE on orders, payments or audit_log: those are kept for legal reasons.
REVOKE DELETE ON carmarket.payments FROM 'carmarket_app'@'%';

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 98: Fazer backup sem bloquear gravações

Use um dump em transação única e teste a restauração.

mysqldump --single-transaction --routines --triggers carmarket \
  | gzip > carmarket-$(date +%F).sql.gz
# Restore test on another machine:
gunzip -c carmarket-2026-10-30.sql.gz | mysql carmarket_restore_test

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 99: Uma mudança de schema segura

Adicione uma coluna que aceite NULL, preencha em lotes e só depois restrinja.

-- Step 1: add the column as nullable (old code keeps working)
ALTER TABLE car_listings ADD COLUMN accident_free TINYINT(1) NULL;
-- Step 2: backfill in batches, not in one huge UPDATE
UPDATE car_listings SET accident_free = 1 WHERE id BETWEEN 1 AND 1000 AND accident_free IS NULL;
-- Step 3 (later release): set NOT NULL after every row has a value.

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Exemplo 100: Uma lista de verificação de lançamento das regras de negócio

Interrompa o lançamento quando uma regra, como o total líquido pago, for quebrada.

#!/usr/bin/env bash
set -euo pipefail
# 1. No active listing without a passing inspection
mysql -N carmarket -e "SELECT COUNT(*) FROM car_listings l WHERE l.status='active' AND NOT EXISTS (SELECT 1 FROM inspections i WHERE i.listing_id=l.id AND i.overall_score>=3)" | grep -q '^0$'
# 2. Net paid never exceeds the final price
mysql -N carmarket -e "SELECT COUNT(*) FROM orders o JOIN (SELECT order_id, SUM(amount) s FROM payments WHERE status='succeeded' AND kind IN ('deposit','balance') GROUP BY order_id) p ON p.order_id=o.id WHERE p.s > o.final_price" | grep -q '^0$'
echo "business checks passed"

*Este exemplo não foi executado aqui, porque o MySQL não estava instalado no computador que preparou este artigo.*

Laboratório: Construir e Testar o Banco de Dados do Marketplace de Carros

Reúna o artigo em um banco de testes: crie as tabelas na ordem certa, insira carros e usuários de exemplo, leve uma oferta pela aceitação e pelo fluxo do pedido, simule dois compradores ao mesmo tempo e termine com a lista de verificação de lançamento.

1. Crie o banco carmarket com utf8mb4 e depois crie as tabelas users, dealers, car_makes, car_models e car_trims, nessa ordem.

2. Insira os usuários de exemplo, uma revenda, as marcas e modelos e uma versão, e depois confira a contagem de linhas com SELECT COUNT(*).

3. Crie car_listings, car_images, inspections e inspection_items, e insira um anúncio com fotos e uma vistoria aprovada.

4. Publique o anúncio somente pela regra que exige vistoria aprovada, e confirme que um anúncio sem vistoria continua aguardando análise.

5. Crie offers e orders, aceite uma oferta dentro de uma transação e confira o histórico de status do pedido.

6. Abra duas sessões do MySQL, trave o mesmo carro nas duas e mostre que apenas uma reserva tem sucesso.

7. Rode a lista de verificação de lançamento do exemplo 100 sobre os dados de teste e corrija qualquer regra quebrada antes de seguir em frente.

Próximos passos

Depois do laboratório, leia guias de modelagem de banco de dados para a sua pilha, acrescente as telas do app móvel a partir das maquetes e teste cada transação com duas sessões ao mesmo tempo antes de lançar. Guarde os scripts em um repositório e execute-os de novo após cada mudança de esquema, porque as regras do marketplace vão crescer junto com o negócio.

Resumo

Um marketplace de carros usados é um conjunto de regras que o banco de dados precisa manter: uma venda por carro, uma vistoria antes da publicação, um sinal retido até a transferência ser concluída e um livro-razão que sempre fecha. Você aprendeu a escrever as regras primeiro, a modelar usuários, revendas e o catálogo de carros, a publicar anúncios com fotos e vistorias, a buscar com índices e paginação por chave, a tratar ofertas e pedidos com fluxos de status claros, a registrar pagamentos como livro-razão, a acompanhar cada etapa da transferência de propriedade, a incluir financiamento e garantias, a construir confiança com avaliações e denúncias, a lidar com condições de corrida com travas e idempotência, a produzir relatórios de negócio e a lançar com segurança.