ออกแบบฐานข้อมูลตลาดซื้อขายรถยนต์มือสอง: ตาราง ข้อมูลตัวอย่าง ธุรกรรม และต้นแบบหน้าแอป 100 ตัวอย่างตั้งแต่ศูนย์ถึงระดับมืออาชีพ

ออกแบบฐานข้อมูลตลาดซื้อขายรถยนต์มือสอง: 100 ตัวอย่างตั้งแต่ศูนย์ถึงระดับมืออาชีพ

ตลาดรถยนต์มือสองเชื่อมผู้ซื้อ ผู้ขาย และดีลเลอร์เข้าด้วยกันรอบสินทรัพย์ที่มีราคาสูง มีเอกลักษณ์เฉพาะ และต้องโอนกรรมสิทธิ์ตามกฎหมาย รถทุกคันมีเลขตัวถัง ประวัติเลขไมล์ รายงานการตรวจสภาพ และทะเบียนที่ต้องเปลี่ยนเจ้าของให้ถูกต้อง บทความนี้ออกแบบฐานข้อมูลของตลาดแบบนี้ตั้งแต่สคีมาว่างจนถึงการปล่อยที่คำนึงถึงโปรดักชัน ใน 100 ตัวอย่าง คุณจะเริ่มจากการระบุกฎทางธุรกิจ สร้างตารางผู้ใช้และดีลเลอร์ แคตตาล็อกรถที่มียี่ห้อ รุ่น และรุ่นย่อย ประกาศขายพร้อมรูปภาพ การตรวจสภาพและบันทึกเลขไมล์ ดัชนีการค้นหา ข้อเสนอและข้อเสนอตอบกลับ คำสั่งซื้อที่มีลำดับสถานะชัดเจน การชำระเงินที่บันทึกเป็นบัญชีแยกประเภท ขั้นตอนการโอนกรรมสิทธิ์พร้อมเอกสาร สินเชื่อและการรับประกัน รีวิวและการรายงาน และทรานแซกชันที่ทำให้ทุกอย่างสอดคล้องกันเมื่อมีการใช้งานพร้อมกัน แต่ละส่วนมีข้อมูลตัวอย่าง คำอธิบายว่าเกิดอะไรขึ้นภายในทรานแซกชันเมื่อรัน และต้นแบบหน้าจอแอปที่ใช้ส่วนนั้น หมายเหตุตามจริง: MySQL ไม่ได้ติดตั้งบนเครื่องที่ใช้เตรียมบทความนี้ จึงไม่ได้รันตัวอย่างทั้ง 100 ข้อที่นี่ SQL เป็นไปตามไวยากรณ์ของ MySQL 8.4 แต่คุณควรรันเองบนฐานข้อมูลทดสอบก่อนนำไปใช้

สิ่งที่ต้องเตรียม

MySQL 8.4 (หรือ Docker สำหรับรันในคอนเทนเนอร์) โปรแกรมไคลเอนต์ SQL เช่น MySQL Workbench หรือเครื่องมือบรรทัดคำสั่ง mysql และความเข้าใจพื้นฐานเรื่องตาราง คีย์ และการ join ใช้ชื่อคน อีเมล และเลขตัวถัง (VIN) ที่เป็นตัวอย่าง ห้ามใช้ข้อมูลลูกค้าจริง และห้ามวางรหัสผ่าน คีย์ 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: ประกาศขายและรูปภาพ

ประกาศคือรถจริงหนึ่งคันที่วางขาย มีเลขตัวถัง เลขไมล์ สี ราคา และสถานะของตัวเอง รูปภาพอยู่ในตารางแยกที่มีลำดับคงที่ ดังนั้นรูปแรกจะเป็นด้านหน้าเสมอ และแกลเลอรีโหลดตามลำดับเดิมทุกครั้ง

หนึ่งแถวต่อรถจริงหนึ่งคัน

