NFC来店・ポイント・承認フローを支える飲食店アプリのDB設計 — 100テーブル超を破綻させない5つの勘所
この記事について
飲食店発見・来店ポイント系アプリのバックエンド設計に携わり、100テーブルを超えるスキーマを扱いました。「ユーザーが店を探す → NFCタグをスキャンして来店 → ポイントが貯まる → 投稿・応援・スタンプラリー」という体験を支えるデータモデルは、判断を誤ると一気に破綻します。
この記事では、実務で効いた設計判断を汎用化して整理します。テーブル名・カラム名はすべて一般化しており、特定の案件・企業を指すものではありません。
対象環境は PHP 8 / Laravel、MySQL 8、バッチはAmazon ECSのスケジュールタスク、画像はS3、プッシュ通知はFirebase Cloud Messagingです。
1. マスタ/トランザクションのプレフィックス命名規則
テーブルが3桁になったとき、まず効くのは「名前だけで役割が分かること」です。この案件では次の規約を徹底していました。
mtb_*… マスタテーブル(mtb_areas,mtb_menu_genres,mtb_payment_types)dtb_*… データ(トランザクション)テーブル(dtb_users,dtb_stores,dtb_visit_histories)
多対多の中間テーブルは dtb_{親}_{マスタ複数形}(例: dtb_store_payment_types)とし、「関連テーブルである」ことを名前に込めます。
mtb_store_types -- 店舗形態マスタ(固定) mtb_menu_genres -- 料理ジャンルマスタ(階層あり) dtb_stores -- 店舗(運用で増減) dtb_store_store_types -- 店舗×店舗形態(中間テーブル)
これだけで、外部キーを張る際の「参照先はマスタか?」という確認が一瞬で終わり、レビューとオンボーディングが速くなります。命名は仕様書ではなくコードそのもの。規約が揃っているだけで、読みやすさは大きく変わります。
2. お知らせを「方向」で分割するテーブル設計
通知系は「1つのテーブルに種別カラム」で始めがちですが、登場人物が増えると破綻します。この案件では送信元と送信先の組み合わせ(方向)でテーブルを分けていました。
- 運営 → ユーザー(
dtb_information_users) - 運営 → 店舗(
dtb_information_stores) - ユーザー/店舗 → 運営(運営宛お知らせ)
さらに既読管理を本体から切り離します。
dtb_information_users -- 運営→ユーザーのお知らせ本体 dtb_information_user_reads -- 既読(お知らせ×ユーザー) dtb_information_user_personalizeds -- 個人宛の場合の対象ユーザー
ポイントは既読を別テーブルにしたことです。本体に既読フラグを持たせると個人宛にしか対応できませんが、既読を中間テーブルにすることで、全体向け(N人が既読)と個人向けの両方を同じ構造で扱えます。
// 未読お知らせ件数(全体向け+自分宛個人向け)
$unreadCount = Information::query()
->where('status', InformationStatus::PUBLISHED)
->where('release_date', '<=', today())
->where(function ($q) use ($userId) {
$q->where('is_personalized', false)
->orWhereHas('personalizeds', fn ($q) => $q->where('user_id', $userId));
})
->whereDoesntHave('reads', fn ($q) => $q->where('user_id', $userId))
->count();
3. ポイント履歴を1テーブルの多態で表現する
ポイントは取得・使用のシーンが多彩です。NFCスキャン、キャンペーン、手動付与、スタンプラリー達成/当選、初回特典、店舗QRでの使用、手動減算——。これらを種別ごとにテーブル分割すると集計がつらくなるため、1テーブルに type を持たせた多態設計にしました。
type 1 : NFC取得 type 4 : スタンプ到達 type 2 : NFC取得(UP時) type 5 : スタンプ抽選当選 type 3 : 手動付与 type 6 : 初回DL特典 type 101: QRで使用 type 102: 手動減算
特徴的なのは店舗IDを2軸持っていることです。
exchange_store_id… どの店で取得/使用したか(取引店舗)burden_store_id… 原資を誰が負担したか(運営負担なら null)
「店で付与されるが、原資は運営か加盟店か」という業務要件を、有効期間つきの負担元設定テーブルを参照して決定します。
DB::transaction(function () use ($user, $store, $grantPoint) {
$burden = $store->pointBurdens()
->where('enabled_start_date', '<=', today())
->where(fn ($q) => $q->whereNull('enabled_end_date')
->orWhere('enabled_end_date', '>=', today()))
->latest('enabled_start_date')->first();
$burdenStoreId = $burden?->burden_type === BurdenType::STORE ? $store->id : null;
$userPoint = $user->points()->create([
'point' => $grantPoint,
'expiration_date' => today()->addYear(),
]);
$user->pointHistories()->create([
'user_point_id' => $userPoint->id,
'point' => $grantPoint,
'type' => PointType::NFC_SCAN,
'exchange_store_id' => $store->id,
'burden_store_id' => $burdenStoreId,
]);
});
残高(dtb_user_points)は有効期限別に複数レコードを持ち、「期限が近い順に消費」を実装します。履歴と残高を分けることで、ダッシュボードの付与/利用ポイント集計は履歴を舐めるだけで済みます。
4. 閲覧ログの生データと日次集計を分離する
店舗管理画面のダッシュボードには「閲覧人数」「PV数」「クーポン利用者数」が並びます。これをリアルタイム集計するとクエリが重くなるため、生ログテーブル群と日次集計の非正規化テーブルを分けました。
-- 生ログ(閲覧のたびにINSERT) dtb_browse_store_info_histories / dtb_browse_post_histories dtb_browse_coupon_histories / dtb_browse_toku_news_histories -- 日次集計スナップショット dtb_store_dashboards (store_id × target_date)
ポイントはPV(延べ)と人数(DISTINCT)を別カラムで持つことです。
INSERT INTO dtb_store_dashboards (store_id, target_date, pv_count_store_info, people_count_browse_store_info) SELECT store_id, DATE(created_at), COUNT(*), COUNT(DISTINCT user_id) FROM dtb_browse_store_info_histories WHERE created_at >= :from AND created_at < :to GROUP BY store_id, DATE(created_at);
バッチはECSスケジュールで動かし、開始・完了・件数・例外をCloudWatchへ出力。数千件単位でチャンク処理し、失敗レコードはスキップしてログに残す定石を踏みます。この分離により、管理画面は集計テーブルを日付範囲で引くだけになり、重いクエリを一切投げなくなります。生ログは監査・再集計のために残しておけます。
5. 2段階承認ワークフローをステータス遷移で管理する
ユーザーが投稿した料理画像を「運営→加盟店」の2段階で承認し、正式なメニュー画像にする仕組みを、1カラムの status 遷移で表現しました。
1 運営確認待ち → 2 運営否認
→ 3 加盟店確認待ち → 4 加盟店否認
→ 5 承認済み
遷移のたびに副作用があり、トランザクションで包みます。
// 加盟店承認(3 → 5)
DB::transaction(function () use ($postImage) {
$postImage->update(['status' => PostImageStatus::APPROVED]);
$postImage->menu->update([
'image_path' => $postImage->image_path,
'registrant_name' => $postImage->user_name, // 「公式」ではなく投稿者名
]);
});
テーブルを分けず状態機械を1カラムに閉じ込めることで、承認画面の一覧・絞り込みが WHERE status = ? だけで済み、実装も追いやすくなります。
確認方法
- ポイント多態:
type別のダミーレコードを投入し、ダッシュボード集計(付与/利用)の分岐を確認 - 閲覧集計: 同一ユーザーで複数回閲覧させ、
pv_count(増える)とpeople_count(増えない)の差を確認 - 承認フロー: 正常系(1→3→5)と否認系(1→2 / 3→4)で副作用の有無を確認
注意点
- 多態テーブルの
typeは必ず enum クラスに閉じ込める。マジックナンバーが散ると種別追加のたびに事故ります。 - 集計スナップショットは
target_dateを持ち、再集計に備えて生ログは消さない。 - 既読・いいね・クリップ等の中間テーブルは複合ユニーク制約で二重INSERTを防ぐ。
まとめ
100テーブル超のスキーマでも、破綻を防ぐ判断はシンプルでした。
- 名前で役割を分ける(
mtb_/dtb_) - 登場人物が違う通知は「方向」で分ける
- 集計軸が同じポイント履歴は多態でまとめる
- 生ログと集計を分けて管理画面を軽くする
- 状態機械は1カラム+トランザクションで包む
「分ける・まとめる」の判断基準は、集計軸が同じかどうか/登場人物が違うかどうか。複雑なドメインほど、実装前にこの物差しを言語化しておくことが、後から入る人にも読める設計につながります。
