0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、前回DynamoDB用に作成したテーブル設計を、Amazon Aurora PostgreSQL(Serverless v2) で設計し直してみました。
リレーショナルデータベースに設計し直すことで、将来のイベント種別追加・スタンプ追加・管理画面実装などに柔軟に対応できるようになりました。

概要

  • DB エンジン: Amazon Aurora PostgreSQL(Serverless v2)
  • 接続方式: RDS Data API(HTTPS / IAM 認可)→Lambdaを非VPCのまま接続する想定
  • 設計方針: 第3正規形を基本としつつ、共通コア + イベント種別ごとの拡張テーブルに分割
  • ORM/クライアント: AWS SDK @aws-sdk/client-rds-data、またはData API対応のDrizzle ORMを想定

セキュリティ

  • line_user_idはLIFF ID Tokenをサーバー側で検証した結果のsubクレームからのみ取得する(クライアントから受け取った値をそのまま使わない)
  • 保存時暗号化: AuroraはAWS KMS Customer Managed Key (CMK) で暗号化する
  • 認証情報: DB認証情報はSecrets Manager(CMK 暗号化 + 自動ローテーション)で管理し、Data API にはSecret ARNを渡す
  • IAM: アプリ実行ロールはData API(rds-data:ExecuteStatement 等)+ 対象SecretのGetSecretValue のみ。

要件サマリ

要件 設計反映
1 LIFFのスタンプラリー機能 events(type=stamp_rally) + stamps + user_stamps
2 LineUserId を保持し後から一覧取得 line_users テーブル(line_user_id に UNIQUE)
3 スタンプ(今後追加あり想定) stamps に行を追加するだけ(スキーマ変更不要)
4 各スタンプの獲得状態を保持 user_stamps(参加者 × スタンプ の獲得レコード)
5 スタンプ獲得時の Flex Message 内容を保持 stamps.flex_message_content(jsonb)
6 獲得時間 / Flex 送信完了状態を保持 user_stamps.acquired_at / flex_message_sent / ..._sent_at
7 新イベント種別への対応(仕様は未確定) events.type で種別分岐 + 種別別拡張テーブルを後付け可能
8 管理画面の実装に対応(現状は不要) 正規化 + 集計ビュー
管理画面の書き込み機能をリリースする前にadmin identity/audit log/change history(監査・トラブル対応用)を必須化する(後付けでは過去の変更履歴を復元できないため)

ER図

