SDD-040 — Buổi học trực tiếp
Chỉ đạo chủ dự án 2026-09-20: "Cần có trang mới, kiểu như Live Session (dành cho các buổi học mà học trực tiếp qua Zoom), rồi khu vực này giúp giáo viên quản lý xem ai đang trong buổi học. Và trạng thái của mỗi learner là như thế nào, họ đã học xong cái gì rồi, chưa học xong cái gì. Trong trang learn.nemo12.com/ielts, ở đâu đó có trang để vào học lớp học live, có chỗ để nhập code của lớp học. Trong đó, learner có thể điền các thông tin thông thường của một buổi học. Đầu buổi học cần trả lời câu hỏi gì đó dạng free text, vài câu khác dạng quiz, và có Mood Tracker, để Mentor biết cảm xúc, và cảm giác của Learner đối với việc học. Và rà soát sự thỏa mãn của Learner đối với mục tiêu và đối với tốc độ học. Mentor cần biết cái gì đang block learner, để cùng hỗ trợ. Hoặc cần biết là Learner đang hào hứng tới đâu. Cuối buổi cần có 4F Reflection. Có một game thi đấu trực tiếp kiểu Kahoot, thầy cô cho mọi người vào game, rồi cùng start game, sau mỗi câu thì có kết quả và bảng xếp hạng."
1. Việc mà tài liệu này giải
Buổi học live hiện diễn ra trọn vẹn trong Zoom, và Nemo12 không biết gì về nó. Ba khoảng trống, xếp theo mức tốn kém:
- Mentor vào buổi mà không biết learner đang ở đâu. Họ phải nhớ, hoặc mở cổng Dolphin ở một tab khác và tra từng người. Nên mười phút đầu buổi thường trôi vào việc hỏi lại những thứ hệ đã biết.
- Cảm giác của learner không được hỏi, nên không được biết. "Mục tiêu này có còn đúng không", "tốc độ có chịu nổi không", "cái gì đang chặn con" là ba câu mà không ai hỏi trong một buổi sáu mươi phút chật kín nội dung — và chúng lại đúng là ba câu quyết định learner còn học tiếp hay không.
- Buổi học kết thúc là bốc hơi. Không có dấu vết nào để buổi sau bắt đầu từ chỗ buổi trước dừng.
Ba khoảng trống ấy ứng với ba khối của tài liệu này: phòng học (§3), check-in và phản tư (§4), ván đấu (§5). Khối thứ ba trông như phần giải trí, nhưng nó cũng là khối trả lời câu hỏi "cả lớp có đang theo kịp không" trong thời gian thực — mười câu hỏi trả lời đồng thời là một phép đo mà không bài kiểm tra nào rẻ bằng.
Ngoài phạm vi: gọi video (Zoom vẫn là Zoom), điểm danh chính thức (REQ-MEN-04), và xếp lịch buổi (đã có ở SDD-034 và SDD-031). Buổi live ở đây không buộc vào một slot: mentor mở phòng khi cần, kể cả buổi dạy bù hẹn qua Zalo.
2. Địa chỉ
| Ai | URL | Trang |
|---|---|---|
| Mentor | dolphin.nemo12.com/live | Danh sách buổi, nút mở phòng mới |
| Mentor | dolphin.nemo12.com/live/{sessionId} | Một buổi: ai đang trong phòng, điều khiển ván đấu |
| Learner | learn.nemo12.com/ielts/live | Ô nhập mã lớp |
| Learner | learn.nemo12.com/ielts/live/{MÃ} | Phòng học |
Địa chỉ phòng của learner mang mã lớp, không mang id nội bộ. Mã là thứ learner đang cầm trên tay, nên gõ thẳng URL vào thanh địa chỉ là vào được, và mentor dán được một link đầy đủ vào khung chat Zoom thay vì đọc mã ba lần. POST /v1/live/join là upsert nên mở lại link ấy bao nhiêu lần cũng chỉ là một người trong danh sách.
Lối vào của learner nằm ở hub /ielts, ngay trên bốn kỹ năng. Phần lớn ngày nó là một dòng vô dụng; nhưng vào cái ngày nó có việc thì learner đang vội và đang nghe mentor đọc mã, và họ mở đúng trang ấy.
3. Phòng học
Mã lớp là bốn chữ số (1000-9999) — chỉ đạo chủ dự án 2026-09-20 (SRC-906), thay cho sáu ký tự chữ-và-số của bản đầu.
Bản đầu bỏ hẳn 0 O 1 I L cho khỏi nghe nhầm, nhưng bỏ những cặp ấy đi vẫn không bỏ được hai việc: mentor phải đánh vần từng chữ cái qua Zoom, và learner phải gõ chúng trên bàn phím điện thoại đang rất muốn sửa ABC234 thành một từ có nghĩa. Bốn chữ số thì đọc thành tiếng một lần là xong, và ô nhập mở thẳng bàn phím số (inputMode="numeric"). Mã luôn đủ bốn chữ số: một mã 0421 đọc lên là "bốn hai mốt" và learner gõ thiếu một ký tự. Số lần thử lại khi trùng tăng từ 3 lên 8, vì 9000 tổ hợp vẫn thừa cho vài chục buổi mở cùng lúc nhưng không còn rộng tới mức ba lần thử là chắc chắn. Mã không phải mật khẩu: mọi người vào phòng đều phải có phiên đăng nhập và có hồ sơ learner, nên mã đoán trúng cũng không lấy được gì. Thứ mã chống là "vào nhầm phòng".
Một mã chỉ trỏ tới một phòng chưa kết thúc (unique index có mệnh đề WHERE status <> 'ended'), nên mã của tuần trước dùng lại được cho tuần này.
Trạng thái buổi: lobby → live → ended. Không có draft: một buổi tạo ra là để learner vào ngay, và một trạng thái mà learner chưa vào được thì mentor sẽ tạo buổi rồi quên bật.
Có mặt đo bằng last_seen_at chứ không bằng một cờ online: cờ chỉ đúng khi mọi người thoát bằng cách bấm nút, còn thực tế là họ đóng tab, mất wifi, hoặc sập nguồn. Quá 90 giây không thấy là không còn ngồi đó — gấp ba nhịp hỏi lại, nên một lần mất gói tin không đẩy ai ra khỏi danh sách.
"Đã học xong cái gì, chưa xong cái gì" đọc từ readPathway (SDD-038) qua một endpoint riêng, /v1/live/sessions/{id}/progress. Riêng vì nó là N+1 theo số người trong phòng, và mentor chỉ cần nó vài lần một buổi — nhét vào endpoint hỏi-lại ba giây một lượt là nhân cái giá ấy với ba mươi lượt mỗi phút.
Buổi đã đóng vẫn mở lại được, cho đúng một người: người đã ngồi trong buổi ấy. Vá một mâu thuẫn có thật giữa hai đầu (Audit #015 D-3). Server CỐ Ý nhận phản tư 4F kể cả sau khi mentor bấm kết thúc, vì mentor đóng buổi lúc 20:00 còn learner đang viết dở chặng Future. Nhưng đường DUY NHẤT để màn learner lấy được sessionId là join bằng mã, mà join lọc status <> 'ended'. Nên chỉ cần learner lỡ tay tải lại trang, hay điện thoại khoá màn rồi trình duyệt dựng lại tab, là biểu mẫu 4F biến mất vĩnh viễn: khả năng server mở ra thì client khoá lại. Mà nhánh 4F chỉ hiện khi buổi đã đóng, tức là nó chỉ tới tay learner đúng trong khoảng thời gian mà một lượt tải lại trang xoá nó.
Hai giới hạn giữ cho đường này không thành một cái cửa mở: chỉ người đã có mặt mới vào lại được (ai cầm mã mà chưa từng dự thì vẫn nhận đúng câu trả lời cũ, nên mã tuần trước không bày danh sách lớp cho người lạ), và lượt mở lại không nhấc last_seen_at — viết nốt phản tư không phải là "đang ở trong phòng", và nói ngược lại là nói dối bảng của mentor.
4. Check-in đầu buổi và phản tư 4F cuối buổi
Biểu mẫu do server khai (modules/liveSession/checkin.ts), cả hai app đọc. Hai bản chép của cùng bộ câu hỏi là hẹn ngày mentor đọc "Mood: 3" dưới một câu hỏi khác với câu learner đã trả lời.
Check-in gồm: một Mood Tracker năm mức có emoji, bốn thang 1-5, hai ô chữ, và ba câu trắc nghiệm mở màn.
| Thang | Câu hỏi | Trả lời câu nào của chỉ đạo |
|---|---|---|
energy | Còn bao nhiêu sức cho buổi này | "cảm xúc và cảm giác của Learner" |
excitement | Hào hứng tới đâu với việc hôm nay | "Learner đang hào hứng tới đâu" |
goal_fit | Mục tiêu IELTS có còn là mục tiêu đúng | "sự thoả mãn đối với mục tiêu" |
pace_fit | Tốc độ học thấy thế nào | "sự thoả mãn đối với tốc độ học" |
goal_fit tách khỏi pace_fit vì hai câu trả lời dẫn tới hai việc khác nhau: mục tiêu sai thì phải ngồi lại đặt mục tiêu khác, tốc độ không chịu nổi thì giữ mục tiêu và giãn lịch. Gộp thành một thang "hài lòng" thì mentor đọc được một con số thấp mà không biết sửa cái nào.
Thang 1-5 chứ không 1-10: learner trả lời trong bốn mươi giây, trên điện thoại. Mười mức thì khác biệt giữa 6 và 7 là do ngón tay chứ không do cảm giác.
Hai ô chữ là focus_text ("hôm nay con muốn gỡ được gì") và blocker_text ("cái gì đang cản con"). Ô thứ hai vẽ trên nền cảnh báo ở màn mentor — nó là câu duy nhất trên thẻ mà mentor phải làm gì đó với nó ngay trong buổi.
Ba câu trắc nghiệm không chấm điểm và không vào hồ sơ: việc của chúng là kéo learner từ trạng thái "vừa mở tab" sang "đang nghĩ về IELTS" trước khi mentor nói câu đầu tiên. Hai trong ba câu cố ý không có đáp án đúng (answer: -1) vì chúng hỏi về learner chứ không hỏi về IELTS.
Phản tư 4F (Greenaway) cuối buổi: Facts · Feelings · Findings · Future, bốn cột rời trong CSDL chứ không một khối JSON — bốn chặng là khung cố định, và bốn cột thì đếm được bằng SQL "bao nhiêu learner viết nổi chặng Future". Thứ tự không đổi được: nhảy thẳng vào "lần sau con làm gì" khi chưa kể lại chuyện gì đã xảy ra thì câu trả lời luôn là "con sẽ cố gắng hơn".
Phản tư nộp được kể cả khi buổi đã đóng: mentor bấm kết thúc lúc 20:00 còn learner đang viết dở chặng Future. Nộp đủ một chặng là gửi được — một biểu mẫu cuối buổi mà chặn đường ra thì thứ nó thu được là bốn ô điền cho xong.
Câu quiz BỎ TRỐNG ghi là bỏ trống, không thành lựa chọn thứ nhất (Audit #015 D-10). Phần quiz của check-in không bắt buộc: điều kiện nộp chỉ đòi mood và các thang đo, nên bỏ trống là đường đi bình thường chứ không phải ngoại lệ. Bản đầu gửi ?? 0 cho câu chưa bấm, và bản ghi nói learner đã chọn "Listening" cho câu hỏi kỹ năng khó nhất trong khi họ chưa đụng vào nó — không phân biệt được với người thật sự đã bấm ô ấy. Cột quiz_json tồn tại để giữ NGUYÊN câu trả lời, nên một câu bịa ra làm hỏng đúng việc mà cột ấy sinh ra để làm.
Ký hiệu là -1, và nó không phải một con số chọn bừa: QUIZ[0].answer đã là -1 cho câu không có đáp án đúng, nên module này vốn đã đọc -1 là "không có lựa chọn nào". Khuôn thân request nhận -1..9; -2 vẫn bị chặn, vì "chưa trả lời" là một giá trị có nghĩa chứ không phải một cái cửa mở cho số rác.
5. Ván đấu
Mười câu IELTS cho người mới (modules/liveSession/game.ts, pack beginner). Không câu nào cần đọc một đoạn văn: trong một ván tính điểm theo giây, một câu cần đọc ba mươi giây chỉ đo tốc độ đọc.
Nhịp do mentor giữ, không do đồng hồ. Mentor mở câu, thấy "7/9 đã trả lời", rồi bấm hiện đáp án. Một ván mà câu tự đóng lúc còn hai người đang đọc đề là một ván dạy learner rằng đọc kỹ thì thiệt.
Điểm: 600 cho đúng + tối đa 400 cho tốc độ, tụt tuyến tính trong 25 giây. Hai phần chứ không phải một hàm tốc độ thuần — nếu tốc độ quyết định phần lớn điểm thì learner học được "đoán nhanh lợi hơn nghĩ", ngược với mọi thứ phòng thi dạy. Với tỉ lệ này, người trả lời đúng ở giây cuối luôn trên người trả lời sai ở giây đầu.
Bốn cái chặn nằm ở server, vì client là thứ learner sửa được bằng DevTools và một bảng xếp hạng là thứ đáng để ai đó thử:
- Đáp án và câu giải thích chỉ rời server khi câu đã đóng.
- Trả lời phải đúng câu đang mở.
UNIQUE(session_id, learner_id, q_index)— luật "mỗi câu một lần" ghi vào schema, không vào tầng ứng dụng.- Đồng hồ cũng ở server. Thân request KHÔNG mang số mili giây; server đo từ
question_started_atcủa chính nó, cột này được ghi cùng lượt vớiquestion_open.
Cái chặn thứ tư thiếu mất trong bản đầu và được Audit #015 (D-2) tìm ra: ba cái trên đều ở server, nhưng số mili giây thì vẫn nhận từ client rồi đem chấm thẳng. Gửi ms: 0 là ăn trọn 400 điểm tốc độ ở mọi câu, kể cả câu bấm ở giây thứ hai mươi tư. Nói cách khác, ba cái khoá cửa trước trong khi cửa sau mở — và cửa sau ấy mở đúng vào phần điểm mà ván đấu dùng để xếp hạng.
Và màn hình phải đọc cùng cái đồng hồ ấy. Trạng thái phòng mang elapsed_ms: câu đã mở được bao lâu, do server đo. Đây là một KHOẢNG chứ không phải một MỐC, và khác biệt ấy có lý do: nếu gửi started_at rồi để client tự trừ bằng đồng hồ của nó thì một máy sai giờ vài phút sẽ hiện ra "hết giờ" ngay từ giây đầu. Client neo con số ấy một lần mỗi câu rồi đếm tiếp bằng đồng hồ của mình, nên nó vừa đúng với người vào muộn, vừa không giật theo độ trễ mạng.
Server cũng không trả về đúng/sai khi ghi nhận câu trả lời: cả lớp cùng biết đáp án một lúc. Ai nộp sớm mà biết ngay mình sai thì có thêm hai mươi giây để nhắc bạn bên cạnh.
Câu giải thích (why) hiện cùng lúc với đáp án. Một ván chỉ nói ai đúng ai sai thì hết buổi learner nhớ bảng xếp hạng chứ không nhớ gì về IELTS.
Một phần hỏng không được khoá cả phòng. Bộ câu hỏi của hai biểu mẫu (/v1/live/form) tải riêng với trạng thái phòng, và cổng render chỉ đòi trạng thái phòng. Bản đầu đòi cả hai, trong khi lượt gọi lấy bộ câu hỏi lại nuốt lỗi với chú thích "màn vẫn mở" — hai chỗ nói ngược nhau, và kết quả là một lượt 500 hay một nhịp rớt sóng khoá luôn cả ván đấu, thứ chẳng dùng tới bộ câu hỏi ấy (Audit #015 D-5). Learner ngồi nhìn "Opening the room..." suốt buổi trong khi cả lớp đang chơi: không lỗi nào để biết, không nút nào để thử lại, và cách duy nhất là tải lại trang — mà tải lại giữa buổi là bỏ lỡ câu đang mở.
Nay chỗ nào cần bộ câu hỏi mà chưa có thì nói ra và cho bấm lại ngay tại chỗ; phần còn lại của phòng chạy bình thường.
6. Vì sao hỏi-lại chứ không WebSocket
Phòng học live là bài toán kinh điển của WebSocket, và Cloudflare có Durable Objects làm đúng việc ấy. Vẫn chọn hỏi-lại (mentor 3 giây, learner 2,5 giây):
- Quy mô thật là một lớp vài người. Phòng mười người là 200 lượt đọc mỗi phút, nhỏ hơn thứ bàn theo dõi chịu mỗi sáng.
- Durable Object là hạ tầng mới trong repo này: thêm một binding, một vòng đời, một chỗ nữa để hỏng lúc 8 giờ tối khi một lớp đang học. Hỏi-lại dùng đúng D1 và đúng worker đang chạy.
- Hỏi-lại hỏng nhẹ. Mất mạng ba giây thì màn hình trễ ba giây rồi đuổi kịp; một lượt hỏi hỏng không xoá màn đang có. WebSocket đứt thì phải tự dựng lại trạng thái, và đó là phần code không ai kiểm được cho tới lúc nó cần chạy.
Ngày một lớp có trăm người cùng chơi thì đây là chỗ phải đổi, và thứ cần đổi chỉ là hai endpoint trạng thái.
7. Dữ liệu
Migration 0258_live_sessions.sql: live_sessions, live_participants, live_checkins, live_reflections, live_game_answers. Trạng thái ván nằm ngay trong live_sessions (ba cột) chứ không ở bảng vòng riêng: một buổi có một ván, một ván có một câu đang mở, và tách ra thì mỗi lượt hỏi "đang câu mấy" — hỏi ba giây một lần — thành một phép JOIN.
8. Phân vai
| Endpoint | Ai gọi được |
|---|---|
/v1/live/sessions/** | mentor · staff · admin |
/v1/live/room/**, /v1/live/join | người có hồ sơ learner |
/v1/live/form | ai đã đăng nhập |
Hai nhóm tách hẳn tiền tố chứ không chung một đường có cờ vai: một đường hai nghĩa là chỗ mà một lần quên kiểm vai biến thành một learner bấm được nút "sang câu tiếp". Quyền điều khiển buộc vào vai, không vào mentor_user_id — một mentor ốm thì người dạy thay phải mở được phòng ấy.
Màn mentor thấy nguyên văn check-in của cả lớp; learner chỉ thấy của chính mình, cộng bảng xếp hạng (tên và điểm, không có gì khác).
9. Trace
| Mục | REQ | Kiểm |
|---|---|---|
| §3 phòng học, mã lớp, có mặt | REQ-MEN-18 | modules/liveSession/routes.test.ts — cổng vai, mã sai, mã của buổi đã đóng, vào hai lần là một người |
| §4 check-in + 4F | REQ-MEN-18 | cùng file — nguyên văn blocker tới tay mentor, nộp lại thì đè, thang ngoài 1-5 bị chặn, 4F nộp được sau khi buổi đóng |
| §5 ván đấu | REQ-MEN-18 | cùng file — đáp án không rò khi câu đang mở, mỗi người một lần, thứ tự bảng xếp hạng, hết câu thì kết thúc |
| §2 địa chỉ | REQ-MEN-18 | apps/learn/src/ielts/liveRoutes.test.ts — parse/href khớp nhau, không nuốt nhánh đang chạy |
9. Lịch buổi học, và cửa sổ mười phút (SRC-972, 22.09.2026)
"Learners cần nhìn thấy một số buổi học và thông tin giới thiệu chung về buổi đó. Các buổi chiều thứ 7, từ 15:00 tới 16:30 là buổi trao đổi về 'Cách đặt mục tiêu, cách học' (…) Learner có thể check-in trước buổi học 10 phút và sau khi kết thúc 10 phút (…) Link zoom sẵn sàng để lấy trước giờ bắt đầu đúng 10 phút."
9a. LỊCH khác PHÒNG, nên khác bảng
live_sessions (§7) là căn phòng: ra đời lúc thầy cô bấm mở, mang mã bốn số, chết khi buổi kết thúc. Nó không trả lời được câu learner hỏi ba ngày trước đó: "tuần này có buổi nào, mấy giờ, nói về cái gì".
live_class_schedule (migration 0272) là lịch: tồn tại trước buổi học và tồn tại tiếp sau khi phòng đã đóng. Gộp hai thứ vào một bảng thì mọi câu về lịch phải lọc bỏ trạng thái phòng, và mọi câu về phòng phải lọc bỏ buổi chưa tới. live_sessions.schedule_id nối hai bên, NULL với phòng mở ngoài lịch.
Giờ lưu kèm múi giờ (2026-09-26T15:00:00+07:00): hệ chạy trên Cloudflare (UTC) còn learner ngồi ở Việt Nam, và một chuỗi giờ không mang múi sẽ được đọc là UTC ở tầng nào đó — buổi 15:00 chiều thành 22:00 tối trên màn hình của chính người sắp vào học.
9b. Luật mười phút nằm ở SERVER
| Mở | Đóng | |
|---|---|---|
| Link Zoom | 10 phút trước giờ bắt đầu | 10 phút sau giờ tan |
| Check-in | như trên | như trên |
Hai cửa sổ trùng nhau hôm nay nhưng là hai hằng số riêng (OPEN_BEFORE_MIN, CLOSE_AFTER_MIN): một buổi có thể cho vào muộn mà không cho check-in muộn, và khi ấy chỉ một trong hai đổi.
Nếu server cứ trả zoom_url rồi để client tự giấu tới giờ, cái link ấy đã nằm trong phản hồi mạng từ lúc learner mở trang lần đầu — cả tuần trước buổi học. Server trả null cho tới đúng phút, kèm zoom_available_at để màn hình nói được "link mở lúc mấy giờ" mà không cần chính cái link.
Buổi đã huỷ không bao giờ trả link, kể cả đang đúng giờ: một learner bấm vào đó sẽ ngồi một mình trong phòng Zoom và nghĩ mình tới nhầm giờ.
9c. Hàm thuần, đồng hồ neo
viewOf(row, now) nhận now từ ngoài. Đọc new Date() bên trong thì ca kiểm sẽ xanh hay đỏ tuỳ vào lúc trong ngày mà CI chạy — và một ca gác luật an toàn mà kết quả phụ thuộc giờ chạy thì không gác gì cả. Chín ca ở liveClasses.test.ts, gồm cả hai mốc sát biên (14:49 và 14:50).
9d. Buổi đã qua vẫn hiện
Danh sách trả ba buổi gần nhất đã qua cộng tám buổi sắp tới. Learner mở trang hôm sau muốn xem lại buổi hôm qua nói về gì, và một danh sách chỉ có tương lai thì hôm sau buổi ấy biến mất như chưa từng có.
Tám buổi thứ Bảy đầu tiên seed sẵn trong migration, INSERT OR IGNORE với id theo ngày nên chạy lại không sinh bản sao.
9e. Link Zoom chỉ cho thành viên IELTS trả phí (SRC-1210, 03.10.2026)
Chỉ đạo chủ dự án: link Zoom chỉ cho người có membership IELTS. Audit 03.10.2026 tìm ra rằng cả hai route lịch chỉ đòi đăng nhập, nên mọi tài khoản - kể cả bố mẹ hay một tài khoản lạ - nhận link trong cửa sổ mười phút.
- Ai nhận link: learner có
learner_ielts_membership.plan = 'member'còn hạn (theo ngày Việt Nam), cộng mentor và admin - người dạy và người vận hành lớp. Người đang dùng thử không tính (chủ dự án chọn 03.10.2026). - Lịch vẫn mở cho mọi tài khoản đăng nhập. Giờ học không phải bí mật; thứ cần giữ là cái phòng.
- Server trả
zoom_url: nullkèmmembers_only: true, và màn hình mời xem gói thành viên thay vì nói "link mở lúc mấy giờ" - một lời hứa sẽ không bao giờ được giữ. - Kiểm quyền bằng
isPaidIeltsMember, hàm chỉ đọc:readMembershiptạo dòng dùng thử ở lượt đọc đầu, và một lượt mở trang Live không được là lúc đồng hồ dùng thử bắt đầu chạy. - Quyền nằm ở ROUTE (
canJoinLive),viewOfchỉ nhận cờcanJoin. Test routeliveClassesRoutes.test.tsgác năm vai qua request thật, vì test của hàm thuần vẫn xanh khi route quên hỏi quyền.