本資料はPCでの閲覧を推奨しています。

PDF生成中...

DESIGN PROPOSAL
tocme スピーキングテスト
招待リンク発行機能
技術設計とトレードオフ分析
提出日
2026-05-25
対象
tocme_assistant 開発チーム
ステータス
設計確定(実装前)
1
第1部
機能定義
サマリー ─ 設計15項目 ─ ユーザーフロー
SECTION 1-1
サマリー
先生が作成したスピーキングテストを、生徒が短URLから受験できる仕組み
1
リンク = 1クラス分
60
リンク有効期限(固定)
同一生徒の再受験
100
1リンクの累計受験上限
スコープ
・先生UI: サイドナビに「リンク発行」ブランチ新設 + 設定編集画面 + URLモーダル
・生徒UI: 名前入力 → 設定画面(先生UI流用・変更可) → 既存テスト画面 → 評価
・DB: talkSessionInvitations 新設(top-level)+ teachersリンク発行用デフォルト設定フィールド群追加
・Auth: Firebase 匿名認証 + invitationId検証
SECTION 1-2
確定した設計(17項目)
#分岐決定内容
1目的受験招待リンク(先生がテストを作成 → 生徒がURL経由で受験)
2粒度1リンク = クラス全員共有(Google Forms型)
3生徒認証匿名 + 名前入力 + URLトークン
4受験画面tocme_assistant 内の未認証ルート(既存資産を再利用)
5DBスキーマtalkSessionInvitations 新設(top-level) + schools/{id}/talkSessions.invitationId 追加
6バックエンド認証Firebase 匿名認証 + invitationId検証(共用アカウント案は棄却)
7URL形式/take/:code + nanoid 8桁英数字
8先生UI配置サイドナビに「リンク発行」ブランチ新設 + 専用設定画面 + 設定変更時 teachers 自動保存
9評価表示既存と同じ評価画面を生徒にも表示
10重複受験無制限・全履歴保存
11期限固定1時間
12期限切れ挙動受験開始済みは完走可、新規アクセスのみ拒否
13入力フォーム名前のみ自由入力(1〜30文字)
14発行後UIシンプルなURL+コピーボタンのみ(QR/コード/カウントダウンなし)
15abuse対策invitation ごとの累計受験者数上限(デフォルト100件 / 要相談)
16先生デフォルト設定teachersに永続化。数値系(会話回数/速度/厳しさ/学年)は既存speakingTest設定と共用、選択系(テーマ/文法/単語/AIキャラ)はリンク発行画面専用フィールド invitationDefault* を新設
17生徒側の設定変更リンク発行時のsnapshot値で初期化、生徒は受験前に自由に変更可。その受験回のみ反映(teachers無影響)
SECTION 1-3
ユーザーフロー

先生フロー

1
サイドナビ起動
「リンク発行」ブランチを選択
2
設定編集
文法・単語・テーマ・AI設定(teachersから初期値復元、変更で即時自動保存)
3
リンク発行
「リンクを発行する」ボタン押下(その時点の設定でsnapshot保存)
4
配布・確認
URLコピー → Slack/Classroom/板書で生徒へ。配布後は既存履歴で結果確認可

生徒フロー

1
URL開く
/take/8h3kn2qx
2
トークン照合
有効性チェック(期限/上限)
3
名前入力
1〜30文字
4
設定画面(NEW)
既存speakingTest設定UI流用、初期値=invitation snapshot、生徒が変更可
5
受験
既存スピーキングテスト画面(再利用)
6
評価
既存と同じ評価画面表示
既存資産の再利用率が高い
受験〜評価画面(WebRTC・Live2D・OpenAI Realtime・採点ロジック)は既存コードをそのまま使用。新規実装は「招待リンクの入口と結果保存の紐付け」のみ。
2
第2部
アーキテクチャ
データスキーマ ─ 認証モデル比較 ─ abuse対策 ─ URL改ざん防御 ─ 補足:匿名認証
SECTION 2-1a
データスキーマ(リンク経由)
既存スキーマ実態: schools/{schoolId}/talkSessions/{talkSessionId}/talkLogs/{...}。speakingTestとsmallTalkは同一talkSessionsを共有(kindフィールドで識別: 1=speakingTest)。studentNamesはList(ペアモード対応)。

