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カラム+トランザクションで包む

「分ける・まとめる」の判断基準は、集計軸が同じかどうか/登場人物が違うかどうか。複雑なドメインほど、実装前にこの物差しを言語化しておくことが、後から入る人にも読める設計につながります。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください