graph TB
subgraph Browser["ブラウザ (Smalruby Editor)"]
GUI["scratch-gui<br/>React / Redux"]
API_CLIENT["classroom-api.js<br/>API クライアント"]
TEACHER_AUTH["teacher-auth.js<br/>認証抽象化 (Google / Microsoft)"]
GC_AUTH["google-classroom-auth.js<br/>Google Classroom OAuth"]
end
subgraph AWS["AWS (ap-northeast-1)"]
APIGW["API Gateway HTTP API"]
LAMBDA["Lambda (Node.js 20)"]
DDB_C["DynamoDB<br/>Classrooms"]
DDB_M["DynamoDB<br/>ClassroomMemberships"]
DDB_S["DynamoDB<br/>ClassroomSubmissions"]
S3["S3<br/>提出ファイル"]
end
subgraph GCP["Google Cloud Platform"]
GAUTH["Google OAuth 2.0<br/>(ID Token 検証)"]
GC_API["Google Classroom API"]
end
subgraph Azure["Microsoft Azure"]
MSAUTH["Microsoft Identity Platform<br/>(ID Token 検証)"]
end
GUI --> API_CLIENT
GUI --> TEACHER_AUTH
GUI --> GC_AUTH
API_CLIENT -->|"HTTPS"| APIGW
TEACHER_AUTH -->|"Google ログイン"| GAUTH
TEACHER_AUTH -->|"Microsoft ログイン"| MSAUTH
GC_AUTH -->|"OAuth 同意"| GAUTH
APIGW --> LAMBDA
LAMBDA --> DDB_C
LAMBDA --> DDB_M
LAMBDA --> DDB_S
LAMBDA -->|"Presigned URL 生成"| S3
LAMBDA -->|"Google ID Token 検証"| GAUTH
LAMBDA -->|"Microsoft ID Token 検証 (JWKS)"| MSAUTH
LAMBDA -->|"コース取得 / 課題投稿"| GC_API
Browser -->|"Presigned URL で直接アップロード"| S3
sequenceDiagram
participant T as 先生 (ブラウザ)
participant S as 生徒 (ブラウザ)
participant API as Lambda
participant G as Google OAuth
participant MS as Microsoft Identity
participant DB as DynamoDB
Note over T,G: 先生の認証 (Google or Microsoft)
alt Google ログイン
T->>G: Google ログイン
G-->>T: ID Token (iss: accounts.google.com)
else Microsoft ログイン
T->>MS: MSAL popup ログイン
MS-->>T: ID Token (iss: login.microsoftonline.com)
end
T->>API: Authorization: Bearer {idToken}
API->>API: iss クレームで Google / Microsoft を自動判別
alt Google
API->>G: verifyIdToken(idToken)
G-->>API: { sub: "teacher-google-id" }
else Microsoft
API->>MS: JWKS で JWT 検証
MS-->>API: { oid: "teacher-microsoft-id" }
end
API->>DB: teacherSub で操作
Note over S,DB: 生徒の認証 (Session Token)
S->>API: POST /classrooms/join {joinCode, seatNumber}
API->>DB: 席番号チェック & メンバー登録
DB-->>API: OK
API-->>S: {sessionToken: "uuid-..."}
S->>API: Authorization: Bearer {sessionToken}
API->>DB: sessionToken-index で検索
DB-->>API: メンバー情報
Note over T,MS: サイレント再認証 (トークン期限切れ時)
T->>T: 30秒ごとの自動リフレッシュで 401 検出
alt Google
T->>G: google.accounts.id.prompt({auto_select: true})
G-->>T: 新しい ID Token (サイレント)
else Microsoft
T->>MS: acquireTokenSilent({forceRefresh: true})
MS-->>T: 新しい ID Token (サイレント)
end
T->>API: 新トークンで再試行
Note over T: 再認証失敗時は Alert バナーを表示
| 項目 | 内容 |
|---|---|
| 認証方式 | Google ID Token (JWT) または Microsoft ID Token (JWT) |
| 有効期限 | 1時間(Google / Microsoft 共通)。stg は ID_TOKEN_MAX_AGE_SECONDS で短縮可 |
| サイレント再認証 | Google: auto_select: true(FedCM 10分制限あり)、Microsoft: acquireTokenSilent({ forceRefresh: true }) |
| 再認証失敗時 | Alert バナー「セッションが無効になりました。」+ 「参加しなおす」ボタン |
| 自動リフレッシュ | 30秒ごとにメンバー情報を更新(refreshMembersOnly、詳細パネルの状態は保持) |
| 項目 | 内容 |
|---|---|
| 認証方式 | Session Token (UUID)、DynamoDB に保存 |
| 有効期限 | 90日(stg は 1日)。verify-session ごとに TTL を延長 |
| セッション期限切れ時 | Alert バナー + 「参加しなおす」ボタンで参加コード入力画面に戻る |
| サービス | リソース名 | 用途 |
|---|---|---|
| API Gateway | ClassroomApi-{stage} |
HTTP API エンドポイント |
| Lambda | ClassroomHandler-{stage} |
ビジネスロジック (Node.js 20, 256MB, 30秒) |
| DynamoDB | Classrooms-{stage} |
クラス情報 |
| DynamoDB | ClassroomMemberships-{stage} |
メンバー (生徒) 情報 |
| DynamoDB | ClassroomSubmissions-{stage} |
提出情報 |
| S3 | smalruby-classroom-submissions-{stage} |
提出ファイル (.sb3, サムネイル, スクリーンショット) |
| Route53 | A レコード | カスタムドメイン |
| ACM | SSL 証明書 | HTTPS |
| CloudWatch Logs | /aws/lambda/ClassroomHandler-{stage} |
ログ (stg: 1週間, prod: 1ヶ月) |
| Stage | ドメイン |
|---|---|
| stg | stg.classroom.api.smalruby.app |
| prod | classroom.api.smalruby.app |
| サービス | 用途 |
|---|---|
| Google OAuth 2.0 | 先生のログイン (ID Token 発行・検証) |
| Google Classroom API | コース一覧取得、生徒数取得、課題投稿 |
| スコープ | 用途 |
|---|---|
classroom.courses.readonly |
コース一覧の取得 |
classroom.rosters.readonly |
生徒名簿の取得 (人数カウントのみ) |
classroom.coursework.students |
課題の作成・投稿 |
| Method | Path | 説明 |
|---|---|---|
POST |
/classrooms |
クラス作成 |
GET |
/classrooms |
クラス一覧 |
GET |
/classrooms/{id} |
クラス詳細 |
PATCH |
/classrooms/{id} |
クラス更新 |
DELETE |
/classrooms/{id} |
クラス削除 (アーカイブ) |
GET |
/classrooms/{id}/co-teachers |
共同管理者一覧 ({ownerSub, coTeacherEmails}) |
POST |
/classrooms/{id}/co-teachers |
共同管理者を email で招待 ({email})。冪等・最大10・email 形式検証 |
DELETE |
/classrooms/{id}/co-teachers/{email} |
共同管理者を解除 |
GET |
/classrooms/{id}/members |
メンバー一覧 (kicked 行は除外) |
DELETE |
/classrooms/{id}/members/{memberId} |
メンバー削除 = ソフト kick (1h tombstone を残し、kicked: true をマーク)。verify-session が 410 reason='kicked' を返せるようにするための仕様。lookup の takenSeats も即座に空く |
GET |
/classrooms/{id}/kick-requests |
退室リクエスト一覧 (Issue #692) |
POST |
/classrooms/{id}/kick-requests/{requestId}/approve |
承認 = handleDeleteMember (= kick) + 同席への全リクエスト削除 |
DELETE |
/classrooms/{id}/kick-requests/{requestId} |
却下 = リクエストのみ削除、メンバーは残る |
GET |
/classrooms/{id}/submissions |
提出一覧 (ダウンロード URL 付き) |
PATCH |
/classrooms/{id}/submissions/{subId} |
提出の返却・コメント |
PUT |
/classrooms/{id}/assignment |
課題コンテンツの設定・更新(ページ + スターター。画像/スターターの Presigned upload URL を返す。空 body で削除) |
POST |
/classroom-groups |
クラス(学級)の作成(name, year, 任意で studentCount / googleClassroomCourseId) |
GET |
/classroom-groups |
自分のクラス一覧(active + archived、共同管理分は role=co-teacher で合流) |
PATCH |
/classroom-groups/{groupId} |
クラスのリネーム・年度変更・人数・GC 紐づけ・共同管理者・アーカイブ/復帰 |
POST |
/classroom-groups/migrate |
v1→v2 冪等 bulk migration(未所属課題のクラス自動作成・割当 + フィールド引き上げ) |
PATCH |
/classroom-groups/{groupId}/topics |
トピックの add / remove / rename(rename・remove は課題へ一括追従) |
POST |
/classrooms/{id}/duplicate |
クラス(授業)の複製。課題コンテンツの S3 オブジェクトもコピー。groupId / className / assignmentName を上書き可。メンバー・提出は複製しない |
POST |
/classrooms/{id}/evaluate |
AI 評価支援。静的解析結果(シグナル + 擬似コード)を Anthropic API にリレーし、mode: grade は S/A/B/C 案 + 根拠 + needsReview、mode: comment は生徒向けポジティブコメント下書きを返す。1リクエスト最大10提出(API GW の30秒制限対策、クライアントがチャンク分割)。先生ごとにレート制限(既定 60回/時) |
| Method | Path | 認証 | 説明 |
|---|---|---|---|
POST |
/classrooms/lookup |
不要 | 参加コードでクラス検索 |
POST |
/classrooms/lookup/kick-request |
不要 | 「使用中の席」を空けてもらう依頼を先生に送信 (Issue #692) |
POST |
/classrooms/join |
不要 | クラスに参加 (→ sessionToken 取得) |
POST |
/classrooms/verify-session |
Session Token | セッション検証 + 提出状況取得。kick された生徒には 410 + {reason: 'kicked', joinCode, className, seatNumber} を返す |
POST |
/classrooms/{id}/submissions |
Session Token | 提出 (Presigned URL 取得) |
DELETE |
/classrooms/{id}/members/me |
Session Token | 自主退出 |
GET |
/classrooms/{id}/assignment |
Session Token または 先生 ID Token | 課題コンテンツ取得(ページ + 画像/スターターのダウンロード URL)。lookup / join / verify-session は hasAssignment フラグを返す |
| Method | Path | 説明 |
|---|---|---|
GET |
/classrooms/google-courses |
Google Classroom のコース一覧 |
POST |
/classrooms/google-import |
コースをインポートしてクラス作成 |
POST |
/classrooms/{id}/google-assignment |
Google Classroom に課題を投稿 |
erDiagram
Classrooms {
string classroomId PK "UUID"
string teacherSub "Google Subject ID (GSI)"
string joinCode "6文字英数字 (GSI)"
string className "最大50文字"
number studentCount "1-50"
string googleClassroomCourseId "任意"
list coTeacherEmails "共同管理者の email 配列 (任意, 最大10)"
map assignment "課題コンテンツ (任意): {pages: [{text, imageKey?}], starterKey?, updatedAt}"
string groupId "所属する組 (任意)"
string status "active / archived"
string createdAt "ISO8601"
string updatedAt "ISO8601"
number ttl "Unix timestamp"
}
GSI:
joinCode-index— 参加コードでの検索teacherSub-index— 先生のクラス一覧(owner 分)
1 つのクラスを作成者(teacherSub)に加えて複数の先生で共同管理できる。
- 招待は email で行い
coTeacherEmails: string[](正規化: 小文字)に保持する。ログイン前の相手も指定でき、Google / Microsoft の混在も可。 - 先生トークン検証 (
verifyTeacherIdToken) は{sub, email}を返す。Google はemail_verifiedのときのみ email を採用、Microsoft はemail/preferred_username。 - 所有権判定は
canManageClassroom(classroom, identity)= 「teacherSub === subまたはcoTeacherEmailsに自分の email が含まれる」。全ての先生向け操作で使用。co-teacher は owner と完全同等(クラス削除・共同管理者の追加/解除も可)。 - 作成者は
teacherSubで管理されcoTeacherEmailsには含めないため、co-teacher API から作成者を外すことはできない(管理者ゼロを防止)。 GET /classroomsは owner 分(teacherSub-index)と co-taught 分(coTeacherEmailsへの Scan +containsフィルタ)の和集合。DynamoDB はリスト属性を GSI 化できないため Scan を使用。Classrooms テーブルは小規模(単一組織・90日 TTL)のため許容。各クラスはrole(owner / co-teacher)を返し、フロントの「共同管理」バッジに使う。
erDiagram
ClassroomMemberships {
string classroomId PK "UUID"
string memberId SK "seat-NN (ゼロ埋め)"
string displayName "最大20文字"
string role "student"
string sessionToken "UUID (GSI)"
string joinedAt "ISO8601"
string lastActiveAt "ISO8601"
number ttl "Unix timestamp"
}
GSI:
sessionToken-index— セッショントークンでの認証
erDiagram
ClassroomKickRequests {
string classroomId PK "UUID"
string requestId SK "UUID"
number seatNumber "1-50"
string reason "任意・最大200文字"
string sourceIpHash "abuse trace用 (sha256 16桁)"
string createdAt "ISO8601"
number ttl "Unix timestamp (1h)"
}
GSI:
classroomId-seatNumber-index— 承認時に同席への全リクエストを batch-delete するため
短命 (TTL 1 時間) のレコード。生徒が「使用中の席を空けてください」と先生に依頼するときに作成される。同一席への複数依頼を許容する仕様 (規制なし)。
handleDeleteMember (= 教師 kick) は 行を物理削除しない:
kicked: true, kickedAt, kickJoinCode, kickClassName, kickSeatNumber, ttl: now+1hをセット- これにより、kick された生徒の次回
verify-session呼出で 410 reason='kicked' を返せる listMembers/lookup takenSeatsはFilterExpression: attribute_not_exists(kicked) OR kicked <> :trueで tombstone を除外joinClassroomのConditionExpression: attribute_not_exists(memberId) OR kicked = :trueで新生徒が kicked 行を上書き可能 (tombstone はその時点で消滅)
erDiagram
ClassroomSubmissions {
string classroomId PK "UUID"
string submissionId SK "UUID"
string memberId "seat-NN (GSI)"
string projectName "最大100文字"
string s3Key "S3 オブジェクトキー"
string thumbnailS3Key "サムネイル S3 キー"
number screenshotCount "0-20"
string status "submitted / returned"
string submittedAt "ISO8601"
string updatedAt "ISO8601"
string teacherComment "最大500文字"
number ttl "Unix timestamp"
}
GSI:
classroomId-memberId-index— 生徒ごとの提出検索
erDiagram
ClassroomGroups {
string groupId PK "UUID"
string teacherSub "先生の Subject ID (GSI)"
string name "クラス名 (最大50文字, 例: 2年1組)"
number year "年度 (2000-2100)"
string status "active / archived"
number schemaVersion "データモデル版 (v2 = クラス→課題モデル)"
string_array topics "課題のトピック一覧 (最大20件)"
string googleClassroomCourseId "GC コース紐づけ (クラス単位, 任意)"
number studentCount "人数 (課題の座席数の真実, 任意)"
string_array coTeacherEmails "クラス単位の共同管理者 (任意)"
string createdAt "ISO8601"
string updatedAt "ISO8601"
number ttl "Unix timestamp (既定400日)"
}
GSI: teacherSub-index — 先生のクラス一覧
クラス(Group)は先生側の管理概念で、1つの学級が年間の複数の課題(Classrooms レコード = 1授業)を束ねる。生徒には見えない(生徒のモデル = 課題ごとの参加コード + 匿名席番号は不変)。
- クラスは生徒の作品を持たないため、90日ではなく長期 TTL(既定400日 ≈ 年度 + バッファ、
GROUP_TTL_DAYS) - クラスのアーカイブは表示上の整理。課題データ自体は 90 日 TTL で自然消滅する
- 前回コメント再掲: クラスに属する課題に生徒が join すると、同じクラスの直近の授業(最大3回分遡る)で同じ席に返却されたコメントが
previousCommentとして join レスポンスに載る(連休明けの個別リキャップ用)
schemaVersion: 2 = すべての課題がクラスに属し、クラス単位の GC 紐づけ・共同管理・人数が真実、という状態。Google Classroom の「クラス → 課題」構造に合わせた再構成で、既存データとは 冪等な bulk migration で互換を取る:
POST /classroom-groups/migrate(クラス一覧の初回表示時にクライアントが呼ぶ): groupId の無い既存課題を className ごとに自動作成したクラスへ割当(年度は課題作成日の JST 4月区切りから推定、同名クラスがあれば再利用)。課題単位の GC courseId(最古優先・クラス側優先)/ coTeacherEmails(和集合)/ studentCount(最大値)をクラスへ引き上げ、schemaVersion=2 をスタンプ。移行済みデータでは何もしない- 認可はクラス経由でも成立(
canManageViaGroup): クラスの所有者・クラス単位の共同管理者は、中のすべての課題を管理できる。課題単位の旧coTeacherEmailsも引き続き有効(後方互換) - 座席数はクラスの
studentCountが真実: 生徒の lookup / join はクラスの人数と課題側スナップショットの max を使う(人数を減らしても既存の着席と衝突しない増加方向のみの反映) - トピック(
PATCH /classroom-groups/{groupId}/topics、body{action: add|remove|rename, name, to?}): クラスのtopics配列を管理。rename / remove はクラス内の課題のtopicへ一括追従(rename は付け替え、remove は解除)。課題の作成・更新で新しいトピック名を使うとクラスの一覧へ自動追加される - AI 評価の日次上限: 先生ごとに
EVAL_DAILY_LIMIT(既定 50 呼び出し/日 ≈ フルクラスの採点+コメントで約 5 回分)を DynamoDB のアトミックカウンタ(Classroomsテーブルの予約キーeval-quota#<teacherSub>#<日付>、TTL 2 日)で永続的に強制。インスタンス内メモリの時間窓(EVAL_RATE_LIMIT_*)は高速な一次ゲートとして併用 - 課題の
topic/sortDate:topicはクラスのトピックへの文字列参照。sortDateは並び順キー(既定=作成日・意味なし・生徒非表示・自由変更可)で、課題管理画面はトピックごと・sortDate 降順に表示する
先生の評価作業を支援する。AI は判定者ではなく提案者: 全結果に機械シグナルを引用した根拠と needsReview フラグが付き、先生が UI で確認・修正・承認してはじめて記録される。
- 入力: 課題名/課題文 + 評価軸(1〜6、grade モードのみ必須)+ 厳しさ(lenient/standard/strict)+ 較正サンプル(先生が採点した実例、最大5件)+ 提出(席番号・シグナル・擬似コード最大4000字)
- モデル:
CLAUDE_MODEL(既定 claude-haiku-4-5)。システムプロンプトに prompt caching(ephemeral)を適用し、同一クラスのチャンク分割呼び出しでキャッシュを共有 ANTHROPIC_API_KEY未設定のステージではエンドポイントは 503(機能無効)- CloudWatch に
classroom_evaluateイベント(トークン数・キャッシュヒット)を構造化ログ出力
クラス(= 授業)には任意で課題コンテンツを持たせられる。先生が編集し、参加済みの生徒が読む。プログラム配付を自動化するための仕組み(生徒は参加するだけで課題とスターターが手に入る)。
pages: (数行テキスト + 画像1枚)× 最大10ページ。テキストは1ページ500文字までstarterKey: スタータープロジェクト(.sb3)の S3 キー。1課題に1つ- 画像は
image/png/image/jpegのみ。オブジェクトは{classroomId}/assignment/プレフィックスに置かれ、編集で参照されなくなったものは best-effort で削除される PUTはpagesの各要素にnewImage(新規アップロード。MIME 指定)かimageKey(既存維持)を指定し、newStarter/keepStarterでスターターを制御する。レスポンスの Presigned URL にクライアントが直接 PUT する(提出フローと同型)GETは生徒(Session Token)と先生(ID Token)の両対応。トークンに.を含むかで判別する(Session Token は UUID、ID Token は JWT)
smalruby-classroom-submissions-{stage}/
{classroomId}/
assignment/
image-{uuid}.png|.jpg ← 課題ページの画像
starter-{uuid}.sb3 ← スタータープロジェクト
{submissionId}/
project.sb3 ← Scratch 3 プロジェクトファイル
thumbnail.png ← プロジェクトサムネイル
screenshot-0.png ← スクリーンショット (0-indexed)
screenshot-1.png
...
| 項目 | 有効期間 (prod) | 有効期間 (stg) |
|---|---|---|
| クラス | 90日 | 1日 |
| メンバー | クラスと同じ | クラスと同じ |
| 提出 | クラスと同じ | クラスと同じ |
| S3 ファイル | クラスと同じ (ライフサイクルルール) | クラスと同じ |
| Stage | 許可オリジン |
|---|---|
| stg | https://smalruby.app, https://smalruby.jp, http://localhost:8601 |
| prod | https://smalruby.app, https://smalruby.jp |
許可ヘッダー: Content-Type, Authorization, X-Google-Access-Token
参加エンドポイント (/classrooms/lookup, /classrooms/join) にはIPベースのレート制限があります。
| 項目 | prod | stg |
|---|---|---|
| ウィンドウ | 60秒 | 30秒 |
| 最大リクエスト数 | 50回 | 100回 |
| 実装方式 | Lambda インメモリ Map | 同左 |