【新設】talkSessionInvitations(top-level)

フィールド説明
invitationIdFirestore auto-ID
codenanoid 8桁(URL用、unique)
schoolId / teacherId発行者の学校・先生
kind1=speakingTest(将来smallTalk拡張余地)
aiCharacterIdテスト設定(既存talkSessionと同形式)
expiresAtcreatedAt + 1時間(固定)
maxUsageCount / usedCount上限100 / 受験開始時atomic increment
createdAt作成日時
※top-level配置: 未認証生徒がschoolIdを知らずにcodeだけで照合できるよう必須

【既存】schools/{id}/talkSessions(1フィールド追加のみ)

フィールド説明
talkSessionId既存
schoolId / teacherId既存(invitationから両方コピー)
studentNames (List)既存。リンク経由は[名前]1件で保存
kind / aiCharacterId / grammarIds 他既存(invitation値をコピー)
score / goodPoint / advice既存(採点結果)
invitationId NEW招待リンク経由のみ値あり(直接受験はnull)
talkLogs/ サブコレクション既存(会話ログ)
管理画面・採点ロジック・履歴UIは 1行も変更不要
リンク経由絞り込みは where invitationId == X を1行足すだけ。
SECTION 2-1b
データスキーマ(teachers 更新)
先生のデフォルト設定を teachers ドキュメントに永続化(リンク発行画面の初期値復元用)

【更新】schools/{id}/teachers/{tid}(リンク発行用フィールド追加)

フィールド説明
speakingTestAiResponseCount 他(既存4項目)既存speakingTest設定と共用(数値系: 会話回数 / 発音厳しさ / AIスピード / 学年モード)
invitationDefaultThemeIds NEWリンク発行画面で選択したテーマIDの永続化
invitationDefaultGrammarIds NEWリンク発行画面で選択した文法IDの永続化
invitationDefaultWordIds NEWリンク発行画面で選択した単語IDの永続化
invitationDefaultCustomWordTexts NEWリンク発行画面で自由入力した単語テキスト
invitationDefaultCharacterIndex NEWリンク発行画面で選択したAIキャラのインデックス
数値系(共用)の挙動
先生がリンク発行画面で会話回数等を変更 → 通常 speakingTest 画面にも反映される。「先生のデフォルト設定は1つ」というシンプル設計。
選択系(独立)の挙動
テーマ・文法・単語・AIキャラはリンク発行画面でのみ永続化(既存 speakingTest 画面では未保存のまま)。次回リンク発行画面を開いたときの初期値として復元。
生徒側は talkSessionInvitations の snapshot を初期値として受験画面に表示。生徒が変更してもその受験回限り(teachers には影響なし)。
SECTION 2-2
認証モデル比較(最重要分析)
未認証生徒が OpenAI Realtime・採点・結果保存を叩く方式を比較
案A: 共用先生アカウントに全員ログイン 採用不可
具体的に起きる事故
①パスワード漏洩 = 全データ筒抜け
共用パスワードはリンクと一緒に何百人に配布される → 事実上の公開情報。
生徒1人が漏らしたら誰でも先生画面にログイン可、過去全クラスのtalkSessions(名前/スコア/会話履歴)を吸い出せる。
②パスワード変更で全員強制ログアウト
別の先生Bがそのアカウントのパス変更 → Firebase Authが全トークン即時失効 → 受験中の生徒が一斉切断 → 結果保存前のセッションが全消失
③Firestoreルールが書けない
「先生は自分のクラスのみ読み書き可」を書きたいが、全員同じUIDなのでrequest.auth.uid == teacherId判定が無意味化。ルールを緩めるしかない → セキュリティルール総崩れ。
案B: Firebase 匿名認証 + invitationId検証 採用
具体的な振る舞い
①パスワード公知化が起きない
匿名Authは内部で自動生成・即破棄される使い捨てトークン。共有パスワードという概念自体が存在しない → 流出・連鎖ログアウト・データ漏洩のリスクが構造的に不可能。
②先生データには手が届かない
Firestoreルールで token.firebase.sign_in_provider != 'anonymous' 判定 → 匿名ユーザーは学校データ・他生徒のリスト/履歴/設定が一切返らない。クライアント改造しても無効。
③既存関数を改修ゼロで利用可
proxyOpenAirequest.auth を見るだけ → 匿名Authでもパス。クライアントは signInAnonymously() 1行追加で完了。
④先生アカウントと完全分離
生徒の事故・BAN・料金が先生アカウントに混入しない。Firebase Extensions「Delete Anonymous Users」で7日後に自動削除されストレージも肥大化しない。
SECTION 2-3
abuse・コスト爆発対策
前提コスト: OpenAI Realtime 入力 $0.06/分 + 出力 $0.24/分
正常時: 1セッション ≈ 6分(12往復で自動終了)→ 約$0.90/セッション → 100セッション約$90/リンク
放置時: 既存実装に無音/離脱検知なし → 接続維持中もAI促し応答で課金継続 → 1セッション$0.5〜$2の純粋無駄

