データベーススキーマ
Subseek で使用する全テーブルの定義。ORM は Drizzle ORM、DB は Turso(SQLite)。
認証(users / sessions / accounts / verifications)と Stripe サブスクリプション(subscriptions)は Better Auth が管理するテーブル。アプリ側のマイグレーションでスキーマは管理されるが、書き込みは原則として Better Auth のフロー経由で行われる(users.country / users.credit_balance のようなアプリ固有カラム除く)。
全体の位置付け(Turso と Meilisearch の使い分け)は データベース概要 を参照。各テーブルの「カラムがいつ更新されるか」を俯 瞰するには Turso 更新タイミング を参照。
ER図
テーブル定義
users
Better Auth が管理するテーブル。Better Auth Stripe プラグイン導入に伴い、stripe_customer_id と credit_balance は旧 billing_accounts テーブルから本テーブルに移行されている(マイグレーション 0007)。
| カラム | 型 | 制約 | デフォルト | 説明 |
|---|---|---|---|---|
id | TEXT | PK, NOT NULL | - | ユーザーID(Better Auth 採番) |
name | TEXT | NOT NULL | - | 表示名(Google OAuth から取得) |
email | TEXT | NOT NULL, UNIQUE | - | メールアドレス |
email_verified | INTEGER (bool) | NOT NULL | false | メール検証フラグ |
image | TEXT | - | - | プロフィール画像 URL |
role | TEXT | NOT NULL | 'member' | 権限ロール(member / admin)。DMCA テイクダウン等の管理操作に利用 |
stripe_customer_id | TEXT | - | - | Stripe Customer ID。Better Auth Stripe プラグイン createCustomerOnSignUp で自動生成 |
credit_balance | INTEGER | NOT NULL | 50 (Free プラン上限) | 現在のクレジット残高。プラン変更時・月次バッチ・クレジット消費時に更新 |
bonus_credit | INTEGER | NOT NULL | 0 | ボーナスクレジット残高。紹介報酬等で付与され、通常クレジットより先に消費される。月初リセットの対象外で、使い切るまで残り続ける |
referral_code | TEXT | UNIQUE | - | 自分の紹介コード(URL-safe 8 文字)。ユーザー作成時に databaseHooks.user.create.after で生成・設定。ユーザー間での共有はコード文字列のまま(URL 形式での共有は現状サポートしない) |
referred_by | TEXT | FK → users.id | - | 紹介者の users.id。Plus 加入前の「紹介コード入力」フォームで確定する。自己参照 FK、ON DELETE SET NULL(紹介者が退会しても被紹介者のアカウントは残す)。自己参照禁止(referred_by != id)はアプリ層で検証 |
referral_rewarded_at | TEXT | - | - | 紹介報酬の付与日時(ISO 8601)。Plus 加入完了の Stripe Webhook で現在時刻を設定。referred_by IS NOT NULL AND referral_rewarded_at IS NULL の行のみ付与対象となり、IS NOT NULL なら冪等にスキップ |
country | TEXT | - | - | ユーザーの国(ISO 3166-1 alpha-2、例: "JP", "US")。後述の自動設定ルール参照 |
created_at | INTEGER (ms) | NOT NULL | unixepoch('subsecond') * 1000 | 作成日時(Unix ミリ秒) |
updated_at | INTEGER (ms) | NOT NULL | 同上、$onUpdate あり | 更新日時(Drizzle が自動更新) |
country の自動設定ルール:
- 供給元: Cloudflare Workers の
CF-IPCountryヘッダー(ISO 3166-1 alpha-2) - 設定タイミング: 認証済み API リクエスト時に
countryBackfillMiddlewareが実行され、country IS NULLの場合にのみ書き込む(初回書き込みのみ) - 冪等性: 既に値が入っているユーザーは上書きしない(
where(and(eq(users.id, userId), isNull(users.country)))で絞り込み) - NULL のままになるケース:
CF-IPCountryがXX(Cloudflare が判別不能) またはT1(Tor exit ノード)を返した場合- ローカル開発環境など
CF-IPCountryヘッダーが供給されない場合
- DB 更新失敗時の挙動: エラーは
console.warnで記録のみ。認証フローをブロックしない
更新タイミング:
| カラム | 書き込まれるフロー | 備考 |
|---|---|---|
id / name / email / image / email_verified | Google OAuth サインアップ時(Better Auth) | 以降は Better Auth updateUser or プロフィール同期時 |
role | 作成時 'member' | admin 手動で admin に昇格 |
stripe_customer_id | Better Auth Stripe createCustomerOnSignUp | 初回のみ。以降は変更なし |
credit_balance | ① 消費時(consumeCreditsTransaction で優先的に減算。不足分はその後 bonus_credit から)② プラン変更時( onSubscriptionComplete / onSubscriptionUpdate / onSubscriptionDeleted が resetCredits を呼ぶ)③ 月次バッチ( resetAllCredits が全ユーザーを 50/500/2000 に一括 UPDATE) | consumeCreditsInTransaction で同一トランザクション内に減算 |
bonus_credit | ① 消費時(consumeCreditsTransaction が credit_balance を使い切った後の不足分を減算)② 付与時( grantBonusCredits が加算。紹介報酬成立・キャンペーン等) | 月次バッチ(resetAllCredits)の対象外で温存される |
referral_code | 作成時(databaseHooks.user.create.after で自動生成、UNIQUE 衝突時は最大 3 回リトライ) | 既存ユーザーはバックフィルスクリプトで付与。生成後は再生成しない |
referred_by | 紹介コード入力時(POST /api/referrals/claim で自分の referred_by をセット) | 既に referred_by がセット済みのユーザーは上書き不可。自己参照(referred_by = id)はアプリ層で拒否 |
referral_rewarded_at | Plus 以上の新規加入時(Stripe Webhook onSubscriptionComplete 内で grantBonusCredits 呼び出しと同時に設定) | IS NOT NULL の行は報酬付与済みとして以降の Webhook ではスキップ |
country | 認証済みリクエスト初回のみ(countryBackfillMiddleware) | 既存値は上書きしない |
created_at | 作成時 | 変更なし |
updated_at | Drizzle の $onUpdate が任意 UPDATE 時に自動更新 | - |
ボーナスクレジット
users.bonus_credit は通常クレジット(credit_balance)とは独立した残高。紹介報酬(#111)をはじめ、キャンペーン・補填など汎用的なボーナス管理基盤として用いる。
Why 通常クレジットと分離するか:
- 紹介報酬のように付与されるクレジットは、月初リセットの対象外にしたい。
credit_balanceと同じカラムで管理すると「リセット時に区別して温存する」ロジックが必要になり、月次バッチが複雑化する bonus_creditを別カラムに分離すると、resetAllCreditsはcredit_balanceのみを UPDATE すればよく、bonus_creditは自動的にリセット対象外になる
消費順序(通常 → ボーナス):
クレジット消費時は credit_balance を優先的に減算し、不足分を bonus_credit から引く。
- 1 動画のコスト(例:
calculateCreditCost(duration) = 3)が通常残高 を超える場合も、通常を使い切ってからボーナスから残りを引く(credit 単位で分割可能) - 通常クレジットは月初リセットで失われる(use-it-or-lose-it)ため先に消費し、永続するボーナスを温存する設計
- ボーナスはヘビー利用月のバッファとして機能し、Plus 解約 → Free ダウングレード後も残るため、churn prevention 的な価値も持つ
- 実装:
apps/api/src/features/credit/model/consume-credits-transaction/consume-credits-transaction.ts
付与方法:
grantBonusCredits(db, { userId, amount }) で加算する。
| 条件 | 挙動 |
|---|---|
amount > 0 の整数 | users.bonus_credit += amount を実行 |
amount === 0 | no-op(DB アクセスなし) |
amount < 0 / 非整数 / NaN / Infinity | Error を throw |
| 対象ユーザーが存在しない | Error を throw(rowsAffected === 0 を検知) |
本関数は排他ロックを持たない。紹介報酬(#111)のように Webhook 経由で呼ぶ場合は、呼び出し側で 冪等性チェック(例: referrals.status === 'completed' の行はスキップ)を行うことで二重加算を防ぐ。
月初リセットでの扱い:
resetAllCredits(月次バッチ batch-monthly-reset.yml から呼ばれる)は credit_balance のみを UPDATE する。bonus_credit は一切触らないため、使い切るまで残高が維持される。
用途:
- 紹介報酬(#111): 紹介された側が Plus 以上に 加入した際、紹介者と被紹介者の双方に 100Cr を付与
- キャンペーン / 補填: 運用上の判断で特定ユーザーにクレジットを付与する場合も同じ関数を使える
紹介機能
紹介者が自分のリンクをシェアし、紹介された側が Plus 以上に加入すると双方にボーナスクレジット 100Cr が付与される仕組み。users テーブルの 3 カラム(referral_code / referred_by / referral_rewarded_at)のみで実装される。
Why referrals テーブルではなくカラム方式:
- F1 方式(Plus 加入前のフォームで紹介コード入力)では、pending 状態を別テーブルで管理する必要が薄い
- 「誰が誰を紹介したか」の中間履歴は不要で、最終的な関係(referred_by)と付与済みフラグ(referral_rewarded_at)さえあれば MVP 要件を満たせる
- 紹介者側の「何人紹介したか」は
SELECT COUNT(*) FROM users WHERE referred_by = ? AND referral_rewarded_at IS NOT NULLで算出できる
紹介コード(referral_code):
- URL-safe アルファベット(
A-Za-z0-9_-)で 8 文字のランダム文字列(REFERRAL_CODE_LENGTH) - ユーザー作成時に
databaseHooks.user.create.afterで自動生成 - UNIQUE 制約に衝突した場合は最大 3 回リトライ(64^8 ≈ 2.8 × 10^14 通りで実用上衝突はほぼ発生しない)
- 一度生成したら再生成しない(シェア済みのコードが無効化 されないように)
紹介の成立フロー:
1. 紹介者 A が自分の `referral_code` をシェア(メッセージ・SNS・メール等)
2. 被紹介者 B がサイトに来てサインアップ(普通の Google OAuth フロー。Cookie / middleware による追跡はしない)
3. B が Plus 加入画面で「紹介コード(任意)」フォームに A のコードを入力
4. POST /api/referrals/claim { code } が呼ばれる
- サーバー側: referral_code から A を特定し、B 自身の referred_by に A.id をセット
- 自己参照(A.id === B.id)や既に referred_by がある場合はエラー
5. B が Stripe チェックアウトに遷移して Plus に加入
6. onSubscriptionComplete(Stripe Webhook)が発火
- B.referred_by IS NOT NULL かつ B.referral_rewarded_at IS NULL のとき:
- grantBonusCredits(A, REFERRAL_REWARD_CREDITS) → A.bonus_credit += 100
- grantBonusCredits(B, REFERRAL_REWARD_CREDITS) → B.bonus_credit += 100
- B.referral_rewarded_at = now()
冪等性:
- ステップ 6 は
referral_rewarded_at IS NULLを条件にするため、Webhook が再送された場合や既に付与済みの場合はスキップされる - トランザクション内で「B の referral_rewarded_at を UPDATE → rowsAffected をチェック」で原子性を担保し、並行 Webhook による二重付与を防ぐ
自己参照 / 既存ユーザーの扱い:
- B が自分のコードを入力しても
referred_by = idになるためclaimAPI 側で拒否する(DB レベルの CHECK 制約は置かない。アプリ層で検証してユーザーに分かりやすいエラーを返す) referred_byが既に設定済みの B はclaimAPI 側で上書き拒否する- 紹介コード入力は Plus 未加入のユーザーのみ可能(加入後は報酬付与されないため UI で隠す + API でも拒否)
channels
YouTube チャンネルのメタデータを管理する。
| カラム | 型 | 制約 | デフォルト | 説明 |
|---|---|---|---|---|
id | TEXT | PK, NOT NULL | - | YouTube チャンネルID(UCxxxxxx) |
title | TEXT | NOT NULL | - | チャンネル名 |
handle | TEXT | - | - | カスタムURL(@handle) |
thumbnail_url | TEXT | - | - | サムネイル画像URL |
subtitle_status | TEXT | NOT NULL | 'pending' | 字幕取得状態: pending / processing / completed / failed / deleted / opted_out |
video_count | INTEGER | NOT NULL | 0 | 動画数(YouTube API から取得) |
country | TEXT | - | - | 運営者の所在国(ISO 3166-1 alpha-2、例: "JP"、運営者未公開の場合 null) |
subscriber_count | INTEGER | - | - | 登録者数(YouTube 側で非公開設定のチャンネルは null) |
created_at | TEXT | NOT NULL | new Date().toISOString() | 作成日時 |
updated_at | TEXT | NOT NULL | new Date().toISOString() | 更新日時 |
インデックス:
| インデックス名 | カラム |
|---|---|
idx_channels_title | title |
idx_channels_handle | handle |
idx_channels_subtitle_status | subtitle_status |
更新タイミング:
| カラム | 初回登録時 | 24h 差分取得時(#305 / fetchChannelDiff) | 備考 |
|---|---|---|---|
id | 設定 |