รถแต่ละคันมีเลขตัวถัง (VIN) ที่ไม่ซ้ำ จึงไม่สามารถลงประกาศรถคันเดียวกันสองครั้ง สถานะเปลี่ยนจากฉบับร่างไปรอตรวจ แล้วไปเป็นที่ขายอยู่ สงวนไว้ ขายแล้ว หรือถูกลบ กฎ 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: ตัวอย่างการตรวจสภาพ

คะแนน 4.5 จาก 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 ทำให้เป็นไปได้ และการแบ่งหน้าแบบ keyset ทำให้หน้าลึกๆ ยังเร็ว

ดัชนีที่ตรงกับคิวรี

ดัชนีรวมบนสถานะ จังหวัด ราคา และปี รองรับการค้นหาทั่วไป ลำดับคอลัมน์สำคัญ ให้วางคอลัมน์ที่เท่ากันไว้ก่อน และคอลัมน์ช่วงไว้ท้ายสุด EXPLAIN แสดงว่าใช้ดัชนีหรือไม่ และ EXPLAIN ANALYZE แสดงเวลาจริง

การค้นหาข้อความเต็มและตัวกรองจากการ join

ดัชนี FULLTEXT บนคำอธิบายรองรับคำเช่น ซันรูฟหรือหนัง เชื้อเพลิงและเกียร์อยู่ในตารางรุ่นย่อย การค้นหาจึง join ตารางนั้น และการ join ใช้ดัชนีของรุ่นย่อย

การแบ่งหน้าแบบ keyset และการค้นหาที่บันทึกไว้

OFFSET อ่านทุกแถวก่อนหน้าแล้วทิ้ง หน้าลึกจึงช้าลง การแบ่งหน้าแบบ keyset จำเวลาที่สร้างและ 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: การแบ่งหน้าแบบ keyset

เลื่อนดูผลลัพธ์โดยไม่ต้องใช้ 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: ข้อเสนอราคาตัวอย่าง

ผู้ซื้อเสนอ 720,000 บาทภายใน 48 ชั่วโมง

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: คำสั่งซื้อและลำดับสถานะ

เมื่อผู้ขายรับข้อเสนอ จะสร้างคำสั่งซื้อ คำสั่งซื้อเดินผ่านชุดสถานะที่กำหนดไว้ และทุกการเปลี่ยนแปลงถูกบันทึก ทำให้ทีมสนับสนุนเห็นเสมอว่าการขายไปถึงสถานะปัจจุบันได้อย่างไร

หนึ่งคำสั่งซื้อที่ยืนยันแล้วต่อรถหนึ่งคัน

คอลัมน์ listing_id ในตาราง orders มีข้อจำกัด 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: การชำระเงินในรูปบัญชีแยกประเภท

เงินจะไม่ถูกเก็บเป็นตัวเลขเดียวที่ถูกแก้ไขไปมา การชำระ การคืนเงิน และยอดคงเหลือแต่ละรายการเป็นแถวในบัญชีแยกประเภท ยอดรวมคำนวณจากแถวเหล่านั้น ประวัติจึงสมบูรณ์เสมอและตัวเลขบวกกันถูกต้องเสมอ

หนึ่งแถวต่อการเคลื่อนไหวของเงิน

การชำระมีประเภท เช่น มัดจำ ยอดคงเหลือ หรือคืนเงิน มีจำนวนเงิน รหัสอ้างอิงจากผู้ให้บริการ และสถานะ รหัสอ้างอิงของผู้ให้บริการไม่ซ้ำ จึงบันทึกการชำระเดิมซ้ำไม่ได้ และเชื่อมแถวนั้นกับบันทึกของธนาคารหรือผู้ให้บริการ

Webhook ที่มาสองครั้ง

ผู้ให้บริการชำระเงินอาจส่งการแจ้งเตือนเดิมมากกว่าหนึ่งครั้ง การอัปเดตเปลี่ยนสถานะเฉพาะเมื่อแถวยังรอดำเนินการอยู่ ข้อความที่สองจึงไม่เปลี่ยนอะไร ทำให้ตัวจัดการปลอดภัยเมื่อลองใหม่