V1で導入する対策

対策実装方法効果
累計受験者数の上限Welcome画面「テストを始める」押下時に usedCount atomic increment、超過時は新規拒否1リンクの被害が有限(デフォルト100件)
1時間自動失効サーバー側で expiresAt をチェック放置時の被害ウィンドウを最小化
会話往復上限(既存)conversationLimit (デフォルト12往復) で自動終了1セッションのコストを6分以内に固定
無音検知タイムアウト NEWクライアント側で90秒無音 → セッション自動終了。結果は保存せず破棄(採点呼出も発生させない)放置接続の自動切断、無駄課金停止
セッション最大時間 NEW受験開始から10分でforce close(無音検知の最後の砦)異常状態でも被害が10分で必ず止まる
タブ閉じ時の切断(既存)明示close処理は無し。ブラウザがRTCPeerConnectionを暗黙close → OpenAI server がICE timeout検知 → 数秒〜数十秒で session終了・課金停止新規実装不要、既存挙動で十分
invitationId検証全関数で「invitation有効性」を必須チェック関数直叩きも受験設定なしには動かない
OpenAIホワイトリスト(既存)既存 proxyOpenAi のエンドポイント絞り込みを継続活用許可されたAPI以外は中継しない
SECTION 2-4
URL改ざん防御(3層)
生徒が /take/... 以外のパスを打ち込んでも、speakingTest 以外触らせない
第1層 ルーターガード
クライアント側 / go_router
匿名ユーザー(=リンク経由生徒)は /take/* 以外アクセス不可。
/teacher-dashboard/settings 等を打ち込んでも「リンクが見つかりません」エラー画面に強制リダイレクト。
redirect: (c, s) {
  if (isAnonymous &&
    !path.startsWith('/take/'))
    return '/take/not-found';
}
第2層 Firestoreルール
サーバー側 / DBアクセス制御
匿名ユーザーは talkSessions への 直接書込不可。書込みは必ず Cloud Functions 経由のみ。先生・学校データもread/write一切不可。
match /schools/{sId}/{doc=**} {
  allow read, write: if
    token.sign_in_provider
    != 'anonymous';
}
第3層 Cloud Functions
サーバー側 / 関数ガード
proxyOpenAi・採点系関数は「invitationIdがvalid」必須チェック。
匿名Auth持っているだけでは何も叩けない。期限切れ・上限到達のリクエストはここで遮断。
if (!await validateInvitation(
  data.invitationId
)) throw HttpsError(
  'permission-denied');
多層防御の意義: いずれか1層が破られても残り2層で防げる。クライアント側ガードはUX用、実セキュリティは第2層・第3層で担保。
SECTION 2-5
補足: Firebase 匿名認証 (Anonymous Auth) とは
名前・メアド・パスワードなしで Firebase ユーザーアカウントを発行する仕組み

仕組み

signInAnonymously() 呼出
Firebaseサーバーが新規UID生成(例: anon_xY9k...)+ JWT発行
JWTはブラウザの localStorage に保存
次回起動時、JWT読込み → 同じUIDで自動継続

「再ログイン」できる?

条件結果
同じデバイス・同じブラウザ・localStorage残存同じUIDで継続
localStorageクリア / シークレットモード新規UID発行
別デバイス・別ブラウザ新規UID発行
アプリ再インストール (mobile)新規UID発行
⚠️ 一度失ったUIDを取り戻す方法は 存在しない(Firebase公式: "There is no way to restore")。

普通の使い道

ユースケース
ショッピングカート登録前に商品を入れておく → 後で signup したらカート維持
ゲストモード登録なしで試用、後で本登録に昇格
ゲーム進捗保存signup なしでも進捗保存、リスクは喪失
レビュー/コメント投稿気軽に書ける、後で身元証明したくなったら昇格
tocme での用途招待リンク経由生徒の使い捨て認証

永続化したい場合

linkWithCredential() を呼ぶと、匿名アカウントを Email/Google/Apple 等のID Providerに **紐付けて永続化**できる。
UIDは同じまま保持される
・既存データ(カート/進捗)はそのまま使える
・tocmeでは 永続化しない(リンクは使い捨てが意図)→ Extensions で7日後に自動削除
tocmeでの位置づけ: 受験の60分だけ匿名UIDで関数を叩く → 受験完了したら捨てる。「アカウント」というより「一時パス」に近い使い方。データ復旧は不要なので消失リスクは問題にならない。
3
第3部
UI/UX設計
先生側UI ─ 生徒側UI
SECTION 3-1
先生側UI

配置場所: サイドナビ新ブランチ

// side_navigation.dart
┌─────────────────┐
│ [logo] │
│ Small Talk │
│ Speaking T. │
│ Game │
🆕リンク発行 │ ← NEW
├─────────────────┤
│ Setting │
│ log out │
└─────────────────┘
既存 SideNavigation に新規ブランチ追加(StatefulShellRoute 4→5 ブランチ拡張)。専用画面では既存 SpeakingTestSubNavigation を流用し、onStart の代わりに onCreateInvitation を呼ぶ。設定UIは100%再利用。

発行後モーダル(シンプル設計)

リンクを発行しました
この URL を生徒に共有してください
https://tocme-prod.web.app/take/8h3kn2qx
QR・短コード単独表示・カウントダウンは V1 スコープ外。
必要に応じて V2 で追加可能(後付けコスト低)。
受験ログ一覧: 既存の talkSessions 一覧画面に「リンク経由」バッジを追加するのみ。並び替え・フィルタは V2 で拡張可能。
SECTION 3-2a
生徒側UI(画面遷移フロー)

画面遷移(6ステップ)

1
Welcome画面(新規)
・テスト概要表示
・名前入力(1-30文字)
・「テストを始める」ボタン
2
設定画面(NEW)
既存 speaking_test_screen 流用
初期値=invitation snapshot
生徒が編集可
3
受験前画面
既存 before_speaking_test_screen 再利用
6
完了画面(新規)
・[ もう一度受験 ] → Welcome画面に戻る
・[ 終わる ] → 「お疲れさまでした」
5
評価画面
既存 speaking_test_evaluation_screen 再利用
4
受験画面
既存 speaking_test_with_ai_screen 再利用
設定UI/受験前/受験/評価=既存流用、新規実装は Welcome / 完了 / エラー3種の計5画面
SECTION 3-2b
生徒側UI(エラー画面)
エラー画面は新規実装3種(リンク不正・期限切れ・定員到達)
リンクが見つかりません
トークン不正・存在しないコード。
URL確認を促す。

遷移条件: getInvitationnot-found エラー
期限が切れています
作成から1時間経過。
先生に新しいリンク発行を依頼するよう案内。

遷移条件: getInvitation / incrementInvitationUsagefailed-precondition エラー
定員に達しました
累計受験者数が上限到達。
先生にご連絡するよう案内。

遷移条件: getInvitation / incrementInvitationUsageresource-exhausted エラー
エラー画面は InvitationErrorScreen(errorType: ...) 単一 Widget で実装。3種の表示は enum 切替(アイコン・色・文言を出し分け)。
4
第4部
運用・実装
運用ルール ─ 実装スコープと次ステップ
SECTION 4-1
運用ルールとエッジケース
シーン挙動意図
リンク発行即座にURL生成(1時間有効)授業中の即時利用に最適化
生徒がURLクリック匿名サインイン → トークン照合 → Welcome画面シームレスな入口
受験開始時に期限切れ新規受験を拒否、エラー画面不正利用防止
受験中に期限切れ(60分経過)最後まで完走可、結果保存OK「あと少しで終わるのに消えた」事故防止
同一生徒の再受験無制限に許可、全履歴保存練習用途・学習効果向上
累計100件超過新規受験拒否、エラー画面コスト爆発防止
受験完了既存と同じ評価画面、即時フィードバック学習モチベーション維持
同名複数生徒全件保存(区別は createdAt + talkLogsV1はシンプル運用、V2で出席番号併用検討
ネットワーク切断既存テストと同じ挙動(再接続トライ)既存実装に依存
リンク再発行新規 invitation 作成(旧リンクは1時間後失効)シンプル運用、無効化APIは V2
先生が設定変更即時自動保存。数値系=既存speakingTest設定フィールド更新(speakingTest画面にも反映)、選択系=invitationDefault系フィールド更新毎回選び直し不要、UX向上
生徒が設定変更受験回のみ反映。teachers無影響、invitation snapshot も更新しない先生の意図と異なる設定で受験させたい場合の柔軟性
リンク発行ボタン押下その時点の設定値で talkSessionInvitations にsnapshot保存後から先生がデフォルト変更しても既発行リンクには影響なし
SECTION 4-2a
実装スコープ
カテゴリ内容規模
FirestoretalkSessionInvitations新設(top-level), schools/{id}/talkSessions.invitationId追加, rules更新 + teachersinvitationDefault* フィールド群追加(選択系の永続化用)
Cloud Functions (新規)createInvitation (先生Auth要), getInvitation (匿名可), incrementInvitationUsage (上限+atomic)
Cloud Functions (改修)proxyOpenAi & 採点系関数に invitationId 検証ガード追加
Routing/take/:code 未認証ルート追加 + 先生用 StatefulShellRoute に「リンク発行」ブランチ追加(4→5ブランチ) + redirect 例外設定
UI 生徒側Welcome画面 + 設定画面(既存 SpeakingTestSubNavigation 流用、編集可)+ 完了画面(再受験/終わる)+ エラー画面3種(リンク不正・期限切れ・上限超過)
UI 先生側サイドナビ「リンク発行」ブランチ追加 + 専用設定画面(既存 SpeakingTestSubNavigation 流用)+ リンク発行モーダル ※「リンク経由」バッジ表示は tocme_admin 側で別タスク
Auth/take/ ルート初期化時に signInAnonymously()
Firebase Extensions「Delete Anonymous Users」導入(7日経過分自動削除)
SECTION 4-2b
次ステップと未確定の細目
本資料の承認 → 詳細計画 → 実装 → dev検証 → 本番リリース
次ステップ
1. 本資料の承認
2. planner エージェントで詳細実装計画(ファイル単位)
3. coder/tester/reviewer での標準ワークフロー実行
4. dev-tocme-assistant 環境で検証 → 本番リリース
未確定の細目(実装中に詰める)
・累計上限のデフォルト値(100件で開始 / 要再考)
・URLのカスタムドメイン(現状 web.app)
・先生側ログ一覧のフィルタ詳細
・welcomeに表示するテスト概要の項目選定
・invitationDefault系フィールドの粒度(1つのMapオブジェクト vs 個別フィールド)
実装はフェーズ分割推奨: Phase 1 基盤層 (Firestore + CF + Auth + Routing) → Phase 2 先生UI → Phase 3 生徒UI → Phase 4 abuse対策 → Phase 5 運用補助