v1
PoC / 設計方針

minedia-www 変更点

インタビュールーム(WebRTC機能)を新基盤(core)へ切り出した後、minedia-www(調査ドメイン)側で何がどう変わるかの設計方針と実装フロー。GitHub Issue #5317。

全体像 — 何が core へ移り、何が残るか

core へ移るもの

Room / Session / Recording / Transcription / 入退室・チャットログ

WebRTC 接続・録画・文字起こし・ルーム内ログは新基盤が単一の事実根拠(source of truth)を持つ。minedia-www は uuid 参照と REST API / Webhook で受ける。

minedia-www に残るもの

調査ドメイン:オファー / 確定 / 出欠 / 謝礼 / 配信 / 振り返り画面

調査業務ロジックと、オペレーター/従業員向けの管理画面(振り返り)は残す。録画・ログは core API から取得して同一 UI で描画する。

設計方針案 — 5つの論点

🗓️

下表は現時点の方針案であり、最終決定は明日の協議で行う。(2026-06-19 の /dig セッションで Operator 管理画面・コードを実査して導いた叩き台)

「Operator として実ログインし、Room/録画をどう読んでいるか」を実査したうえで、5つの設計分岐の方針案をまとめた。これらは互いに整合し、「外部化 Slot ごとの薄いローカル参照モデル」へ一意に収束する想定。

ID論点方針案
DEC-01二重運用期(OpenTok 既存+新基盤)の既存コード呼び出し統一インターフェースで吸収。Slot#interview_roomOpentokRoomLivekitRoom アダプタを返し、呼び出し側をエンジン非依存にする
DEC-02事後処理(要約/分析/閲覧認可)の Recording への繋ぎ直しrecording_uuid(文字列参照)へ。物理 FK belongs_to :ot_archive を廃止
DEC-03UI/ジョブの読み取り経路混合:詳細=core API on demand / 一覧・集計の hot 列だけローカル非正規化キャッシュ
DEC-04振り返り管理画面(チャット履歴/入退室/録画一覧/再生/発言録)の所在minedia-www に残し core API 取得。二重運用期も同一 UI で両エンジンを見る
DEC-05状態同期の権威役割分担:Room 解放/入室可否/録画=core、オファー/確定/謝礼/出欠=調査。Webhook で双方向同期

アンカーモデル — 薄いローカル参照モデル InterviewRoomRef

5つの方針案は 外部化 Slot ごとの薄いローカル参照モデルに収束する。これが「統一IF のアダプタ先」「一覧用キャッシュ」「詳細 API 用 uuid 保持先」「Webhook 更新先」を兼ねる。新テーブル interview_room_refs(仮称・別名 LivekitRoom)を追加する。

Slot / ExtemporarySlot
id調査側スロット
…既存列無変更
1 : 1二股FK
interview_room_refs (別名 LivekitRoom)
アンカー(二股FK・どちらか一方)
slot_idSlot に 1:1
extemporary_slot_id即席 Slot に 1:1
core uuid 参照(事実は core が正)
core_session_uuid詳細API・Webhook 突合
core_room_uuid録画/ログ参照
access_token入室URL(core 発行)
非正規化キャッシュ(一覧・集計用)
cached_session_status一覧描画
cached_recording_status録画あり/失敗
cached_total_duration_sec録画時間合計
cached_recording_uuidsdownload_url 起点
last_synced_at同期鮮度
uuid 参照API / Webhook
core(新基盤)
Session↔ core_session_uuid
Room↔ core_room_uuid
Recording[]↔ cached_recording_uuids

索引:unique [slot_id] / unique [extemporary_slot_id] / unique [core_session_uuid]OpenTok legacy 案件はこの行を持たず slot.room で従来どおり。新エンジン案件のみ1行持つ。エンジン選択は projects.webrtc_engineopentok/livekit・default opentok)で持ち、Slot が継承する。

なぜ「薄い参照モデル」か

  • Slot に列直付けだけでは、詳細 API キャッシュ・録画 uuid 配列・同期鮮度の置き場が散らかる。1:1 付帯テーブルに隔離するほうが移行の継ぎ目が明確。
  • core の完全ローカルミラーは「事実は core が正、調査は uuid 参照」(DEC-02)と矛盾し整合性責務が重い。採らない。
  • 薄い参照モデルは「事実は core が正」を保ちつつ一覧性能だけローカルで担保する最小の方法。

録画・事後処理の繋ぎ直し(DEC-02)

現状、録画後加工の各モデルが belongs_to :ot_archive(物理 FK)。外部化後は recording_uuid 参照へ。

