二手车交易平台数据库设计:表结构、示例数据、事务、应用原型,从零到专业的 100 个示例

二手车交易平台数据库设计:从零到专业的 100 个示例

二手车交易平台把买家、卖家和经销商连接在一个高价值、独一无二、且必须依法过户的资产周围。每辆车都有车架号(VIN)、里程历史、检测报告,以及必须正确变更车主的登记信息。本文通过 100 个示例,把这样一个平台的数据库从空白模式设计到面向生产环境的发布。你将梳理业务规则,创建用户和经销商表,建立包含品牌、车型和配置版本的车辆目录,发布带照片的车源,记录检测与里程,建立搜索索引,处理报价与还价,设计状态流转清晰的订单,把支付记录为账本,完成带文件的过户步骤,处理贷款与质保,通过评价和举报建立信任,并用事务保证这一切在并发使用下保持一致。每个部分都包含示例数据、事务运行时内部发生的情况,以及使用该部分的应用界面原型。需要坦诚说明:准备本文所用的计算机上没有安装 MySQL,因此这 100 个示例都没有在这里实际运行。SQL 遵循 MySQL 8.4 语法,但在依赖它们之前,请你自己在测试数据库上运行每个部分。

需要准备的东西

MySQL 8.4(或用 Docker 在容器中运行)、MySQL Workbench 等 SQL 客户端或 mysql 命令行工具,以及对表、键和连接的基本理解。请使用虚构的人名、邮箱和车架号,切勿使用真实客户数据,也不要在脚本中粘贴真实密码、API 密钥或银行信息。

如何使用示例

SQL 示例在测试数据库中按给出的顺序于 MySQL 8.4 上运行。HTML 示例是可在浏览器中打开的界面原型。JSON 示例展示 API 的数据结构,bash 示例使用 mysql 和 curl 命令。

知识点 1: 业务规则与环境设置

从规则开始,而不是从表开始。谁可以发布车源,谁可以购买,车源上线前必须满足什么条件,以及绝对不能发生什么,例如同一辆车卖两次。数据库负责强制执行这些规则,因此应用中的缺陷无法破坏它们。

参与者及其需求

买家搜索、收藏并出价。卖家发布车源并回复报价。经销商发布大量车源,并拥有认证标识。检测员为车辆打分,管理员审核举报。每种角色都需要自己的权限和页面。

数据库必须保证的规则

一辆车在同一时间只能卖给一位买家。只有通过检测后,车源才能上线。定金会被保留,直到过户完成。每一次价格和状态变更都会被记录。先把这些规则写下来,再决定哪些由 CHECK 约束、外键和事务来强制执行。

字符集与数据库

使用 utf8mb4 创建数据库,使泰文、表情符号和姓名能按输入原样保存。所有表都应使用相同的字符集,因为混用编码会导致乱码和比较错误。

示意图

买家购买二手车时发生的事情:报价被接受并预留车辆,定金记入账本,带文件的过户步骤逐一完成,只有到这时车辆才被标记为已售
买家购买二手车时发生的事情:报价被接受并预留车辆,定金记入账本,带文件的过户步骤逐一完成,只有到这时车辆才被标记为已售

示例 1: 先写下业务规则

谁能做什么,以及数据库必须强制执行哪些规则。

{
  "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"
  ]
}

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 2: 创建数据库

使用 utf8mb4,使泰语和表情符号能被正确保存。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 3: 用户表

买家、卖家、经销商和员工共用一张账户表。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 4: 示例用户

带占位密码哈希的虚构账户。

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');

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 5: 经销商表

把经营信息与登录账户分开保存。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 6: 示例经销商

把已认证的企业关联到其用户账户。

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]';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 2: 用户、经销商与地址

每个登录的人在 users 表中都有一行,并带有角色。经销商的额外经营信息保存在自己的表中,地址则让搜索能按省份筛选车辆。把这些分开,可以让每张表都更小,每条规则也更容易检查。

带角色的单一账户表

users 表保存邮箱、密码哈希和角色。卖家、买家、经销商、检测员或管理员等角色决定允许访问哪些页面和执行哪些操作。切勿存储明文密码,只保存 bcrypt 哈希。

作为独立表的经销商

经销商是完成工商登记的用户,因此公司名称和税号保存在 dealers 表中,并通过外键指向 users。只有工作人员核对文件后才会设置 is_verified 标志,只有通过验证的经销商才会显示徽章。