ER図(mermaid.ai)
erDiagram
    events ||--o{ event_participants : "has"
    events ||--o{ stamps : "defines"
    line_users ||--o{ event_participants : "joins"
    event_participants ||--o{ user_stamps : "earns"
    stamps ||--o{ user_stamps : "is earned as"
    event_participants ||--o{ stamp_rally_event_completion_claims : "completes (per generation)"

    events {
        bigint id PK
        text code UK
        text type
        text name
        timestamptz starts_at
        timestamptz ends_at
        text status
        timestamptz created_at
        timestamptz updated_at
    }
    line_users {
        bigint id PK
        text line_user_id UK
        timestamptz created_at
        timestamptz updated_at
    }
    event_participants {
        bigint id PK
        bigint event_id FK
        bigint line_user_id FK
        timestamptz registered_at
        timestamptz created_at
        timestamptz updated_at
    }
    stamps {
        bigint id PK
        bigint event_id FK
        text code
        text name
        int display_order
        boolean is_active
        jsonb flex_message_content
        timestamptz created_at
        timestamptz updated_at
    }
    user_stamps {
        bigint id PK
        bigint event_id FK
        bigint event_participant_id FK
        bigint stamp_id FK
        timestamptz acquired_at
        boolean flex_message_sent
        timestamptz flex_message_sent_at
        timestamptz created_at
        timestamptz updated_at
    }
    stamp_rally_event_completion_claims {
        bigint id PK
        bigint event_participant_id FK
        text stamp_set_signature
        int required_count
        timestamptz claimed_at
    }

SCR-20260604-nvjy.png

テーブル定義DDL(Data Definition Language)

0: updated_at自動更新トリガー
1: events(イベントマスタ)
2: line_users(全イベントで一意のLINEユーザー)
3: event_participants(各イベント参加者=LINEユーザー x 特定イベントの関連づけ)
4: stamps(スタンプマスタ)
5: user_stamps(スタンプ獲得状態=各イベント参加者 x 特定スタンプの関連づけ)
6: stamp_rally_event_completion_claims(スタンプ全コンプリートの一意確定)
 ※スタンプラリーイベント参加者専用テーブル

0: updated_at自動更新トリガー

行更新時にupdated_atを自動的に現在時刻へ書き換える共通関数です。
updated_atをアプリケーションのコードで毎回書く運用も可能ですが、

  • UPDATE文を書く箇所すべてで指定しないと更新が漏れる
  • 複数のサービス・言語・経路(管理画面、バッチ、手動SQL等)から更新され
    る場合、全部で揃えるのは現実的でない
  • (特に)手動でpsqlから直接UPDATEしたときに更新されない

といった問題が起こり得ます。
DB側のトリガーに寄せておけば、どんな経路でUPDATEされても必ずupdated_atが正しく入ることが保証されます。

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

1: events(イベントマスタ)

開催するイベント(現状はスタンプラリーのみだが、将来は別種別も追加可能)を1行=1イベントで管理する親マスタテーブルです。

CREATE TABLE events (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code        TEXT NOT NULL,                     -- 外部公開用の安定したコード (例: 'stamp-rally-202606')
  type        TEXT NOT NULL,                     -- イベント種別 (下記のCHECK参照)
  name        TEXT NOT NULL,                     -- 管理画面などで表示用の名称
  starts_at   TIMESTAMPTZ,                       -- 公開開始日時 (任意)
  ends_at     TIMESTAMPTZ,                       -- 公開終了日時 (任意)
  status      TEXT NOT NULL DEFAULT 'draft',     -- draft / active / ended / archived
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT events_code_uk UNIQUE (code),
  -- 種別は CHECK で表現 (新種別の追加はCHECKへ値を足すだけ。ネイティブENUMより柔軟)
  -- 現状はスタンプラリーのみ。新イベント種別が決まったら値を追加する
  CONSTRAINT events_type_chk   CHECK (type IN ('stamp_rally')),
  CONSTRAINT events_status_chk CHECK (status IN ('draft', 'active', 'ended', 'archived')),
  -- 開始・終了が両方ある場合は開始 < 終了を強制 (管理画面の入力ミスをDBで弾く)
  CONSTRAINT events_period_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at)
);

CREATE TRIGGER trg_events_updated_at
  BEFORE UPDATE ON events
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CONSTRAINTは「データの整合性ルールに名前を付ける仕組み」です。
名前を付けることでエラー原因が分かりやすくなります。
また、これらの整合性ルールはDBが不正なデータの登録・更新を自動で防いでくれます。

bigintintの違いは

PostgreSQLにおけるintbigintの違いは、主に格納できる数値の範囲(とそれに伴うバイト数) です。
どちらも整数で、計算や比較の使い方は同じです。
bigintintに比べ、桁あふれ(オー バーフロー)するリスクがない分、バイト数が多くなります。

バイト数 範囲 別名
smallint 2 約-3.2万〜+3.2万 int2
int(integer) 4 約-21億〜+21億(±2,147,483,647) int4
bigint 8 約-922京〜+922京(±9.2×10¹⁸) int8

後からintbigintに広げる移行は全関連カラムを巻き込んで非常に重いため、将来の拡張が予測できない場合は最初からbigintにしておくのが推奨されています。

なぜstringではなくtext?

PostgreSQLにはstringという型は存在しません。文字列型は次の3つです。

説明
text 長さ上限なしの可変長文字列
varchar(n) 最大 n 文字までの可変長文字列
char(n) 固定長 n 文字(足りない分は空白で埋める)

PostgreSQLではtextvarcharは内部的に全く同じ実装で、格納方法も処理速度も同一です。
性能差が無いため、「長さ制限が業務要件として必要」な列のみvarchar(n)、それ以外はtextにしておくのが推奨されています。
※MySQL(Aurora MySQL)に切り替える場合はTEXTは扱いが重いため注意が必要です。

PK, FK, UKの違い

すべて「制約(constraint)」であり、必須というわけではありませんが、実務では多くの場合使用されています。

PK = Primary Key(主キー)

「その1行を指す代表の鍵」です。1つのテーブルに1つだけ設定でき、自動的に「重複
不可 + NULL不可」になります。

FK = Foreign Key(外部キー)

「別テーブルの行を指すポインタ」です。参照先に存在しない値は入れられないようにDBが守ってくれます。
(例:「参加者テーブル」内に「イベントIDを指すFK」がある場合、存在しないイベントの参加者を作れない)
また、ON DELETE CASCADEを付ければ、親(イベントID)を消すと子も自動で消すことが可能になります。

UK = Unique Key(一意キー)

「この列(または列の組み合わせ)の値は重複してはいけない」というルールで
す。PKと違い、1テーブルに複数設定でき、NULLも基本許容されます。
「line_user_id」や「event_id」等に使用されます。

Key 役割 個数 重複 NULL
PK 行を一意に特定する代表ID テーブルに1つ 不可 不可
UK 特定の値の重複を禁止 複数OK 不可 基本OK
FK 他テーブルの行を参照 複数OK 可(多対一なので) 可(任意参照なら)

2: line_users(全イベントで一意のLINEユーザー)

LINEユーザーを全イベント横断で1人=1行に集約する、ユーザーIDの一元管理テーブルです。

CREATE TABLE line_users (
  id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  line_user_id  TEXT NOT NULL,                        -- 検証済みID Tokenのsubクレーム由来 (PII)
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(), 
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT line_users_line_user_id_uk UNIQUE (line_user_id)
);

CREATE TRIGGER trg_line_users_updated_at
  BEFORE UPDATE ON line_users
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

なぜline_usersテーブルが別で必要なのか?

1. ユーザーは「イベント横断で1人1行」だから

line_usersline_user_idにUNIQUEが張ってあり、同じ人は何イベントに参加してもDB全体で1行です。一方event_participantsは「誰がどのイベントに参加したか」の橋渡しテーブルであり、[ユーザー×イベントの組み合わせ]ごとに1行増えます。
もしline_usersを作らず参加レコードにline_user_id(PII)を直接持たせると、同じ人がイベントに参加するたびに同じline_user_id文字列が何行も複製されます。テーブルを分けることでPIIの重複保存をなくし、ユーザーという実体を1か所に集約できます。

2. PIIの一元管理 / GDPR削除点になるから

line_user_idは「個人を継続識別できるID=PII」として扱う方針です。これを1テーブルに集約しておくと、

  • 削除が1行で済む:
    DELETE FROM line_users WHERE line_user_id = $1;
    だけでON DELETE CASCADEにより配下のevent_participants → user_stamps → event_completion_claimsまで一括削除されます。(event_participantsテーブル参照)
    もしPIIが各参加レコードに散らばっていると、削除漏れのリスクが上がります。
  • また、暗号化・マスキングの対象として管理しやすくなります

3: event_participants(各イベント参加者=LINEユーザー x 特定イベントの関連づけ)

「どのユーザーが・どのイベントに参加したか」を1行で表す、line_userseventsを多対多で結ぶ中間(橋渡し)テーブルです。

CREATE TABLE event_participants (
  id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id       BIGINT NOT NULL REFERENCES events(id)     ON DELETE RESTRICT,  -- イベント終了は status='archived' で表現。物理削除での証跡消失を防ぐ
  line_user_id   BIGINT NOT NULL REFERENCES line_users(id) ON DELETE CASCADE,   -- GDPR 削除はline_users起点。RESTRICTでないためevent_idをRESTRICTにしても削除フローは壊れない
  registered_at  TIMESTAMPTZ NOT NULL DEFAULT now(),       -- そのイベントへの初回登録日時
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- 同一ユーザーが同一イベントに二重登録されないことを保証 (冪等登録の要)
  CONSTRAINT event_participants_uk UNIQUE (event_id, line_user_id),
  -- user_stampsからの複合外部キー (id, event_id) の参照先。
  -- idは単独でPKだが、複合FKの参照先には対応するUNIQUE制約が必要なため明示する
  CONSTRAINT event_participants_id_event_uk UNIQUE (id, event_id)
);

-- 管理画面「イベント別 参加者一覧(登録日時順)」用(現状は不要)
-- 管理画面を実装する際は、深いページの OFFSET 非効率と同時刻登録の順序不安定を避けるため、
-- このINDEXを (event_id, registered_at, id) に拡張し、keyset paginationを採用する。
-- 現時点では管理画面が無いため索引は最小構成のままとする。
-- CREATE INDEX idx_event_participants_event_registered
--   ON event_participants (event_id, registered_at);

CREATE TRIGGER trg_event_participants_updated_at
  BEFORE UPDATE ON event_participants
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

複合外部キーは、複数の列の組み合わせが正しいことをDBで保証するために使います。
今回のケースでは、「イベント参加者」と「特定のイベント」の組み合わせが矛盾しないことを保証しています。

CREATE INDEX
PostgreSQLがevent_participantsテーブルとは別に、(event_id, registered_at) の順に並んだ検索用のデータ構造(B-tree)を裏で維持します。これにより、特定のクエリが高速になります(INSERTするたびに、テーブル本体だけでなくこの索引にもエントリを追加・整列する必要があるため、書き込みが少し遅くなるなどのデメリットもあります)

なぜわざわざUNIQUE制約を追加しているか

CONSTRAINT event_participants_id_event_uk UNIQUE (id, event_id)
PostgreSQLでは、外部キーの参照先カラムにはUNIQUE(またはPK)制約が必須というルールがあります。
idは単独でPKなので、それ自体は一意なのですが、 (id, event_id)というペアの組み合わせを参照先にするには、「そのペアに対するUNIQUE制約」が形式上必要になります。

4: stamps(スタンプマスタ)

各イベントで集められるスタンプ1種類=1行を定義するマスタテーブルです。

CREATE TABLE stamps (
  id                    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id              BIGINT NOT NULL REFERENCES events(id) ON DELETE RESTRICT,  -- イベント終了は status='archived' で表現。物理削除での証跡消失を防ぐ
  code                  TEXT NOT NULL,                  -- イベント内で安定したコード (例: '001')
  name                  TEXT NOT NULL,                  -- 表示名 (例: '第1スタンプ')
  display_order         INT  NOT NULL DEFAULT 0,        -- 表示順 ("order" は予約語のため display_order)
  is_active             BOOLEAN NOT NULL DEFAULT true,  -- 非公開化したい場合にfalse
  flex_message_content  JSONB,                          -- 獲得時に送るFlex Message JSONをそのまま格納
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- 同一イベント内でコード重複を禁止
  CONSTRAINT stamps_event_code_uk UNIQUE (event_id, code),
  -- user_stamps からの複合外部キー (id, event_id) の参照先 (上記event_participantsと同様)
  CONSTRAINT stamps_id_event_uk UNIQUE (id, event_id)
);

-- スタンプ一覧 (表示順) 取得用。display_orderは重複可(DEFAULT 0)のためidをタイブレークに含め安定ソートを担保
-- CREATE INDEX idx_stamps_event_order ON stamps (event_id, display_order, id);

CREATE TRIGGER trg_stamps_updated_at
  BEFORE UPDATE ON stamps
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

5: user_stamps(スタンプ獲得状態=各イベント参加者 x 特定スタンプの関連づけ)

user_stampsは「参加者の所属イベント」と「スタンプの所属イベント」が一致することをDBレベルで強制します。

CREATE TABLE user_stamps (
  id                     BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id               BIGINT NOT NULL,                     -- 参加者・スタンプ双方の所属イベント (整合性担保用に非正規化)
  event_participant_id   BIGINT NOT NULL,
  stamp_id               BIGINT NOT NULL,
  acquired_at            TIMESTAMPTZ NOT NULL DEFAULT now(),  -- 獲得時刻
  flex_message_sent      BOOLEAN NOT NULL DEFAULT false,      -- Flex Message送信完了フラグ
  flex_message_sent_at   TIMESTAMPTZ,                         -- 送信完了時刻
  created_at             TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at             TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- 二重獲得防止: 同じ参加者が同じスタンプを2行持てない
  CONSTRAINT user_stamps_uk UNIQUE (event_participant_id, stamp_id),

  -- 複合外部キーで「参加者の所属イベント」と「スタンプの所属イベント」の一致をDBレベルで強制する。
  -- event_idを介して両FKを張ることで、別イベントの参加者×スタンプの組み合わせは挿入できない
  CONSTRAINT user_stamps_participant_event_fk
    FOREIGN KEY (event_participant_id, event_id)
    REFERENCES event_participants (id, event_id) ON DELETE CASCADE,
  -- stamp側はRESTRICT。スタンプの物理削除で獲得履歴(証跡)が巻き添え消失するのを防ぐ
  -- (非公開化はis_active=falseで表現する)
  CONSTRAINT user_stamps_stamp_event_fk
    FOREIGN KEY (stamp_id, event_id)
    REFERENCES stamps (id, event_id) ON DELETE RESTRICT,

  -- 送信フラグと送信時刻の整合を強制 (未送信なら時刻NULL / 送信済みなら時刻必須)
  CONSTRAINT user_stamps_flex_sent_chk CHECK (
    (flex_message_sent = false AND flex_message_sent_at IS NULL)
    OR
    (flex_message_sent = true  AND flex_message_sent_at IS NOT NULL)
  )
);

-- 「このスタンプを獲得した参加者」逆引き (管理集計用)
-- CREATE INDEX idx_user_stamps_stamp ON user_stamps (stamp_id);

CREATE TRIGGER trg_user_stamps_updated_at
  BEFORE UPDATE ON user_stamps
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

6: stamp_rally_event_completion_claims(スタンプ全コンプリートの一意確定)

このテーブルはスタンプラリー(type='stamp_rally')の完了確定だけに使用します。
他種別のイベントは、その種別固有の<種別>_*拡張テーブルを別途新設する想定です。
参加者が、「その時点でactiveなスタンプ集合を全獲得した」という事実を、完了世代(= activeスタンプ集合)ごとに1行で確定させます。
また、完了通知(Flex/プッシュ)を「その世代の初回完了時に一度だけ」発火させるよう制御する役割もあります。
スタンプは後から追加入れ替えされることが想定され、その都度全スタンプ獲得の達成条件は変わるため必要になります。

CREATE TABLE stamp_rally_event_completion_claims (
  id                    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_participant_id  BIGINT NOT NULL REFERENCES event_participants(id) ON DELETE CASCADE,
  -- 完了確定時点のactiveスタンプid集合(昇順連結)。例: "12,15" / 入れ替え後 "15,18"
  -- 集合が変われば別世代になり、件数が同じ「入れ替え」でも衝突せず別世代として扱える
  stamp_set_signature   TEXT NOT NULL,
  required_count        INT  NOT NULL,                       -- その世代のactiveスタンプ総数(監査・表示用。signatureのid個数と一致する冗長値)
  claimed_at            TIMESTAMPTZ NOT NULL DEFAULT now(),  -- その世代の完了確定時刻
  -- 「同一参加者 × 同一完了世代」は1行のみ(競合・リトライでも世代あたり通知1回に限定)
  CONSTRAINT stamp_rally_event_completion_claims_uk UNIQUE (event_participant_id, stamp_set_signature),
  -- 完了世代は必ず1件以上のactiveスタンプを伴う。管理SQL/移行/バッチからの不正値(<=0)をDBで弾く
  CONSTRAINT stamp_rally_event_completion_claims_required_count_chk CHECK (required_count > 0)
);

アクセスパターンの注意点

スタンプマスタ一覧取得時は、

SELECT
    id,
    code,
    name,
    display_order,
    is_active
FROM
    stamps
WHERE
    event_id = $1
    AND is_active
ORDER BY
    display_order,
    id;

などのようにし、
・データが大きなflex_message_contentは一覧で返さない
・display_order重複時はidで安定ソートさせる
などといった工夫が必要になります。
※Aurora PostgreSQLのData APIは1MB(結果セット)、1レコードあたり64KBの制限があるため

今回は以上になります!

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?