中古車マーケットプレイスのデータベース設計: テーブル、サンプルデータ、トランザクション、アプリのモックアップ、ゼロからプロレベルまでの 100 の例
中古車マーケットプレイスは、高額で唯一無二の、法律上の名義変更が必要な 1 つの資産を中心に、購入者、販売者、ディーラーをつなぎます。どの車にも車台番号、走行距離の履歴、検査報告書、そして正しく所有者が変わらなければならない登録があります。この記事は、そのようなマーケットプレイスのデータベースを、空のスキーマから本番を意識したリリースまで 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: 業務ルールとセットアップ
テーブルではなくルールから始めます。誰が車を出品でき、誰が購入でき、車を公開する前に何が真でなければならず、何が決して起きてはならないか。たとえば同じ車を二度売ることです。データベースはこれらのルールを強制するので、アプリケーションのバグでルールが壊れることはありません。
関係者とそのニーズ
購入者は検索し、お気に入りを保存し、オファーを出します。販売者は車を出品し、オファーに応じます。ディーラーは多くの車を出品し、認証バッジを持ちます。検査員は車を採点し、管理者は通報を確認します。それぞれの役割には固有の権限と画面が必要です。
データベースが守るべきルール
1 台の車は同時に 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: ユーザーテーブル
購入者、販売者、ディーラー、スタッフのための 1 つのアカウント表。
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 に役割付きの 1 行を持ちます。ディーラーは事業情報を専用のテーブルに持ち、住所によって検索を県で絞り込めます。これらを分けておくと各テーブルが小さくなり、各ルールも確認しやすくなります。
役割を持つ 1 つのアカウントテーブル
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: 車のメーカー
メーカーごとに 1 行、名前は一意。
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: 車種
各車種は 1 つのメーカーに属します。
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: グレード(トリム)
同じ車種でもエンジンが違う複数のバージョンがあります。
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 のようなグレードは、その車種の、独自のエンジンとギアボックスを持つバージョンです。同じ車種でも年式ごとに複数のグレードがあり得るため、年式の範囲はグレードに持たせます。
オプションと多対多の関係
サンルーフやリアカメラのようなオプションは、すべてのグレードにあるわけではありません。リンクテーブルがオプションとグレードを結び付けるので、1 つのオプションを多くのグレードに、1 つのグレードに多くのオプションを持たせられます。
カタログが重要な理由
販売者がグレードを選ぶと、エンジン、燃料、座席数はカタログから入ります。販売者が入力を間違えることはなく、値は自由記述ではなくコードとして保存されるため、燃料やギアボックスでの検索も確実に機能します。
例 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: 出品情報
販売される実車 1 台につき 1 行。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: 写真のサンプル
最初の出品の写真 3 枚。
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: 出品と写真
出品は販売中の実車 1 台です。独自の車台番号、走行距離、色、価格、ステータスを持ちます。写真は固定の順序を持つ別テーブルにあり、最初の写真は常に正面になり、ギャラリーは毎回同じ順序で読み込まれます。
実車 1 台につき 1 行
各車には一意の車台番号があるため、同じ車を二度出品することはできません。ステータスは下書きから審査待ち、公開中、予約済み、売却済み、削除のいずれかに進みます。CHECK ルールにより、負の価格や負の走行距離は保存できません。
出品を安全に変更する
価格を変更できるのは販売者だけです。販売者の確認を変更と同じ UPDATE 文に入れ、確認と書き込みの間に隙間が生じないようにします。ステータスは許可された段階に沿ってのみ移動させ、各変更を記録します。
写真と詳細ページ
画像ファイルではなく写真の URL と位置を保存します。詳細ページは車、販売者、写真を 1 つのクエリで読み込みます。モックアップは、その結果がスマートフォンとデスクトップでどのように配置されるかを示します。
例 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: 出品の状態を進める
1 つ前の段階からのみ変更を許可します。
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: 出品の詳細ページ
車、写真、販売者を 1 つのクエリで読み込みます。
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: モックアップ: 詳細ページ
ギャラリー、概要、操作を 1 画面にまとめます。
<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: 検索、絞り込み、性能
購入者は県、価格、年式、燃料、ギアボックスで検索し、1 秒もかからずに結果が出ることを期待します。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: オファーと入札
オファーは、有効期限付きで車に対して購入者が出す価格の提案です。提示価格は変わらないので、複数の購入者がオファーを出せ、販売者は 1 件を承諾し、他を拒否し、新しい価格で逆提案できます。
独立したテーブルとしてのオファー
オファーは出品、購入者、金額、ステータス、有効期限を持つ 1 行です。販売者の逆提案は購入者のオファーを指す新しい行なので、交渉の全体が履歴に残ります。
最低価格と確認
販売者は最低価格を設定できます。受け入れ可能として販売者に見せる前に、オファーを最低価格と比べます。判断は理由とともに記録し、後で紛争に答えられるようにします。
期限切れとモックアップ
定期ジョブが古いオファーを期限切れにします。オファーを出すダイアログには有効期限が表示され、販売者ができることが説明されるので、購入者は次に何が起きるかを知ることができます。
例 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: 1 つのトランザクションでオファーを承諾
車をロックし、オファーを承諾し、出品を予約するのを一緒に行います。
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: 他のオファーを拒否
1 つのオファーが成立すると、同じ車への他のオファーは終了します。
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: 注文とステータスの流れ
販売者がオファーを承諾すると、注文が作られます。注文は決められた一連のステータスを経て進み、すべての変更が記録されるので、サポートのスタッフは販売が現在の状態にどう至ったかをいつでも確認できます。
車 1 台につき確定した注文 1 件
orders の listing_id 列には UNIQUE 制約があるため、2 つのリクエストが同時に届いても、データベースは同じ車の 2 件目の注文を拒否します。手付金の額は最終価格から計算され、ここでは 10 パーセントです。
同じトランザクションで注文を作成
オファーの承諾、出品の予約、注文の挿入は 1 つのトランザクションの中で行われます。どれかのステップが失敗すると、すべてがロールバックされるため、注文のない予約や、予約されていない注文は決して生じません。
ステータス履歴と許可された段階
注文のすべての変更は、旧ステータス、新ステータス、操作者、時刻とともに履歴テーブルに書き込まれます。UPDATE は現在のステータスを確認するので、注文は手付金待ちから一足飛びに完了へ移ることはできません。
例 55: 注文テーブル
車 1 台につき確定した購入 1 件。確定価格と手付金を持ちます。
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: 注文を 1 段階進める
予定された段階からのみ状態を変えます。
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: 台帳としての支払い
お金は編集される単一の数値としては決して保存しません。支払い、返金、残金の各項目は台帳の 1 行です。合計は行から計算されるので、履歴は常に完全で、数字は常に合います。
お金の動き 1 件につき 1 行
支払いには、手付金、残金、返金といった種類、金額、決済事業者の参照番号、ステータスがあります。決済事業者の参照番号は UNIQUE なので、同じ支払いを二度記録することはできず、行を銀行や決済事業者自身の記録と結び付けます。
2 回届く Webhook
決済事業者は同じ通知を複数回送ることがあります。更新は行がまだ保留中のときにだけステータスを変えるので、2 通目のメッセージでは何も変わりません。これにより、ハンドラーは再試行しても安全です。
返金と正味の支払額
返金は新しい行であり、古い行を上書きする負の支払いではありません。注文の正味支払額は、成功した支払いから成功した返金を引いた合計です。リリース前チェックは、正味支払額が最終価格を超えた場合にリリースを止めます。
例 61: 支払いテーブル
お金の動きは 1 行ずつ、決済事業者の参照番号を持ちます。
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 の処理
1 回だけ更新するので、重複したメッセージでは何も変わりません。
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: 名義変更の手順
所有者の変更の各手順を 1 行ずつ記録します。
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: 注文の標準手順
売買契約から鍵の引き渡しまでの 5 段階。
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: ローンと保証
ほとんどの購入者は全額を現金では払いません。銀行ローンは注文に結び付いた申し込みであり、保証は売却後の数か月または数キロメートルの間、購入者を守ります。
ローンの申し込み
ローンの申し込みは注文に属し、銀行、金額、期間、ステータス(提出済み、承認、否認、取り下げ)を記録します。期間は、6 から 84 か月といった CHECK ルールで制限されるので、あり得ない条件は入力できません。
売却日から始まる保証
保証は注文が完了したときにだけ作られます。月数と走行距離の上限に、開始日を加えて保存します。まだ有効かどうかを確認するには日付と走行距離の両方を比べるので、購入者は正しい答えを得られます。
オファーを明確に示す
注文ページは月々の支払額を見積もりとして、保証の条件をわかりやすい言葉で示します。明確な情報は売却後の紛争を減らします。これは後から苦情を処理するより安上がりです。
例 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 か月、60 万バーツの融資の申し込み。
INSERT INTO financing_applications (order_id, bank, amount, term_months)
VALUES (1, 'Example Bank', 600000.00, 60);
*この例は、この記事を準備したコンピューターに MySQL がインストールされていなかったため、ここでは実行していません。*
例 75: 保証
注文ごとに 1 件の保証。期間と走行距離を持ちます。
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 人のユーザーが良い車を隠せてはいけません。
売却後のレビュー
レビューは完了した注文に対してだけ許され、各人は注文ごとに 1 回レビューできます。評価は CHECK ルールで 1 から 5 に制限されます。平均評価は 3 件のレビューの後にだけ表示されるので、1 件のレビューで販売者が完璧にも最悪にも見えることはありません。
複数の人からの報告
報告には、偽の価格や誤った走行距離など、何が間違っているかを書きます。出品を非表示にする前に、重複しない報告者の数を数えるので、1 人だけで車を隠すことはできません。非表示になった出品はスタッフの確認キューに入ります。
レビューと報告の操作のモックアップ
レビューフォームは星と感想を求め、報告リンクは見えるが邪魔にならない位置にあります。良いデザインは報告を簡単にし、誤ってレビューを投稿することを難しくします。
例 79: 販売完了後のレビュー
注文ごとに 1 人 1 件、評価は 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: 販売者ごとの平均評価
レビューが 3 件以上あるときだけ評価を表示します。
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: 実際に動くトランザクション: 競合状態
2 人の購入者が同時に承諾ボタンを押すことがあります。注意しないと、両方のリクエストが車は販売中だと読み取り、両方が車を売ってしまいます。このトピックは、トランザクション、行ロック、ロックの順序、冪等性キーが、そのような、そして実際のシステムの他の障害をどう防ぐかを示します。
ロックがないと何が起きるか
セッション A は車が販売中だと読みます。セッション B も同じステータスを読みます。両方が予約済みに更新し、両方が注文を作ります。車は二度売られます。SELECT FOR UPDATE で車の行をロックすると、2 つ目のセッションは 1 つ目のコミットを待ち、その後で予約済みを見て止まります。
変更された行数を確認する
WHERE status = active を持つ UPDATE は、勝者には 1 行、敗者には 0 行変更します。アプリケーションは影響を受けた行数を読み、敗者に 409 Conflict を返すので、購入者は偽の成功ではなく明確なメッセージを見ます。
デッドロック、ロールバック、再試行
同じ行を違う順序でロックする 2 つのトランザクションは、デッドロックを起こすことがあり、MySQL はその一方をロールバックします。すべてのコードの経路で、まず車をロックし、次にオファー、次に注文をロックしてください。途中で何かが失敗したら ROLLBACK がそのトランザクションのすべての変更を取り消し、呼び出し元は同じ冪等性キーで安全に再試行できます。
例 85: 2 人の購入者が同時に承諾
行をロックして、1 つのトランザクションだけが勝つようにします。
-- 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: レポートと分析
ビジネスには数字が必要です。日ごとの売上、ディーラーごとの在庫額、出品から売却までのファネルです。レポートは同じテーブルを読むので、正しいステータスを使わなければならず、キャンセルされた注文を収入として数えてはいけません。
完了した注文からの日次売上
収益には完了した注文だけを数えます。注文が作られた日、または会計上必要ならば完了した日で集計します。その選択をレポートに明記し、同じ数字を読む 2 人が、それが何を意味するかで一致するようにします。
ディーラー向けのビュー
ビューは、ディーラーの在庫台数、在庫額、市場に出ている平均日数を 1 か所に示します。ビューは結合を隠すので、アプリケーションはそれをテーブルのように問い合わせ、ルールは 1 つの定義にまとまります。
コンバージョンのファネルとダッシュボード
出品された車、少なくとも 1 件のオファーがある車、完了した注文を数えます。ダッシュボードは上部に主要な数字を、下部に販売中の車の表を示すので、販売者はまず何に注意すべきかを見られます。
例 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 ヘッダーを必須にし、タイムアウト後の再試行で 2 件目の注文が作られないようにします。ステータスの値はドキュメント化してください。モバイルアプリと Web アプリは同じように読まなければならないからです。
最小権限、バックアップ、マイグレーション
アプリケーションのユーザーは車のデータベースを読み、挿入し、更新できますが、支払いや監査記録は削除できません。書き込みをブロックしないように単一トランザクションのダンプでバックアップし、復元は別のマシンでテストします。スキーマは小さな段階で変えてください。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 でオファーを出す
再試行を安全にするため冪等性キーを送ります。
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: 安全なスキーマ変更
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 がインストールされていなかったため、ここでは実行していません。*
ラボ: 車のマーケットプレイスのデータベースを作成してテストする
テスト用データベースで記事をまとめます。テーブルを順に作り、サンプルの車とユーザーを挿入し、オファーを承諾から注文の流れまで通し、2 人の購入者が同時に操作する状況を再現し、最後にリリースチェックリストで締めくくります。
1. utf8mb4 で carmarket データベースを作成し、その順に users、dealers、car_makes、car_models、car_trims のテーブルを作ります。
2. サンプルユーザー、ディーラー 1 社、メーカーと車種、グレード 1 件を挿入し、SELECT COUNT(*) で行数を確認します。
3. car_listings、car_images、inspections、inspection_items を作成し、写真と合格した検査を持つ出品を 1 件挿入します。
4. 合格した検査を必要とするルールを通してのみ出品を公開し、検査のない出品は審査待ちのままであることを確認します。
5. offers と orders を作成し、1 件のオファーをトランザクション内で承諾して、注文のステータス履歴を確認します。
6. MySQL のセッションを 2 つ開き、両方で同じ車をロックして、予約が成功するのは 1 つだけであることを示します。
7. テストデータに対して例 100 のリリースチェックリストを実行し、先へ進む前に壊れたルールを修正します。
次のステップ
ラボの後は、ご自身のスタックのデータベース設計ガイドを読み、モックアップからモバイルアプリの画面を追加し、公開前に 2 つのセッションで同時にすべてのトランザクションをテストしてください。スクリプトはリポジトリに保存し、スキーマを変更するたびに再実行してください。事業が成長するにつれて、マーケットプレイスのルールも増えていくからです。
まとめ
中古車マーケットプレイスは、データベースが守らなければならないルールの集まりです。1 台の車は 1 度だけ売られ、公開前に検査を受け、手付金は名義変更が完了するまで預かられ、常に合計が合う台帳が存在します。ルールを先に書くこと、ユーザー、ディーラー、車両カタログをモデル化すること、写真と検査を伴う出品、インデックスとキーセットページングによる検索、明確なステータス遷移を持つオファーと注文の扱い、支払いを台帳として記録すること、名義変更の各手順の追跡、ローンと保証、レビューと報告による信頼の構築、ロックと冪等性による競合状態の処理、ビジネスのレポート、そして安全なリリースを学びました。