Skip to content

音声投稿機能 設計書(初版)

実装後の現状はpipelineworks-and-uploadsを参照。本書は実装前に書かれた初版設計書で、当時の検討過程の記録として残す。

Worker側の実変換処理(Opusエンコードの実装そのもの)は対象外とし、ジョブキュー連携のインターフェースまでを設計範囲とする。

全体の流れ

音声投稿は、下書きの作成、ファイルのアップロード、メタデータの編集、公開という四つの段階からなる。

各段階の主体と、それに伴うデータの変化を時系列で示す。

  1. クライアントがPOST /worksを呼び、空の下書き(works行、status = draft)を作成する。レスポンスでwork_idを受け取る
  2. クライアントはwork_idUpload-Metadataに含めてTUSアップロードを開始する
  3. tusdのPreCreateフックがwork_idを検証し、uploads行を作成してworks行と紐づける
  4. アップロードが進行している間、クライアントはPATCH /works/:idでタイトルなどのメタデータを随時上書きする
  5. TUSアップロードが完了すると、Backendが同一トランザクション内でuploads.statusを更新し、変換ジョブをRiverのキューにinsertする
  6. Workerがジョブを取り出し、変換結果を自分のDB接続でuploadsworksに直接書き込む
  7. クライアントがPOST /works/:id/publishを呼ぶと、変換が完了済みなら即座にpublishedになり、未完了ならpublish_pendingで待機し、Worker完了時に自動でpublishedへ進む

データモデル

works

投稿そのものを表すテーブルである。

物理ファイルの変換パイプラインを表すuploadsテーブルとはライフサイクルが異なるため、別テーブルとして分離する。