地址与偏好

省份保存在每个地址和每条车源上,并建立索引,使按省份搜索速度很快。预算和燃料类型等买家偏好与账户分开保存,因此修改偏好不必触及账户本身。

示例 7: 地址

保存位置,使搜索可以按省份筛选。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 8: 示例地址

一位位于春武里的卖家的地址。

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]';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 9: 买家偏好

记录预算和偏好的燃料类型。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 10: 登录会话

让会话过期,并随用户一起删除。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 11: 汽车品牌

每个品牌一行,名称唯一。

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');

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 12: 车型

每个车型只属于一个品牌。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 13: 示例车型

通过与品牌表连接来插入车型。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 14: 配置版本(Trim)

同一车型有不同发动机的多个版本。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 3: 车辆目录:品牌、车型与配置版本

目录只保存一次车型的事实信息,而不是在每个车源上重复。一个品牌有多个车型,一个车型有多个配置版本,而配置版本包含买家真正比较的发动机、燃料和变速箱。

品牌、车型、配置版本

Toyota 这样的品牌拥有 Camry 这样的车型。G 2.5 这样的配置版本是该车型的一个版本,拥有自己的发动机和变速箱。同一车型在不同年份可能有多个配置版本,因此年份范围应归属于配置版本。

选装配置与多对多关联

天窗或后置摄像头等选装配置并非每个配置版本都有。关联表把选装配置与配置版本连接起来,因此一个选装配置可以出现在多个配置版本中,一个配置版本也可以拥有多个选装配置。

为什么目录很重要

卖家选择配置版本后,发动机、燃料和座位数都来自目录。卖家无法输错这些信息,而且由于值是以代码形式保存,而不是自由文本,按燃料或变速箱的搜索也能可靠地工作。

示例 15: 目录中的配置版本

汽油自动挡,适用于某个年款范围。

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';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 16: 混动配置版本

CVT 变速箱的混动版本,没有结束年份。

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';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 17: 选装配置

天窗、摄像头、车道辅助和真皮座椅,以代码表示。

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');

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 18: 配置版本的选装项

用多对多表把选装项与配置版本关联。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 19: 车辆挂牌

每辆实际出售的车一行,并带有 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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 20: 示例在售挂牌

在春武里出售的 2019 款凯美瑞。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 21: 车辆照片

保存照片 URL,并使用固定的显示顺序。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 22: 示例照片

首个挂牌的三张照片。

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);

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 4: 车源与照片

车源就是一辆待售的实车。它有自己的车架号、里程、颜色、价格和状态。照片保存在一张单独的表中,并有固定顺序,因此第一张照片总是正面照,图库每次都按同样的顺序加载。

每辆实车一行

每辆车都有唯一的车架号,因此同一辆车不能被发布两次。状态会从草稿变为待审核,再变为在售、已预留、已售出或已下架。CHECK 规则可防止保存负价格或负里程。

安全地修改车源

只有卖家可以修改价格。把卖家身份检查放在与修改相同的 UPDATE 语句中,使检查与写入之间没有空隙。状态只能沿允许的步骤变化,并且每次变化都要记录。

照片与详情页

保存照片的 URL 及其位置,而不是图片文件。详情页用一次查询同时加载车辆、卖家和照片。原型展示了该结果在手机和桌面上的排版方式。

示例 23: 草稿挂牌

卖家在上架前先创建车辆信息。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 24: 以车主身份修改价格

在同一条语句中检查卖家身份。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 25: 推进挂牌状态

只允许从上一个步骤变更状态。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 26: 某省的在售挂牌

与目录表连接,显示品牌、车型和配置版本。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 27: 挂牌详情页

在一条查询中加载车辆、照片和卖家。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 28: 原型:搜索结果卡片

包含价格、规格和出价按钮的车辆卡片。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 29: 原型:详情页

图库、摘要和操作集中在一个屏幕上。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 30: 原型:颜色与间距

将设计令牌定义为 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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 5: 检测与车况

二手车的价值只取决于它的车况。检测员为车辆打分,对各个部件评级,并附上报告。车源只有在检测通过后才会上线,因此买家看到的事实与检测员记录的完全一致。

检测与检测项目

一次检测包含 0 到 5 分的总分和一份 PDF 报告。发动机、刹车或轮胎等每个部件都有自己的一行,并给出良好、一般、需维修或需更换的等级。等级解释了总分,因此买家能看出问题出在哪里。