การคืนเงินและยอดสุทธิที่ชำระ

การคืนเงินเป็นแถวใหม่ ไม่ใช่การชำระแบบติดลบที่ทับของเดิม ยอดสุทธิที่ชำระของคำสั่งซื้อคือผลรวมของการชำระที่สำเร็จลบด้วยการคืนเงินที่สำเร็จ และการตรวจก่อนปล่อยจะหยุดการปล่อยหากยอดสุทธิที่ชำระเกินราคาสุดท้าย

ตัวอย่างที่ 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: จัดการ webhook การชำระเงินที่ส่งซ้ำ

อัปเดตเพียงครั้งเดียว ข้อความซ้ำจึงไม่เปลี่ยนอะไร

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: รีวิว การรายงาน และความไว้วางใจ

ตลาดจะทำงานได้ก็ต่อเมื่อผู้คนไว้วางใจ รีวิวเกิดหลังการขายที่เสร็จสมบูรณ์ การรายงานให้ผู้ใช้แจ้งปัญหาได้ และกฎที่ซ่อนประกาศต้องชัดเจนและเป็นธรรม เพื่อไม่ให้ผู้ใช้คนเดียวที่โกรธสามารถซ่อนรถดีได้

รีวิวหลังการขาย

รีวิวทำได้เฉพาะคำสั่งซื้อที่เสร็จสมบูรณ์ และแต่ละคนรีวิวได้หนึ่งครั้งต่อคำสั่งซื้อ คะแนนจำกัดที่ 1 ถึง 5 ด้วยกฎ CHECK คะแนนเฉลี่ยแสดงเมื่อมีรีวิวอย่างน้อยสามรายการ เพื่อให้รีวิวเพียงรายการเดียวทำให้ผู้ขายดูดีเกินจริงหรือแย่เกินจริงไม่ได้

การรายงานจากหลายคน

การรายงานระบุสิ่งที่ผิด เช่น ราคาปลอมหรือเลขไมล์ไม่ถูกต้อง นับผู้รายงานที่ไม่ซ้ำกันก่อนซ่อนประกาศ เพื่อให้คนเดียวไม่สามารถซ่อนรถได้เพียงลำพัง ประกาศที่ถูกซ่อนจะเข้าคิวให้เจ้าหน้าที่ตรวจ

ต้นแบบของการรีวิวและการรายงาน

แบบฟอร์มรีวิวถามคะแนนดาวและความเห็น ลิงก์รายงานมองเห็นได้แต่ไม่ขวางทาง การออกแบบที่ดีทำให้การรายงานทำได้ง่าย และการโพสต์รีวิวโดยไม่ตั้งใจทำได้ยาก

ตัวอย่างที่ 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: ทรานแซกชันในการใช้งานจริง: สภาวะการแข่งขัน

ผู้ซื้อสองคนอาจกดรับข้อเสนอพร้อมกัน ถ้าไม่ระวัง ทั้งสองคำขอจะอ่านว่ารถยังขายอยู่ แล้วขายรถคันเดียวกันสองครั้ง หัวข้อนี้แสดงว่าทรานแซกชัน การล็อกแถว ลำดับการล็อก และคีย์ idempotency ป้องกันปัญหานี้และความล้มเหลวอื่นๆ ของระบบจริงได้อย่างไร

สิ่งที่เกิดขึ้นเมื่อไม่มีการล็อก

เซสชัน A อ่านว่ารถยังขายอยู่ เซสชัน B อ่านสถานะเดียวกัน ทั้งสองอัปเดตเป็นสงวนไว้ และทั้งสองสร้างคำสั่งซื้อ รถจึงถูกขายสองครั้ง การล็อกแถวรถด้วย SELECT FOR UPDATE ทำให้เซสชันที่สองรอจนเซสชันแรกยืนยัน แล้วเซสชันที่สองจะเห็นสถานะสงวนไว้และหยุด

ตรวจจำนวนแถวที่เปลี่ยน