列名説明
iduuid主キー。gen_random_uuid()で発行する
user_idtext投稿者のFirebase UID
titletextタイトル。公開の必須項目
descriptiontext説明文。任意
thumbnail_keytextCocot Storage上のサムネイル画像キー。未設定時はクライアント側でデフォルト画像を表示する
statuswork_statusdraft / publish_pending / published / deleted
upload_idtextuploads.idへの外部キー。下書き作成直後はNULLで、TUSアップロード開始時に設定する
published_attimestamptz公開日時。公開前はNULL
created_attimestamptz作成日時
updated_attimestamptz更新日時
CREATE TYPE work_status AS ENUM (
'draft', -- メタデータ入力中、アップロード未完了
'publish_pending', -- ユーザーが公開操作を行ったが、変換が未完了
'published', -- 公開済み
'deleted' -- 投稿者が削除済み
);
CREATE TABLE works (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
title text NOT NULL DEFAULT '',
description text,
thumbnail_key text,
status work_status NOT NULL DEFAULT 'draft',
upload_id text REFERENCES uploads(id),
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_works_user_id ON works(user_id);
CREATE INDEX idx_works_status ON works(status);
CREATE INDEX idx_works_upload_id ON works(upload_id);

titleをNOT NULLかつデフォルト空文字にしているのは、下書き作成時点ではタイトルが未入力であることを許し、公開時のバリデーションでのみ必須にするためである。

uploads(既存テーブルへの追加)

既存のuploadsテーブルに、対応するworks行を辿るための列を追加する。

ALTER TABLE uploads ADD COLUMN work_id uuid REFERENCES works(id);
CREATE INDEX idx_uploads_work_id ON uploads(work_id);

works.upload_iduploads.work_idの双方向参照になる点に注意する。

works.upload_idはアプリケーションが「この投稿の現在のアップロードはどれか」を参照する経路であり、uploads.work_idはWorkerが変換完了時に「このアップロードはどの投稿に属するか」を参照する経路である。

用途が異なるため、どちらか一方を省略せず両方を維持する。

tags / work_tags

タグは正規化し、worksとの対応を中間テーブルで持つ。

CREATE TABLE tags (
id bigserial PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE work_tags (
work_id uuid NOT NULL REFERENCES works(id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags(id),
PRIMARY KEY (work_id, tag_id)
);
CREATE INDEX idx_work_tags_tag_id ON work_tags(tag_id);

タグ名の正規化(大文字小文字や表記揺れの統一)についてはまだ検討していない。未確定事項として後述する。

API設計

エンドポイントはすべて/api配下に置き、Firebase認証ミドルウェアを通す。

POST /works

下書きを作成する。

リクエストボディは不要とする(タイトル等は空で作成し、直後のPATCHで埋める運用にする)。

レスポンスでwork_idと初期状態(status = draft)を返す。

PATCH /works/:id

タイトル、説明、タグを更新する。

リクエストボディに含まれるフィールドだけを更新する部分更新とする。

statuspublishedまたはdeletedの投稿に対する更新は拒否する。

タグの更新は、送信された配列でwork_tagsの中間テーブルを丸ごと置き換える(差分計算はしない)。

PUT /works/:id/thumbnail

サムネイル画像をバイナリで直接受け取り、Cocot Storageに保存してthumbnail_keyを更新する。

POST /works/:id/publish

公開を試みる。

titleが空の場合はエラーを返す。

対応するuploads.statusdoneであれば、works.statuspublishedにし、published_atを記録する。

doneでなければworks.statuspublish_pendingにし、Worker側の完了処理を待つ。

DELETE /works/:id

投稿を削除する。

物理データを即座に消去せず、works.statusdeletedにする論理削除とする。

Cocot Storage上のファイル自体を物理削除するかどうかは、未確定事項として後述する。

GET /works/:id, GET /works(一覧・検索)

投稿の詳細取得、およびキーワード・タグによる一覧取得を提供する。

status = publishedの投稿のみを対象とする(下書きや削除済みは本人以外に見せない)。

TUSアップロードとの連携

PreCreateフック

クライアントは、TUSアップロード開始リクエストのUpload-Metadatawork_idを含める。

サーバーはPreCreateフックで次を検証する。

  • work_idが存在し、リクエストの認証ユーザーが所有していること
  • 対応するworks.statusdraftであること(公開済みや削除済みの投稿への再アップロードを防ぐ)
  • works.upload_idがまだ設定されていないこと(同じ下書きに対する二重アップロードを防ぐ)

検証を通過したら、uploads行を作成し、work_id列にひも付ける。

同時にworks.upload_idをこのuploads.idで更新する。

この二つの書き込みは同一トランザクションで行う。

アップロード完了時(CompleteUploadsフック)

既存のmain.goにあるCompleteUploadsチャンネルの受信処理を拡張する。

同一トランザクション内で、次の二つを行う。

  1. uploads.statustranscodingに更新する
  2. Riverの変換ジョブをinsertする(river.Client.InsertTxを使い、pgxのトランザクションを共有する)

ジョブのペイロードには、最低限upload_idを含める。

work_idはWorker側でuploads.work_idから辿れるため、ペイロードに重複して持たせる必要はない。

トランザクションをコミットした後にRiverキューへ通知することで、DB更新とジョブ投入の不整合(更新だけ成功してジョブが投入されない、あるいはその逆)を防ぐ。

Worker側の完了処理

Workerはジョブを受け取ったら、変換処理の実装(本書の対象外)を経て、次のDB更新を単一トランザクションで行う。

  1. uploads.statusdone(失敗時はfailederror_messageに理由を記録)に更新し、converted_keyを記録する
  2. uploads.work_idから対応するworks行を取得する
  3. works.statuspublish_pendingであれば、publishedに更新しpublished_atを記録する。draftのままであればworks.statusは変更しない(ユーザーがまだ公開操作をしていないため)

sqlc構成

Backend、Workerともに単一のGoモジュール(cocot/backend)に属するため、sqlcの生成コードは共有できる。

backend/db/配下に、次の構成を想定する。

backend/db/
schema/ -- マイグレーション用DDL(既存のリネーム連番方式を継続)
queries/ -- sqlc用のクエリ定義(*.sql)
sqlc.yml -- 生成設定

sqlc.ymlは現状空であるため、queries/配下にクエリを書き始める段階で合わせて設定する。

生成先パッケージはBackend、Worker双方からimportできる場所(例: backend/db/gen)に置く。

未確定事項

  • タグ名の表記揺れ(大文字小文字、全角半角)をどう正規化するか
  • 投稿削除時、Cocot Storage上の音声ファイルとサムネイルを物理削除するか、保持したまま参照だけ切るか
  • アップロード中の音声ファイル差し替え(一度TUSアップロードを開始した後、選び直したい場合の挙動)
  • Riverジョブの再試行回数やタイムアウトの方針
  • PATCH /works/:idの更新頻度が高い場合(アップロード中の自動保存)のレート制限の要否