モデル現状To-Be
TranscriptionRequestbelongs_to :ot_archivecore へ移管。調査側からは廃止。必要なら transcription_uuid 参照
StatementSummaryRequest(要約)belongs_to :ot_archiverecording_uuidtranscription_uuid を保持。自前 slot_id/project_id も保持(local JOIN 用)
ProcessedOtArchivebelongs_to :ot_archiverecording_uuid 保持
OtArchiveSummarybelongs_to :ot_archiverecording_uuid 保持
OtArchiveAccess(録画閲覧認可)belongs_to :ot_archiverecording_uuid。認可主体は 未決(DEC-08)

動画再生URL:ot_archive.video_s3_key → 新エンジンは core の GET /recordings/{id}/download-url(短命の署名S3 URL)を取得。旧 OpenTok 案件は ot_archive のまま(二重運用)。

統一インターフェース(DEC-01)と読み取り書き換え

Slot#interview_room がエンジンに応じてアダプタを返し、呼び出し側を非依存にする。

Slot#interview_room
  ├─ engine == opentok → OpentokRoom.new(room)        # 既存ローカル Room をラップ・無変更
  └─ engine == livekit → LivekitRoom.new(interview_room_ref, core_api_client)

両アダプタが共通インターフェースに応答する:
#chat_messages(usage:) / #entry_histories / #recordings / #playback_events / #access_url(role:) / #movie_duration_sec

各アダプタの実装イメージ

OpentokRoom — 既存のローカル Room / OtArchive をそのまま読む薄いラッパ。挙動は無変更。

class OpentokRoom
  def initialize(room) = @room = room

  def chat_messages(usage:) = @room.room_chat_messages.where(usage:)
  def entry_histories       = @room.room_entry_histories
  def recordings            = @room.ot_archives
  def playback_events       = @room.playback_events
  def access_url(role:)     = @room.room_access_token(role)
  def movie_duration_sec    = @room.ot_archives.sum(:file_duration_sec)  # DB集計
end

LivekitRoom — 一覧・集計は interview_room_refs の cache 列で即返し、詳細はその場で core API を叩く(on demand)。API 障害時はデグレード値を返す。

class LivekitRoom
  def initialize(ref, client)
    @ref, @client = ref, client
  end

  # 一覧・集計:API を叩かず cache 列で即返し
  def movie_duration_sec = @ref.cached_total_duration_sec

  # 詳細:core API on demand。障害時は「一時的に取得できません」+uuid
  def chat_messages(usage:)
    @client.get("/rooms/#{@ref.core_room_uuid}/chat-messages", channel: usage)
  rescue Core::ApiError
    Core::Degraded.new(@ref.core_room_uuid)
  end

  def entry_histories   = @client.get("/rooms/#{@ref.core_room_uuid}/entry-histories")
  def recordings        = @client.get("/rooms/#{@ref.core_room_uuid}/recordings")
  def playback_events   = @client.get("/rooms/#{@ref.core_room_uuid}/room-events", type: "handout")
  def access_url(role:) = @client.post("/room-access-tokens", room: @ref.core_room_uuid, role:)
end

主な読み取り書き換え

箇所現状To-Be
振り返り画面 (B)@room.room_chat_messages 等を直接@interview_room.chat_messages(usage:)。Livekit は core API on demand
ダッシュボード集計 (C)joins(ot_archive→ot_session→room→slot)Room ホップ除去:local slot_id/project_id で JOIN、duration/status は cached_*
一覧 N+1 prefetch (C)includes(room: :ot_archives)includes(:interview_room_ref) でキャッシュ列を読む
録画時間集計 (C)joins(room: :ot_archives).sumcached_total_duration_sec を集計
🛡️

API on demand の劣化設計:詳細 (B) で core API が落ちている場合、チャット/録画セクションのみデグレード表示(「一時的に取得できません」+ uuid 表示)。ページ全体は調査データで描画を継続する。

同期 — Webhook 役割分担(DEC-05)

状態を2軸に分け、衝突しないよう権威を分界する。

権威同期方向
Room 解放 / 入室可否 / 締切 / 録画coreSession.statuscore → 調査へ Webhook(事実通知)
オファー / 確定 / 出欠 / 謝礼調査AnswerSlot.status調査内で完結。退室イベントは core → 調査がトリガー

連携インターフェース

連携点方向minedia-www 側の実装
Session/Room 作成調査 → core(命令)Slot 作成後に POST /sessions。失敗は outbox + リトライ
入室トークン発行調査 → core(命令)配布/入室時に POST /room-access-tokens で participant JWT 払い出し
緊急発行(force の後継)オペ → core(命令)generate_force_access_token を core 緊急発行 API に置換
キャンセル → Room 解放調査 → core(命令)AnswerSlot キャンセル時に DELETE /sessions/{id}
録画完了 recording.status_changedcore → 調査(Webhook)recording_uuid/duration/statuscached_* と事後処理へ反映
退室 participant.leftcore → 調査(Webhook)謝礼/コメント表示トリガー・出欠
session.endedcore → 調査(Webhook)cached_session_status 更新
文字起こし完了 transcription.completedcore → 調査(Webhook)要約/集計ジョブのトリガー
🧯