คำสั่ง UPDATE ที่มี WHERE status = active เปลี่ยนหนึ่งแถวให้ผู้ชนะ และศูนย์แถวให้ผู้แพ้ แอปพลิเคชันอ่านจำนวนแถวที่ได้รับผลกระทบและตอบ 409 Conflict ให้ผู้แพ้ ผู้ซื้อจึงเห็นข้อความที่ชัดเจน แทนที่จะเห็นว่าสำเร็จแบบหลอกๆ

ดีดล็อก การย้อนกลับ และการลองใหม่

สองทรานแซกชันที่ล็อกแถวเดียวกันในลำดับต่างกันอาจเกิดดีดล็อก และ MySQL จะย้อนกลับหนึ่งรายการ ให้ล็อกรถก่อน แล้วจึงข้อเสนอ แล้วจึงคำสั่งซื้อ ในทุกเส้นทางโค้ด เมื่อบางอย่างล้มเหลวกลางทาง ROLLBACK จะยกเลิกทุกการเปลี่ยนแปลงของทรานแซกชันนั้น และผู้เรียกสามารถลองใหม่ได้อย่างปลอดภัยด้วยคีย์ idempotency เดิม

ตัวอย่างที่ 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: ล็อกตามลำดับที่กำหนด

ทุกเส้นทางล็อกรถก่อนข้อเสนอ เพื่อหลีกเลี่ยงดeadlock

-- 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: คีย์สำหรับ idempotency

คำขอซ้ำที่มีคีย์เดียวกันจะคืนผลลัพธ์แรก

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: การรายงานและการวิเคราะห์

ธุรกิจต้องการตัวเลข ได้แก่ ยอดขายรายวัน มูลค่าสต็อกของดีลเลอร์แต่ละราย และกรวยตั้งแต่การลงประกาศจนถึงการขาย รายงานอ่านตารางเดียวกัน จึงต้องใช้สถานะที่ถูกต้อง และห้ามนับคำสั่งซื้อที่ยกเลิกเป็นรายได้

ยอดขายรายวันจากคำสั่งซื้อที่เสร็จ

นับเฉพาะคำสั่งซื้อที่เสร็จแล้วเป็นรายได้ จัดกลุ่มตามวันที่สร้างคำสั่งซื้อ หรือวันที่เสร็จสมบูรณ์หากการบัญชีต้องการ อธิบายทางเลือกนี้ไว้ในรายงาน เพื่อให้คนสองคนที่อ่านตัวเลขเดียวกันเข้าใจความหมายตรงกัน

วิวสำหรับดีลเลอร์

วิวให้จำนวนสต็อก มูลค่าสต็อก และจำนวนวันเฉลี่ยที่รถวางขายในที่เดียว วิวซ่อนการ join ไว้ ดังนั้นแอปพลิเคชันจึงคิวรีได้เหมือนตาราง และกฎทั้งหมดอยู่ในนิยามเดียว

กรวยการแปลงและแดชบอร์ด

นับรถที่ลงประกาศ รถที่มีข้อเสนออย่างน้อยหนึ่งรายการ และคำสั่งซื้อที่เสร็จสมบูรณ์ แดชบอร์ดแสดงตัวเลขสำคัญที่ด้านบนและตารางรถที่ขายอยู่ด้านล่าง ผู้ขายจึงเห็นก่อนว่าอะไรต้องให้ความสนใจ

ตัวอย่างที่ 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 ที่ชัดเจน ผู้ใช้ฐานข้อมูลที่มีสิทธิ์น้อยที่สุด การสำรองที่กู้คืนได้ การเปลี่ยนแปลง schema ที่ปลอดภัย และเช็กลิสต์การปล่อยที่หยุดการปล่อยที่ไม่ดีก่อนถึงผู้ซื้อ

สัญญา API และ idempotency

