|
| 1 | +# データベース定義 |
| 2 | + |
| 3 | +PostgreSQL のテーブル定義と、テーブル間の関係をまとめた文書。**スキーマ本体と |
| 4 | +マイグレーションをたどらなくても全体像が分かるようにするため**に置いている。 |
| 5 | + |
| 6 | +定義の**権威は [`db/init/01_schema.sql`](../db/init/01_schema.sql)**(テーブル・索引・ |
| 7 | +トリガー・初期データのすべて。「現在あるべきスキーマの唯一の定義」)。この文書はそれを |
| 8 | +読める形に写したもので、列ごとの細かい経緯は SQL のコメントに書いてある。 |
| 9 | +[`db/migrations/`](../db/migrations/) は既存 DB を保持したまま移行するためのスクリプトで、 |
| 10 | +最終形はマイグレーションではなくスキーマ本体の側が持つ。 |
| 11 | + |
| 12 | +**DB に変更を入れたら、同じコミットでこの文書も更新する**(手順は末尾)。 |
| 13 | + |
| 14 | +## 全体像 |
| 15 | + |
| 16 | +中心は `spots`(スポット)で、種別(`spot_types`)がスポットの名前空間になる。 |
| 17 | +ユーザーごとの記録(訪問・非表示・予定・口コミ)はすべて `users` × `spots` に紐づく。 |
| 18 | + |
| 19 | +```mermaid |
| 20 | +erDiagram |
| 21 | + spot_types ||--o{ spot_type_settings : "種別ごとの設定" |
| 22 | + spot_types ||--o{ spots : "" |
| 23 | + spot_types ||--o{ spot_deletions : "" |
| 24 | + spot_types ||--o{ spot_routes : "" |
| 25 | + spot_types ||--o{ visit_plan_lists : "" |
| 26 | + app_settings }o--|| spot_types : "active_spot_type_id" |
| 27 | +
|
| 28 | + users |o--o{ spots : "created_by" |
| 29 | + users |o--o{ spot_routes : "created_by" |
| 30 | + users |o--o{ spot_deletions : "deleted_by" |
| 31 | + users ||--o{ visits : "" |
| 32 | + users ||--o{ spot_hides : "" |
| 33 | + users ||--o{ visit_plans : "" |
| 34 | + users ||--o{ visit_plan_lists : "" |
| 35 | + users ||--o{ reviews : "" |
| 36 | +
|
| 37 | + spots ||--o{ spot_route_points : "" |
| 38 | + spots ||--o{ visits : "" |
| 39 | + spots ||--o{ spot_hides : "" |
| 40 | + spots ||--o{ visit_plans : "" |
| 41 | + spots ||--o{ visit_plan_list_items : "" |
| 42 | + spots ||--o{ reviews : "" |
| 43 | +
|
| 44 | + spot_routes ||--o{ spot_route_points : "seq 順" |
| 45 | + visit_plan_lists ||--o{ visit_plan_list_items : "seq 順" |
| 46 | +``` |
| 47 | + |
| 48 | +このほかに、どのテーブルとも関係を持たない `schema_migrations`(適用済み |
| 49 | +マイグレーションの記録。[`db/entrypoint.sh`](../db/entrypoint.sh) が作る)がある。 |
| 50 | + |
| 51 | +## 横断的な決めごと |
| 52 | + |
| 53 | +- **主キーは `uuid`**(`gen_random_uuid()`、pgcrypto 拡張)。例外は複合キーの |
| 54 | + `spot_type_settings`(種別×キー)と `spot_route_points`(ルート×seq)、 |
| 55 | + singleton の `app_settings` |
| 56 | +- **全テーブルに `created_at` / `updated_at`** を持ち、`updated_at` は共有の |
| 57 | + `set_updated_at()` トリガーが自動更新する。日時はすべて `timestamptz` |
| 58 | +- **列挙は数値ではなく文字列 + `check` 制約**(`users.role`・`spots.status`・ |
| 59 | + `spots.origin`・`reviews.visibility`)。`psql` で覗いたときに読めるほうを優先 |
| 60 | +- **外部キーは実際に張ってある**。削除時の挙動で役割を分けている: |
| 61 | + **記録・従属データは `on delete cascade`**(スポットを消せば訪問記録も消える)、 |
| 62 | + **作成者への参照は `on delete set null`**(ユーザーを消しても作ったスポット・ |
| 63 | + ルートは残る) |
| 64 | +- **公開状態(`status`)は `spots` と `spot_routes` で共通の4値**。 |
| 65 | + `published`(全員に見える)/ `pending`(承認待ち。本人 + moderator 以上)/ |
| 66 | + `rejected`(却下)/ `private`(作成者本人のみ。誰でも作れる非公開スポット) |
| 67 | +- **「ユーザー×スポットで1件まで」のトグル型テーブル**(`spot_hides` / |
| 68 | + `visit_plans`)は `unique (user_id, spot_id)` で構造ごと保証する |
| 69 | +- **写真の実体は DB に入れない**。`visits.photos` は `text[]` の相対パス |
| 70 | + (`<ユーザーID>/<年>/<月>/<uuid>.<拡張子>`)で、実体は bind マウントされた |
| 71 | + `photos/` に置く(`lib/photos.ts`)。DB と `photos/` は一緒にバックアップする |
| 72 | +- **`visits.visited_on` の null は「時期不明」**(訪問した日を覚えていない)。 |
| 73 | + `unvisited = true` は「未訪問記録」で、訪問済みの判定には数えない |
| 74 | + |
| 75 | +## テーブル |
| 76 | + |
| 77 | +### 種別と設定 |
| 78 | + |
| 79 | +```mermaid |
| 80 | +erDiagram |
| 81 | + spot_types { |
| 82 | + uuid id PK |
| 83 | + text key UK "機械可読キー(例 tourist)" |
| 84 | + text label "表示名(例 観光地)" |
| 85 | + } |
| 86 | + spot_type_settings { |
| 87 | + uuid spot_type_id PK, FK |
| 88 | + text key PK "設定名(lib/types.ts に既知キー)" |
| 89 | + text value "boolean 相当は 'true'/'false' の文字列" |
| 90 | + } |
| 91 | + app_settings { |
| 92 | + boolean singleton PK "check(singleton) で常に1行" |
| 93 | + uuid active_spot_type_id FK "ルート(/)のリダイレクト先" |
| 94 | + } |
| 95 | + spot_types ||--o{ spot_type_settings : "" |
| 96 | + app_settings }o--|| spot_types : "" |
| 97 | +``` |
| 98 | + |
| 99 | +- **`spot_type_settings` は EAV 形式**。設定を追加するたびに `spot_types` に列を |
| 100 | + 増やさずに済む。**行が無いキーは設定ごとの既定値**として扱う(口コミの有効化・ |
| 101 | + 管理者限定閲覧のような boolean のほか、`series_styles`・`categories`・ |
| 102 | + `region_scope` など JSON・文字列値のキーもある) |
| 103 | +- 画面・API の対象種別は常に URL の `/[type]/...` で決まる。`app_settings` は |
| 104 | + 「ログイン後に自動で開く種別」を決めるためだけの 1 行 |
| 105 | + |
| 106 | +### アカウント |
| 107 | + |
| 108 | +```mermaid |
| 109 | +erDiagram |
| 110 | + users { |
| 111 | + uuid id PK |
| 112 | + text email UK |
| 113 | + text password_hash "google_id とどちらかは必須(check)" |
| 114 | + text google_id UK "Googleログイン用" |
| 115 | + text role "admin / spot_admin / moderator / user" |
| 116 | + text nickname "口コミ等の表示名。未設定は「匿名」" |
| 117 | + } |
| 118 | +``` |
| 119 | + |
| 120 | +- 自由サインアップは無く、管理者が作成する。**最初の1アカウントだけ**セットアップ |
| 121 | + 画面から作成でき、自動的に admin になる |
| 122 | + |
| 123 | +### スポットとルート |
| 124 | + |
| 125 | +```mermaid |
| 126 | +erDiagram |
| 127 | + spots { |
| 128 | + uuid id PK |
| 129 | + uuid spot_type_id FK |
| 130 | + text key "種別内で一意(部分ユニーク索引)。CSV・ルートからの参照用" |
| 131 | + text name |
| 132 | + text name_kana |
| 133 | + double lat |
| 134 | + double lng |
| 135 | + text region "region_scope 設定で意味が変わる(都道府県・州・国名)" |
| 136 | + text series "1スポット1つ。色分け・ルートとの突き合わせの単位" |
| 137 | + text_array categories "0個以上(GIN索引)" |
| 138 | + text description |
| 139 | + text status "published / pending / rejected / private" |
| 140 | + text origin "csv / manual。travel-log-data への還元抽出に使う" |
| 141 | + uuid created_by FK "on delete set null" |
| 142 | + } |
| 143 | + spot_deletions { |
| 144 | + uuid id PK |
| 145 | + uuid spot_type_id FK |
| 146 | + text key "削除時点の値のコピー(墓標)" |
| 147 | + text name |
| 148 | + double lat |
| 149 | + double lng |
| 150 | + text region |
| 151 | + uuid deleted_by FK |
| 152 | + } |
| 153 | + spot_routes { |
| 154 | + uuid id PK |
| 155 | + uuid spot_type_id FK |
| 156 | + text name "種別内でユニーク" |
| 157 | + text series "spots.series と同じ値空間。矢印の色" |
| 158 | + text description |
| 159 | + text status "spots と同じ4値" |
| 160 | + uuid created_by FK |
| 161 | + } |
| 162 | + spot_route_points { |
| 163 | + uuid route_id PK, FK |
| 164 | + integer seq PK "昇順が巡った順" |
| 165 | + uuid spot_id FK "on delete cascade(点だけ抜ける)" |
| 166 | + text description "次の経由地への区間の説明。最終地点は null" |
| 167 | + } |
| 168 | + spot_types ||--o{ spots : "" |
| 169 | + spot_types ||--o{ spot_deletions : "" |
| 170 | + spot_types ||--o{ spot_routes : "" |
| 171 | + spots ||--o{ spot_route_points : "" |
| 172 | + spot_routes ||--o{ spot_route_points : "" |
| 173 | +``` |
| 174 | + |
| 175 | +- **`spots.key` は自然キー(名前)ではなく明示キー**。改名・座標修正で |
| 176 | + ルート CSV からの参照が壊れないようにするため。不要なスポットは null でよい |
| 177 | +- **`spot_deletions` は削除の墓標**。CSV 由来の公開スポットを画面から個別削除した |
| 178 | + ときだけ記録し、travel-log-data 側の `exclude.txt` へ追記する候補として |
| 179 | + 還元用エクスポートに出す(行が消えるので値をコピーして残す) |
| 180 | + |
| 181 | +### ユーザーごとの記録 |
| 182 | + |
| 183 | +```mermaid |
| 184 | +erDiagram |
| 185 | + visits { |
| 186 | + uuid id PK |
| 187 | + uuid user_id FK |
| 188 | + uuid spot_id FK |
| 189 | + timestamptz visited_on "null = 時期不明" |
| 190 | + text memo |
| 191 | + text_array photos "photos/ 配下の相対パス" |
| 192 | + boolean unvisited "true = 未訪問記録(訪問済みに数えない)" |
| 193 | + } |
| 194 | + spot_hides { |
| 195 | + uuid id PK |
| 196 | + uuid user_id FK |
| 197 | + uuid spot_id FK "ユーザー×スポットでユニーク(トグル)" |
| 198 | + } |
| 199 | + visit_plans { |
| 200 | + uuid id PK |
| 201 | + uuid user_id FK |
| 202 | + uuid spot_id FK "ユーザー×スポットでユニーク(トグル)" |
| 203 | + } |
| 204 | + visit_plan_lists { |
| 205 | + uuid id PK |
| 206 | + uuid user_id FK |
| 207 | + uuid spot_type_id FK |
| 208 | + text title |
| 209 | + text description |
| 210 | + date start_date |
| 211 | + date end_date |
| 212 | + } |
| 213 | + visit_plan_list_items { |
| 214 | + uuid id PK |
| 215 | + uuid list_id FK |
| 216 | + uuid spot_id FK "リスト×スポットでユニーク" |
| 217 | + integer seq "リスト内の並び順" |
| 218 | + } |
| 219 | + reviews { |
| 220 | + uuid id PK |
| 221 | + uuid user_id FK |
| 222 | + uuid spot_id FK |
| 223 | + text body |
| 224 | + text visibility "public / private" |
| 225 | + } |
| 226 | + visit_plan_lists ||--o{ visit_plan_list_items : "" |
| 227 | +``` |
| 228 | + |
| 229 | +- **`visits` は同一スポットへの複数回訪問を許容する**(トグルではない) |
| 230 | +- **`visit_plans`(1スポットの予定)と `visit_plan_lists`(順序付きの旅程)は独立**。 |
| 231 | + 訪問を記録すると `visit_plans` からは自動で消える |
| 232 | +- **`reviews` は掲示板方式**(1ユーザーが同じスポットに何件でも書ける)。機能自体の |
| 233 | + ON/OFF は種別ごとに `spot_type_settings` の `reviews_enabled` で切り替える。 |
| 234 | + シリーズ表示ロジックには `reviews` を一切参照させない |
| 235 | + |
| 236 | +### 索引 |
| 237 | + |
| 238 | +ユニーク制約・主キー由来のものを除いた、検索用の索引。 |
| 239 | + |
| 240 | +| テーブル | 索引 | 何のため | |
| 241 | +|---|---|---| |
| 242 | +| spots | `region` / `series` / `spot_type_id` | 地図・一覧の絞り込み | |
| 243 | +| spots | `categories`(GIN) | 複数カテゴリの包含検索(`&&`) | |
| 244 | +| spots | `(spot_type_id, key)` ユニーク(key が null 以外) | CSV・ルートからの参照キー | |
| 245 | +| spot_deletions | `spot_type_id` | 還元用エクスポートの抽出 | |
| 246 | +| spot_routes | `series` | シリーズ絞り込みとの連動 | |
| 247 | +| spot_route_points | `spot_id` | スポットからの逆引き | |
| 248 | +| visits / spot_hides / visit_plans | `user_id` / `spot_id` | ユーザーの記録の取得と逆引き | |
| 249 | +| visit_plan_lists | `user_id` / `spot_type_id` | 旅程の一覧 | |
| 250 | +| visit_plan_list_items | `list_id` / `spot_id` | 旅程の中身と逆引き | |
| 251 | +| reviews | `spot_id` | スポット詳細の口コミ一覧 | |
| 252 | + |
| 253 | +## 変更手順 |
| 254 | + |
| 255 | +1. **[`db/init/01_schema.sql`](../db/init/01_schema.sql) を直す**(唯一の定義。 |
| 256 | + 追加分を別の初期化ファイルに切り出さない) |
| 257 | +2. **同じコミットで [`db/migrations/`](../db/migrations/) に移行スクリプトを足す** |
| 258 | + (`<連番>_<内容>.sql`。全文 idempotent にする。`begin`/`commit` と |
| 259 | + `schema_migrations` への insert は書かない —— `db/entrypoint.sh` が受け持つ。 |
| 260 | + 詳細は [`db/migrations/README.md`](../db/migrations/README.md)) |
| 261 | +3. **この文書も同じコミットで更新する**(テーブル・列・索引・制約・関係の変更) |
| 262 | +4. 適用は `docker compose up` で自動(`db-migrate` サービス)。現物を確かめるなら |
| 263 | + `docker compose exec db psql -U travel_log -d travel_log -c '\d+ spots'` |
0 commit comments