障害・整合性:作成時 core ダウン → Slot は保存成功させ Session 作成は outbox で非同期リトライ(入室URLは「準備中」表示・調査業務は止めない)。Webhook 欠落 → 定期 polling fallback でリコンサイル(last_synced_at で鮮度監視)。cached_* は一覧用ヒントに留め、課金根拠は常に core を正として詳細時に再取得。

実装フロー

主軸は A主軸ハイブリッド:既存を壊さない継ぎ目(統一IF)を先に完結させ、新エンジン部は契約モックで先行 TDD し、core を待つ。core 依存度で4フェーズに割れる。

Phase 0seam リファクタcore 非依存・最優先

統一IF Slot#interview_room を導入し、既存 OpenTok Room を OpentokRoom でラップ。この段階では常に OpentokRoom を返す(engine 分岐なし)。

主なタスク
  • OpentokRoom クラスを追加し、共通IF 6メソッドを既存 Room/OtArchive 委譲で実装
  • Slot#interview_room を追加(暫定で常に OpentokRoom.new(room) を返す)
  • 振り返り画面と ot_archives_controller@interview_room.* 経由へ書き換え
  • OpentokRoom の IF 単体テスト+既存リグレッションテストを通す

受け入れ:挙動完全無変更・全テスト緑(純リファクタ)。

Phase 1スキーマ migrationcore 非依存

エンジン分岐・薄い参照モデル・録画事後処理の繋ぎ直しのスキーマを用意する。新列は未使用(default/null)でも既存集計が壊れない状態にする。

主なタスク
  • migration: projects.webrtc_engine / extemporary_folders.webrtc_engine(default opentok)
  • migration: interview_room_refs(二股FK・uuid群・cached列・unique 索引)+ モデル/関連定義
  • migration: 事後処理4モデルへ recording_uuid / 自前 slot_id / project_id を追加
  • 物理FK belongs_to :ot_archive を nullable 化(廃止は Phase 3)
  • (C) のローカルJOIN / cache 集計経路を engine 分岐で用意(ダッシュボード・一覧・録画時間)

受け入れ:migration 可逆・既存 OpenTok 経路は無変更で緑。

Phase 2新エンジン部 contract-first TDDcore OpenAPI 公開が着手条件

LivekitRoom 経路一式を core 契約に対して mock で完成させる。契約逸脱を mock で検出できる状態にする。

主なタスク
  • core 公開 OpenAPI を vendoring、openapi_parser+WebMock で契約テスト土台を用意
  • Core::ApiClient 実装(ApiKey 認証・Idempotency-Key・outbox パターンのリトライ)
  • LivekitRoom を共通IFで実装(cache 即返し+ API on demand + 障害時デグレード)
  • Webhook receiver +同期ハンドラ(録画完了/退室/session.ended/文字起こし完了 → cached_*・事後処理)
  • Slot#interview_room の engine 分岐を有効化(livekit 案件は LivekitRoom を返す)

受け入れ:mock 相手に「作成→入室URL→(B)読み取り→Webhook 同期」が契約テストで緑。

Phase 3core 結合core 稼働待ち・現状ブロック

mock を実 core へ差し替え、二重運用を経て完全移行する。core 稼働でアンブロックされる唯一のフェーズ。

主なタスク
  • mock → 実 core へ差し替え、1案件を新エンジンで e2e(作成→配布→入室→録画→振り返り→事後処理)
  • 二重運用投入:どの案件を opentok/livekit で回すかの運用ルールを確定
  • 完全移行:物理FK belongs_to :ot_archive を廃止、legacy Room/OtSession/OtArchive を read-only 化
  • 過去データ(録画・チャット・入退室)の core 移送 or read-only 残置の範囲を確定

依存関係

Phase 0 (seam) ──┐
                 ├─► Phase 2 (contract-first, mock) ──► Phase 3 (core 結合) ★core稼働待ち
Phase 1 (schema)─┘
  • Phase 0 / 1 は core 非依存で先行完了できる。
  • Phase 2 は core の OpenAPI 公開が着手条件(gating)=core の稼働サービスは不要だが契約公開は必要(minedia-www は pure consumer)。
  • Phase 3 のみ core 稼働がブロッカー。

本ページは設計ドキュメントであり、本番コード(app/, db/)は変更しない。GitHub Issue #5317。