Skip to content

SDD-032 — Quyền học curriculum: học thử lesson đầu ​

Chỉ đạo chủ dự án 2026-09-05: "Các khóa đều cho phép học thử một lesson đầu tiên."

1. Điều đợt này thật sự làm ​

Câu chỉ đạo nghe như nới một cửa. Thực tế ngược lại.

Trước đợt này curriculum không có cửa nào. Mọi route lesson chỉ hỏi một câu — "tài khoản này có quyền trên learner đó không" (requireLearnerAccess) — và không hỏi câu thứ hai: "learner đó có được học khoá này không". Không có bảng quyền, không có cột is_free, không có nhánh nào phân biệt bài trả tiền với bài miễn phí. Một learner bất kỳ đọc được cả 201 lesson của IELTS Core.

Nên việc phải làm là dựng cái khoá, rồi chừa lesson đầu ra — không phải mở thêm một lối.

2. Luật học thử ​

Mỗi course mở đúng một lesson: bài seq nhỏ nhất của unit seq nhỏ nhất.

Luật đọc theo seq, không theo thứ tự mảng mà truy vấn trả về. getCourse có ORDER BY, nhưng một luật tính tiền không nên phụ thuộc vào mệnh đề ORDER BY nằm ở tệp khác — đổi nó là đổi cái miễn phí mà không ai nhận ra. Course chưa có lesson nào thì trial_lesson_id = null, và khi ấy không có gì mở.

Bài học thử mở trọn vẹn: đủ bốn chặng, học liệu, bài kiểm, bài ngẫm, ghi tiến độ bình thường. Một bài thử bị cắt xén không trả lời được câu hỏi mà người học thử đang hỏi.

3. Cắt nội dung ở server, không làm mờ ở giao diện ​

getCourse trả toàn bộ nội dung của mọi lesson trong một payload. Nếu chỉ khoá ở giao diện thì nội dung vẫn nằm nguyên trong JSON: mở tab Network là đọc hết cả khoá. Một cái khoá mà dữ liệu vẫn gửi đi thì không phải khoá, nó là lời đề nghị.

redactCourse (workers/api/src/modules/curriculum/access.ts) cắt trước khi dữ liệu rời server. Lesson bị khoá còn lại đúng phần mục lục: id, seq, tên bài, câu hỏi dẫn dắt, kèm cờ locked: true. Bị cắt: học liệu, bài kiểm, câu chuyện, hiểu lầm, lỗi hay mắc, can thiệp, outcome, concepts_vi, và cả Performance Task của unit — đó là đề bài đầy đủ, gửi đi là cho không phần đắt nhất.

Mục lục giữ lại là có chủ ý: người đang cân nhắc trả tiền cần thấy mình sắp mua gì, và bốn thứ đó đã công khai ở /v1/public/ielts/catalog cho ielts.nemo12.com từ trước. Giấu nốt chúng thì vừa khoá chặt hơn chỗ đang mở, vừa biến trang khoá học thành một danh sách trống.

learner_id là tuỳ chọn ở GET /v1/curriculum/courses/{id} — trang khoá học phải mở được cho người chưa chọn con nào. Thiếu nó nghĩa là chưa có quyền, không phải "bỏ qua kiểm tra".

4. curriculum_entitlements ​

Bảng ở migration 0202_trial_lesson.sql. Khoá duy nhất (learner_id, course_id).

Quyền gắn với learner, không phải user: một phụ huynh có nhiều con, và tiền trả cho đứa này không phải quyền học của đứa kia. granted_to_user_id giữ lại ai đã trả, để đối soát.

source phân biệt purchase (qua cổng thanh toán) với grant (admin cấp tay: học bổng, đền bù, dạy thử kéo dài) — ngày đối soát doanh thu sẽ chỉ đếm purchase.

Không có cột expires_at: chưa có luật hết hạn nào được chốt, và một cột không ai đọc là một lời hứa sai.

Thiếu bảng ≠ cho qua. Bảng chưa tồn tại (code deploy trước migration là nếp bình thường ở repo này) thì hasEntitlement trả false, tức mọi người vẫn học thử được và không ai mất tiền oan. Hiểu ngược lại là mở toang cả khoá vì một bảng chưa kịp tạo.

5. LESSON_LOCKED — và vì sao nó là 403 ​

Khoá nội dung mà vẫn cho ghi tiến độ lên bài đang khoá thì dữ liệu tiến độ nói dối: nó bảo learner đã học một bài chưa từng được gửi tới máy họ. Nên các route chặng · cụm · bài ngẫm đi qua chốt thứ hai canOpenLesson.

Mã lỗi mới LESSON_LOCKED → 403, là ngoại lệ duy nhất của luật "401 chứ không 403" trong docs/reference/errors.md. Luật đó tồn tại để 403 không tiết lộ rằng một tài nguyên có thật — với dữ liệu trẻ em đó đã là rò rỉ. Ở đây không có gì để rò: sự tồn tại của mọi bài học đã công khai trong catalog. Đổi lại, trả 401 gây hại thật — client hiểu là phiên hết hạn (apps/learn/src/api.ts), bảo learner đăng nhập lại, và họ làm lại rồi vẫn không mở được bài.

Giao diện đọc cờ locked chứ không suy từ mảng rỗng: một bài soạn dở cũng có mảng rỗng, và hai chuyện đó phải nói khác nhau với learner. Bài bị khoá đi đường riêng trước LessonView — để nguyên thì learner thấy một bài trống trông y hệt một bài chưa soạn.

6. Tự mở hồ sơ học thử ​

POST /v1/learners/self tạo learner cho chính tài khoản đang đăng nhập, status='active', profile_source='self'.

Trước đó câu "học sinh tự login rồi tự tìm hiểu khoá học" chưa đúng: đăng nhập Google thì được, nhưng tài khoản mới sinh ra là owner của một family (đường dành cho phụ huynh), còn learner thì phải có mã mời — nên người tự tìm tới chỉ gặp màn "nhập mã mời".

Hai đường tồn tại song song, không thay nhau: bố mẹ lập hồ sơ cho con nhỏ (hồ sơ gắn sẵn gia đình, người theo dõi, mục tiêu) và học sinh lớn tự tìm tới. Trên màn Join, lối tự mở nằm dưới ô mã mời — ai đang cầm mã thì mã vẫn là đường đúng.

Gọi lại lần nữa với tài khoản đã là learner thì trả về chính hồ sơ cũ, không ném 409: người dùng bấm hai lần là chuyện thường, và ở đây "đã có" chính là kết quả mong muốn.

Route này không cấp quyền học. Learner tạo ra ở đây học được đúng phần học thử như mọi learner khác.

7. Chưa làm: cổng thanh toán ​

source='purchase' và order_ref đã có chỗ trong bảng, nhưng chưa có cổng thanh toán nào được nối — repo không có hạ tầng thanh toán, và việc chọn nhà cung cấp (VNPay · MoMo · Stripe) cùng merchant credentials là quyết định của chủ dự án.

Tới khi đó, đường mở khoá thật là source='grant' cấp tay. Màn hình bài bị khoá vì vậy không dựng nút "Ghi danh" — một nút bấm vào không đi đâu làm learner tưởng mình vừa làm sai; nó nói bằng chữ để gia đình liên hệ.

Trace ​

REQSection
REQ-TRI-01§2
REQ-TRI-02§3
REQ-TRI-03§4
REQ-TRI-04§5
REQ-TRI-05§6