里程记录

保存卖家、检测员和保养记录提供的每一个里程读数。如果新读数低于旧读数,里程表可能被调低,因此系统会标记该车源以便复核,而不是显示错误的数字。

检测后再发布

只有存在得分至少为 3.0 的合格检测,车源才会转为在售。这条规则同时存在于数据库和应用中,因此任何代码路径都无法发布未经检测的车辆。

示例 31: 车辆检测

获得认证的检测员为每辆车打分。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 32: 检测示例

5 分制得 4.5 分,附 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]';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 33: 检测项目

为每个部件评定:良好、一般、需维修或需更换。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 34: 检测项目示例

刹车片剩余 40%,车门有划痕。

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');

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 35: 找出需要维修的部件

统计每个挂牌的问题数量。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 36: 检测合格后才发布

只有分数达标的挂牌才会上线。

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);

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 37: 原型:检测报告

挂牌页面上显示的各项评级。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 38: 里程记录

保留每一次读数,以发现被调回的里程表。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 6: 搜索、筛选与性能

买家按省份、价格、年份、燃料和变速箱搜索,并期望在远不到一秒内得到结果。在 WHERE 和 ORDER BY 中出现的列上建立索引使这成为可能,而键集分页让深层页面也保持快速。

与查询匹配的索引

在状态、省份、价格和年份上建立的复合索引支持常见搜索。列的顺序很重要:把等值条件的列放在前面,把范围条件的列放在最后。EXPLAIN 显示是否使用了索引,EXPLAIN ANALYZE 显示真实耗时。

全文搜索与关联筛选

描述字段上的 FULLTEXT 索引支持天窗、真皮等词语。燃料和变速箱保存在配置版本表中,因此搜索需要关联该表,而关联会使用配置版本的索引。

键集分页与已保存的搜索

OFFSET 会读取并丢弃之前的所有行,因此越深的页面越慢。键集分页记住最后的创建时间和 id,并从那里继续。已保存的搜索把筛选条件以 JSON 形式保存,使买家能收到符合条件的新车源通知。

示例 39: 搜索索引

把状态、省份、价格和年份组合建立索引。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 40: 全文搜索

用 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);

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 41: 按燃料和变速箱筛选

连接配置版本表,查找 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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 42: 键集分页

无需代价高昂的 OFFSET 即可翻页。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 43: 已保存的搜索

以 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]';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 44: 原型:筛选侧边栏

品牌、价格、年份和燃料筛选,以及显示按钮。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 45: 按品牌统计车辆数

在每个筛选选项旁显示数量。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 46: 检查查询计划

使用 EXPLAIN ANALYZE 查看是否使用了索引。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 7: 报价与议价

报价是买家对某辆车提出的价格建议,并带有过期时间。标价保持不变,因此多位买家可以报价,卖家可以接受其中一个、拒绝其他的,或以新价格还价。

报价作为独立的表

一条报价是一行,包含车源、买家、金额、状态和过期时间。卖家的还价是一条新行,指向买家的报价,因此完整的议价过程都保留在历史记录中。

最低价与检查

卖家可以设置最低价。在把报价显示为可接受之前,先将其与最低价比较。连同理由一起保存决定,以便日后回应争议。

过期与原型

一个定时任务会把旧报价标记为已过期。出价对话框显示过期时间,并说明卖家可以做什么,让买家知道接下来会发生什么。

示例 47: 出价

买家提出价格,标价保持不变。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 48: 示例出价

买家在 48 小时内出价 720,000 泰铢。

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]';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 49: 原型:出价对话框

金额输入框、有效期说明和发送按钮。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 50: 拒绝过低的出价

将出价与卖家可接受的最低价比较。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 51: 在一个事务中接受报价

同时锁定车辆、接受报价并预留挂牌。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 52: 拒绝其他报价

一个报价成交后,同一辆车的其他报价全部关闭。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 53: 让旧报价过期

定时任务会关闭超过截止时间的报价。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 54: 卖家的还价

把卖家的还价保存为与买家报价相关联的新报价。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 8: 订单与状态流转

卖家接受报价时,会创建一笔订单。订单按固定的一组状态推进,每次变化都会被记录,因此支持人员总能看到一笔交易是如何达到当前状态的。

每辆车只有一笔已确认的订单

orders 表的 listing_id 列带有 UNIQUE 约束,因此即使两个请求同时到达,数据库也会拒绝同一辆车的第二笔订单。应付定金根据最终价格计算,这里是 10%。