ส่งรายการประกาศในรูปแบบ JSON ที่คงที่ พร้อมตัวชี้สำหรับหน้าถัดไป ต้องมีส่วนหัว Idempotency-Key สำหรับข้อเสนอและคำสั่งซื้อ เพื่อให้การลองใหม่หลังหมดเวลาไม่สร้างคำสั่งซื้อที่สอง บันทึกค่าของสถานะไว้ในเอกสาร เพราะแอปมือถือและแอปเว็บต้องอ่านค่าเหล่านี้ให้เหมือนกัน

สิทธิ์น้อยที่สุด การสำรอง และการย้ายข้อมูล

ผู้ใช้แอปอ่าน เพิ่ม และแก้ไขฐานข้อมูลรถได้ แต่ลบการชำระเงินหรือบันทึกการตรวจสอบไม่ได้ สำรองด้วยดัมพ์แบบทรานแซกชันเดียวเพื่อไม่ให้บล็อกการเขียน และทดสอบการกู้คืนบนเครื่องอื่น เปลี่ยน schema เป็นขั้นเล็กๆ คือเพิ่มคอลัมน์ที่เป็น NULL ได้ เติมข้อมูลเป็นชุด แล้วค่อยเข้มกฎในการปล่อยครั้งถัดไป

เช็กลิสต์การปล่อย

ก่อนการปล่อยแต่ละครั้ง ให้รันการตรวจตามกฎธุรกิจ ได้แก่ ไม่มีรถที่ขายอยู่โดยไม่ผ่านการตรวจ และไม่มีคำสั่งซื้อที่ยอดสุทธิที่ชำระมากกว่าราคาสุดท้าย หากการตรวจใดล้มเหลว สคริปต์การปล่อยจะหยุดทำงาน เพื่อไม่ให้กฎที่เสียหายไปถึงผู้ซื้อ

ตัวอย่างที่ 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

ส่งคีย์ idempotency เพื่อให้ลองซ้ำได้อย่างปลอดภัย

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: การเปลี่ยนแปลง schema อย่างปลอดภัย

เพิ่มคอลัมน์ที่เป็น null ได้ เติมข้อมูลเป็นชุด แล้วค่อยกำหนดเงื่อนไขภายหลัง

-- 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. สร้างฐานข้อมูล carmarket ด้วย utf8mb4 จากนั้นสร้างตาราง 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 กับข้อมูลทดสอบ และแก้กฎที่ละเมิดก่อนไปต่อ

ขั้นต่อไป

หลังจากแล็บ ให้อ่านแนวทางการออกแบบฐานข้อมูลสำหรับสแต็กของคุณ เพิ่มหน้าจอแอปมือถือจากต้นแบบ และทดสอบทุกทรานแซกชันด้วยสองเซสชันพร้อมกันก่อนเปิดตัว เก็บสคริปต์ไว้ในรีโพซิทอรีและรันใหม่หลังทุกการเปลี่ยน schema เพราะกฎของตลาดจะเพิ่มขึ้นเมื่อธุรกิจโตขึ้น

สรุป

ตลาดรถยนต์มือสองคือชุดกฎที่ฐานข้อมูลต้องรักษาไว้: รถหนึ่งคันขายได้หนึ่งครั้ง ต้องผ่านการตรวจก่อนขึ้นประกาศ เงินมัดจำถูกเก็บไว้จนการโอนเสร็จ และบัญชีแยกประเภทที่บวกกันได้ถูกต้องเสมอ คุณได้เรียนการเขียนกฎก่อน การสร้างแบบจำลองผู้ใช้ ดีลเลอร์ และแคตตาล็อกรถ การลงประกาศพร้อมรูปและการตรวจ การค้นหาด้วยดัชนีและการแบ่งหน้าแบบ keyset การจัดการข้อเสนอและคำสั่งซื้อพร้อมลำดับสถานะ การบันทึกการชำระเป็นบัญชีแยกประเภท การติดตามขั้นตอนการโอนกรรมสิทธิ์ สินเชื่อและการรับประกัน การสร้างความไว้วางใจด้วยรีวิวและการรายงาน การจัดการสภาวะการแข่งขันด้วยการล็อกและ idempotency การรายงานธุรกิจ และการปล่อยอย่างปลอดภัย