在同一事务中创建订单

接受报价、预留车源和插入订单都在一个事务中完成。如果任何一步失败,全部回滚,因此不会出现没有订单的预留车辆,也不会出现没有预留车辆的订单。

状态历史与允许的步骤

订单的每次变更都会写入历史表,包括旧状态、新状态、操作人和时间。UPDATE 会检查当前状态,因此订单不能从待付定金直接跳到已完成。

示例 55: 订单表

每辆车一个已确认的订单,包含最终价格和定金。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 56: 根据已接受的报价创建订单

在接受报价的同一事务中插入订单。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 57: 订单状态历史

记录每次状态变化,以及执行人和时间。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 58: 将订单推进一步

只允许从预期的步骤变更状态。

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);

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 59: 取消订单并释放车辆

订单在早期取消时,将挂牌恢复为在售。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 60: 原型:订单进度页面

从报价被接受到交易完成的各个步骤。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 9: 以账本形式记录支付

金额永远不会保存为一个可以被反复修改的数字。每一笔支付、退款和尾款都是账本中的一行。合计从这些行计算得出,因此历史记录始终完整,数字始终对得上。

每次资金变动一行

一笔支付包含类型(如定金、尾款或退款)、金额、支付服务商的引用编号和状态。支付服务商引用编号是 UNIQUE 的,因此同一笔支付无法被记录两次,并且该行与银行或支付服务商自己的记录相关联。

会重复到达的回调

支付服务商可能会多次发送同一通知。只有当该行仍处于待处理状态时,更新才会改变状态,因此第二条消息不会产生任何变化。这使得处理程序在重试时是安全的。

退款与已付净额

退款是一行新记录,而不是覆盖原有记录的负数支付。订单的已付净额是成功支付之和减去成功退款之和。如果已付净额超过最终价格,发布前检查会阻止本次发布。

示例 61: 支付表

每一笔资金变动都是一行,并带有支付商的引用编号。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 62: 在调用支付前记录定金

先将支付记为待处理,之后再更新。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 63: 处理重复的支付回调

只更新一次,因此重复的消息不会产生任何变化。

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';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 64: 每个订单的已付净额

从账本中累加定金和尾款,减去退款。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 65: 退还定金

插入一条与原定金关联的退款记录。

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';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 66: 原型:定金支付页面

定金金额、冻结说明和支付按钮。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 10: 所有权过户与文件

在许多国家,车辆只能在运输部门凭签署的文件才能更换车主。数据库把这一过程建模为一系列步骤,每一步都有自己的文件,因此买家可以准确看到还剩什么,工作人员则可以在车辆被标记为已售之前核验每一份文件。

步骤作为行

每个过户步骤都有编号、名称和状态:待办、进行中、已完成或已阻塞。只有当所有步骤都已完成时,订单才会被标记为完成,这一检查与把车辆标记为已售处于同一事务中。

文件与核验

每份文件都有类型(如买卖合同或登记簿)、文件 URL、上传人以及核验时间。只有当某步骤所需的文件已核验,该步骤才能标记为完成,因此仅有一张未经核验的表格照片是不够的。

买家的清单

原型为买家展示一份带勾选框和上传按钮的清单。该清单由步骤表生成,因此界面与数据库始终一致地反映哪些已经完成。

示例 67: 过户步骤

车辆所有权变更的每一步各占一行。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 68: 订单的标准步骤

从买卖合同到交付钥匙的五个步骤。

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');

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 69: 文件

保存每个上传的文件,并记录类型和核验时间。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 70: 只有文件通过核验才完成该步骤

在标记步骤完成之前先检查文件。

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);

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 71: 原型:过户清单

每个步骤的复选框以及上传按钮。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 72: 最后才变更所有权

只有所有步骤都完成,订单才会关闭。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 11: 贷款与质保

大多数买家不会一次性全款付清。银行贷款是与订单关联的一份申请,而质保则在销售后的若干个月或若干公里内为买家提供保障。

贷款申请

贷款申请属于某笔订单,记录银行、金额、期限和状态:已提交、已批准、已拒绝或已撤回。期限由 CHECK 规则限制,例如 6 到 84 个月,因此无法录入不合理的贷款条件。

从销售日期开始的质保

只有当订单完成时才会创建质保。它保存月数、公里数上限以及开始日期。判断质保是否仍然有效时,需要同时比较日期和里程,这样买家得到的答案才是正确的。

清晰地展示报价

订单页面以估算的形式显示月供,并用通俗的语言说明质保条款。清晰的信息可以减少售后争议,这比事后处理投诉更划算。

示例 73: 贷款申请

买家向银行申请购车贷款。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 74: 申请示例

申请 60 个月、600,000 泰铢的贷款。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 75: 质保

每个订单一份质保,包含月数和公里数。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 76: 交易完成时开始质保

从已完成的订单创建质保。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 77: 检查质保是否仍然有效

将日期和里程与限制进行比较。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 78: 原型:贷款与质保信息框

订单页面上的预计月供和质保条款。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 12: 评价、举报与信任

只有当人们信任平台时,交易市场才能运转。评价在交易完成后产生,举报让用户标记问题,而隐藏车源的规则必须清晰、公平,不能让一个愤怒的用户就能隐藏一辆好车。

交易完成后的评价

只有已完成的订单才能评价,每个人对每笔订单只能评价一次。评分由 CHECK 规则限制在 1 到 5 之间。平均分只有在积累三条评价后才显示,因此一条评价不会让卖家显得完美或极差。

多人举报

举报会说明问题所在,例如价格造假或里程错误。在隐藏车源之前,先统计不重复的举报人数,这样一个人无法单独隐藏一辆车。被隐藏的车源会进入工作人员的复核队列。

评价与举报操作的原型

评价表单要求填写星级和评论,举报链接清晰可见但不会碍事。好的设计让举报变得容易,而误发评价则变得困难。

示例 79: 交易完成后的评价

每个订单每人一条评价,评分 1 到 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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 80: 为已完成的订单添加评价

只有已完成的订单才能接收评价。

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';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 81: 卖家的平均评分

只有在评价达到三条后才显示卖家的评分。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 82: 挂牌举报

买家和卖家都可以举报可疑的挂牌。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 83: 多次举报后隐藏挂牌

采取行动前先统计不重复的举报人数。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 84: 原型:评价表单与举报按钮

星级评分、评论框和举报链接。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 13: 事务实战:竞态条件

两位买家可能同时点击接受。如果不加小心,两个请求都会读到车辆仍在售,然后两人都把这辆车卖了。本主题展示事务、行锁、加锁顺序和幂等键如何防止这类问题,以及真实系统中的其他故障。

没有加锁时会发生什么

会话 A 读到车辆在售。会话 B 读到相同的状态。两者都把它更新为已预留,两者都创建了订单。这辆车被卖了两次。使用 SELECT FOR UPDATE 对车辆行加锁,会让第二个会话等待第一个提交,然后第二个会话看到已预留并停止。

检查受影响的行数

带有 WHERE status = active 的 UPDATE 对获胜者更新 1 行,对失败者更新 0 行。应用程序读取受影响的行数,并向失败者返回 409 Conflict,因此买家看到的是清楚的提示,而不是虚假的成功。

死锁、回滚与重试

两个以不同顺序锁定相同行的事务可能发生死锁,MySQL 会回滚其中一个。在每条代码路径中,都应先锁车辆,再锁报价,最后锁订单。当中途出现失败时,ROLLBACK 会撤销该事务的所有变更,调用方可以使用相同的幂等键安全地重试。

示例 85: 两位买家同时接受

锁定该行,使只有一个事务成功。

-- 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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 86: 检查受影响的行数

计数为 1 表示本次请求成功,为 0 表示失败。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 87: 按固定顺序加锁

所有代码路径都先锁车辆再锁报价,以避免死锁。

-- 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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 88: 车辆被他人拿走时回滚

预留在中途失败时撤销订单。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 89: 幂等键

使用相同键的重复请求会返回第一次的结果。

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.

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 90: 审计日志

记录谁修改了什么,以及修改前后的值。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 14: 报表与分析

业务需要数字:每日销量、每家经销商的库存价值,以及从挂牌到售出的转化漏斗。报表读取的是同样的表,因此必须使用正确的状态,且绝不能把已取消的订单计入收入。

基于已完成订单的每日销量

收入只统计已完成的订单。可以按订单创建的日期分组,如果财务需要,也可以按完成日期分组。在报表上说明这一选择,使阅读同一数字的两个人对其含义有一致的理解。

经销商视图

视图一次性向经销商展示库存数量、库存价值以及平均在售天数。视图隐藏了关联细节,因此应用程序可以像查询普通表一样查询它,而规则都集中在一处定义中。

转化漏斗与仪表盘

统计已挂牌车辆、至少收到一份报价的车辆以及已完成的订单。仪表盘顶部显示关键数字,下方是在售车辆的表格,因此卖家能先看到需要优先关注的内容。

示例 91: 每日销售报表

每天已完成订单的总金额。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 92: 经销商库存视图

每个经销商的车辆数、库存金额和挂牌天数。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 93: 转化漏斗

统计已挂牌的车、收到报价的车和已完成的订单。

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;

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 94: 原型:卖家仪表盘

关键数字和在售车辆表格。

<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>

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

知识点 15: 生产环境:API、安全、备份与发布

在笔记本电脑上能运行的数据库还不是一个可以投入使用的市场。生产环境需要清晰的 API 约定、最小权限的数据库用户、可恢复的备份、安全的表结构变更,以及在糟糕的发布到达买家之前将其拦下的发布检查清单。

API 约定与幂等性

以稳定的 JSON 结构返回车源列表,并附带下一页的游标。对报价和订单要求提供 Idempotency-Key 请求头,使超时后的重试不会创建第二笔订单。记录状态值的含义,因为移动端应用和网页应用必须以相同方式读取它们。

最小权限、备份与迁移

应用用户可以读取、插入和更新车辆数据库,但不能删除支付记录或审计记录。使用单事务转储进行备份,使写入不被阻塞,并在另一台机器上测试恢复。以小步骤变更表结构:先添加可为空的列,分批回填数据,然后在之后的发布中收紧规则。

发布检查清单

每次发布前运行业务检查:不能有未通过检测却在售的车辆,也不能有净已付金额超过最终价格的订单。如果任何检查失败,发布脚本会停止,因此被破坏的规则永远不会到达买家。

示例 95: 公开挂牌 API

挂牌页面的 JSON 结构,并带有下一页的游标。

{
  "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"
  }
}

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 96: 通过 API 出价

发送幂等键,使重试是安全的。

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}'

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 97: 应用的最小权限

应用用户不能删除支付或审计记录。

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'@'%';

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 98: 不阻塞写入的备份

使用单事务转储,并测试恢复。

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

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 99: 安全的表结构变更

先添加可为空的列,分批回填,之后再收紧约束。

-- 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.

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

示例 100: 业务规则发布检查清单

当净已付金额等规则被破坏时,停止发布。

#!/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"

*此示例未在本机运行,因为准备本文的计算机没有安装 MySQL。*

实验:搭建并测试二手车平台数据库

在测试数据库上把本文串起来:按顺序创建表,插入示例车辆和用户,让一个报价经历接受和订单流程,模拟两位买家同时操作,最后以发布检查清单收尾。

1. 使用 utf8mb4 创建 carmarket 数据库,然后按顺序创建 users、dealers、car_makes、car_models 和 car_trims 表。

2. 插入示例用户、一个经销商、品牌和车型,以及一个配置版本,然后用 SELECT COUNT(*) 检查行数。

3. 创建 car_listings、car_images、inspections 和 inspection_items,然后插入一条带照片且检测合格的车源。

4. 只通过要求检测合格的规则发布车源,并确认没有检测的车源仍处于待审核状态。

5. 创建 offers 和 orders,在事务中接受一个报价,并检查订单的状态历史。

6. 打开两个 MySQL 会话,在两个会话中锁定同一辆车,并证明只有一次预留能够成功。

7. 用测试数据运行示例 100 中的发布检查清单,并在继续之前修复任何被破坏的规则。

下一步

完成实验后,请阅读适用于你所用技术栈的数据库设计指南,参照原型添加移动端应用界面,并在上线前用两个会话同时测试每一个事务。把脚本保存在代码仓库中,每次修改表结构后都重新运行,因为随着业务增长,平台的规则也会越来越多。

总结

二手车交易平台是一组数据库必须守住的规则:一辆车只能卖一次,发布前必须检测,定金在过户完成前被保留,账本的合计始终正确。你学会了先写规则,建模用户、经销商和车辆目录,发布带照片和检测的车源,用索引和键集分页进行搜索,用清晰的状态流转处理报价和订单,把支付记录为账本,跟踪过户的每个步骤,加入贷款和质保,通过评价和举报建立信任,用锁和幂等性处理竞态条件,制作业务报表,并安全地发布。