Học viện hàng hóa — API cho bên thứ ba
API đọc nội dung học tập (khóa học → chủ đề → bài học → quiz) và thử thách
(bộ câu hỏi độc lập, ngoài mọi khóa học) của Học viện hàng hóa.
Chỉ đọc, chỉ GET, xác thực bằng header X-API-Key.
Mỗi endpoint trong trang này có khối test gọi trực tiếp từ trình duyệt.
https://hvhh.sfi.com.vnBase URL/api/v1Phiên bảnTổng quan
| Base URL | https://hvhh.sfi.com.vn |
| Tiền tố phiên bản | /api/v1 |
| Định dạng | JSON, Content-Type: application/json; charset=utf-8 (UTF-8, có dấu tiếng Việt) |
| Phương thức | Chỉ GET (và OPTIONS cho preflight CORS) |
curl -H "X-API-Key: hvhh_xxxxxxxx" https://hvhh.sfi.com.vn/api/v1/courses
endQuiz của chủ đề), và cuối khóa học (endQuiz của
khóa học). Trạng thái đăng thừa hưởng xuống dưới: chủ đề thuộc khóa học draft không
xuất hiện ở bất kỳ endpoint nào, và bài học của nó cũng vậy — kể cả khi bản thân chúng đã
published.
⚡ Cấu hình test — dùng cho mọi khối test trong trang
Danh mục endpoint
| Endpoint | Mô tả | Xác thực | Cache/ETag |
|---|---|---|---|
| GET /api/health | Health check + contentVersion để phát hiện nội dung đổi |
KHÔNG CẦN KEY | Không |
| GET /api/v1/courses | Danh sách toàn bộ khóa học đã đăng — endpoint vào của cây nội dung | X-API-KEY | Có |
| GET /api/v1/courses/{slug} | Mục lục một khóa học: chủ đề + mục lục bài học + endQuiz cuối khóa |
X-API-KEY | Có |
| GET /api/v1/topics/{slug} | Toàn bộ một chủ đề: bài học + contentHtml + quiz + endQuiz |
X-API-KEY | Có |
| GET /api/v1/lessons/{slug} | Một bài học đơn lẻ (deep link), kèm topic tối thiểu |
X-API-KEY | Có |
| GET /api/v1/challenges | Danh sách thử thách đã đăng — bộ câu hỏi độc lập, ngoài mọi khóa học | X-API-KEY | Có |
| GET /api/v1/challenges/{slug} | Bộ câu hỏi của một thử thách — ?mode=full|random, ?limit=N |
X-API-KEY | Chỉ mode=full |
Tổng cộng 7 endpoint. Không có endpoint ghi, không có endpoint quiz riêng, không có danh sách bài học phẳng (xem §8). Hai endpoint thử thách là duy nhất có tham số query.
Endpoint
Tổng cộng 7 endpoint: 1 health check (không cần key) và 6 endpoint nội dung. Mỗi endpoint có khối test riêng, dùng Base URL / key ở khối Cấu hình test.
1.1 · Health check
/api/health
Kiểm tra service. Không cần API key — hữu ích cho monitor và cho việc phát hiện nội dung vừa đổi
(§7). Endpoint này không đi qua cache và không có ETag.
/api/v1/*, nên /api/health không trả
Access-Control-Allow-Origin và bị trình duyệt chặn khi gọi cross-origin. Dùng
curl hoặc gọi từ backend của bạn:
curl https://hvhh.sfi.com.vn/api/health
Response
{ "ok": true, "service": "sfi-hvhh", "contentVersion": 7 }
| Field | Kiểu | Giải thích |
|---|---|---|
| ok | boolean | Luôn true khi service sống. Nếu không nhận được JSON này thì service đang chết hoặc đang deploy |
| service | string | Luôn "sfi-hvhh". Dùng để chắc chắn không gọi nhầm service khác đứng sau cùng domain |
| contentVersion | number | Số phiên bản nội dung hiện tại, tăng 1 sau mỗi thao tác ghi nội dung. Reset về 1 mỗi lần service khởi động lại, nên chỉ dùng để phát hiện có thay đổi, không dùng như số tăng đơn điệu vĩnh viễn |
Test — GET /api/health
1.2 · Danh sách khóa học
/api/v1/courses
Danh sách toàn bộ khóa học đã đăng, đã sắp thứ tự. Đây là endpoint vào của cây nội dung — dùng cho trang danh sách khóa học.
Response 200
{
"data": [
{
"id": "clx7k2h9d9000abcd1234efgh",
"slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
"title": "Giao dịch hàng hóa phái sinh cơ bản",
"description": "Khóa học nền tảng về giao dịch hàng hóa phái sinh.",
"coverImageUrl": "https://cdn.example.com/hvhh/khoa-hoc-1.jpg",
"sortOrder": 0,
"topicCount": 2,
"hasEndQuiz": true,
"topics": [
{
"id": "clx7k2h9d0000abcd1234efgh",
"slug": "gioi-thieu-hang-hoa",
"title": "Giới thiệu hàng hóa",
"description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
"coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
"sortOrder": 0,
"lessonCount": 1,
"hasEndQuiz": true,
"lessons": [
{
"id": "clx7k2h9d0001abcd1234efgh",
"slug": "hop-dong-tuong-lai-la-gi",
"title": "Hợp đồng tương lai là gì?",
"coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
"sortOrder": 0,
"updatedAt": "2026-08-21T07:30:00.000Z"
}
],
"updatedAt": "2026-08-21T07:30:00.000Z"
}
],
"updatedAt": "2026-08-22T07:30:00.000Z"
}
],
"total": 1
}
Field cấp ngoài
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| data | array | không | Mảng khóa học, đã sắp theo sortOrder tăng dần → đồng hạng thì khóa học tạo trước đứng trước. Mảng rỗng nếu chưa có khóa học nào được đăng |
| total | number | không | Số phần tử trong data. Vì không phân trang, luôn bằng data.length — có mặt để client không phải đếm và để sau này thêm phân trang không phá vỡ hợp đồng |
Mỗi phần tử trong data — đối tượng CourseSummary
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string | không | Khóa chính bền vững của khóa học. Dùng làm khóa lưu trữ / đối chiếu ở phía bạn |
| slug | string | không | Định danh dạng URL, duy nhất toàn hệ thống. Dùng để gọi /api/v1/courses/{slug}. Có thể đổi nếu tiêu đề thay đổi |
| title | string | không | Tên khóa học, tiếng Việt có dấu |
| description | string | có | Mô tả ngắn dạng văn bản thuần (không phải HTML). null khi không có nội dung |
| coverImageUrl | string | có | URL tuyệt đối https:// tới ảnh bìa khóa học. null khi không có ảnh |
| sortOrder | number (int) | không | Thứ hạng sắp xếp, từ 0, không đảm bảo liên tục/duy nhất. Thứ tự đúng là thứ tự mảng |
| topicCount | number (int) | không | Số chủ đề đã đăng trong khóa học (chủ đề draft không tính). Có thể là 0 |
| hasEndQuiz | boolean | không | true nếu khóa học có ít nhất một câu hỏi đã đăng ở quiz cuối khóa. Dùng để hiện nhãn "có bài kiểm tra cuối khóa" mà không cần gọi endpoint chi tiết |
| topics | array | không (có thể []) | Chủ đề đã đăng của khóa học kèm mục lục bài học, cấu trúc giống hệt topics[] ở §1.3 (CourseTopic). lessons[] của mỗi chủ đề chỉ có id, slug, title, coverImageUrl, sortOrder, updatedAt — không có contentHtml, không có quizzes. Quiz cuối khóa vẫn chỉ có ở §1.3 |
| updatedAt | string (ISO) | không | Lần cuối bản ghi khóa học được sửa. ⚠️ Không đổi khi nội dung chủ đề / bài học bên trong đổi — xem §7 |
Test — GET /api/v1/courses
1.3 · Chi tiết một khóa học
/api/v1/courses/{slug}
Mục lục một khóa học: danh sách chủ đề kèm danh sách bài học của từng chủ đề, cộng quiz cuối khóa.
contentHtml của bài học và không kèm câu hỏi trong bài — một khóa học đầy đủ sẽ
là một payload rất lớn. Khi người học mở một chủ đề, gọi
/api/v1/topics/{slug} để lấy nội dung thật.
Tham số đường dẫn
| Tham số | Kiểu | Bắt buộc | Giải thích |
|---|---|---|---|
| slug | string | CÓ | slug khóa học lấy từ /api/v1/courses. Phân biệt chữ hoa/thường. Sai hoặc khóa học đang draft → 404 course_not_found |
Response 200
{
"id": "clx7k2h9d9000abcd1234efgh",
"slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
"title": "Giao dịch hàng hóa phái sinh cơ bản",
"description": "Khóa học nền tảng về giao dịch hàng hóa phái sinh.",
"coverImageUrl": "https://cdn.example.com/hvhh/khoa-hoc-1.jpg",
"sortOrder": 0,
"updatedAt": "2026-08-22T07:30:00.000Z",
"topics": [
{
"id": "clx7k2h9d0000abcd1234efgh",
"slug": "gioi-thieu-hang-hoa",
"title": "Giới thiệu hàng hóa",
"description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
"coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
"sortOrder": 0,
"lessonCount": 1,
"hasEndQuiz": true,
"lessons": [
{
"id": "clx7k2h9d0001abcd1234efgh",
"slug": "hop-dong-tuong-lai-la-gi",
"title": "Hợp đồng tương lai là gì?",
"coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
"sortOrder": 0,
"updatedAt": "2026-08-21T07:30:00.000Z"
}
],
"updatedAt": "2026-08-21T07:30:00.000Z"
}
],
"endQuiz": {
"questions": [
{
"id": "clx7k2h9d9005abcd1234efgh",
"type": "multiple_choice",
"question": "Chọn các phát biểu đúng về giao dịch hàng hóa phái sinh:",
"explanation": null,
"options": [
{ "id": "clx7k2h9d9006abcd1234efgh", "text": "Phát biểu A", "isCorrect": true },
{ "id": "clx7k2h9d9007abcd1234efgh", "text": "Phát biểu B", "isCorrect": false }
]
}
]
}
}
Field cấp khóa học
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id, slug, title, description, coverImageUrl, sortOrder, updatedAt | — | — | Giống hệt CourseSummary ở §1.2 |
| topics | array | không (có thể []) | Chủ đề đã đăng, đã sắp theo sortOrder → thời điểm tạo. [] nếu khóa học chưa có chủ đề nào đăng |
| endQuiz | object | null | có | Bài kiểm tra cuối khóa, cấu trúc { questions: [...] } giống endQuiz của chủ đề. null khi không có câu hỏi cuối khóa nào đã đăng — phải kiểm tra null trước khi đọc .questions |
Mỗi phần tử topics[] — đối tượng CourseTopic
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string (CUID) | không | Khóa chính bền vững của chủ đề. Dùng làm khóa lưu trữ / đối chiếu ở phía bạn |
| slug | string | không | Định danh dạng URL, duy nhất toàn hệ thống (không chỉ trong khóa học), chỉ chữ thường/số/gạch nối (đã bỏ dấu). Dùng để gọi /api/v1/topics/{slug}. Có thể đổi nếu tiêu đề thay đổi |
| title | string | không | Tên chủ đề, tiếng Việt có dấu. Hiển thị trực tiếp cho người học |
| description | string | có | Mô tả ngắn dạng văn bản thuần (không phải HTML). null khi không có nội dung |
| coverImageUrl | string | có | URL tuyệt đối https:// tới ảnh bìa chủ đề. null khi không có ảnh — phía bạn cần một ảnh mặc định. Ảnh đọc công khai, <img src> trực tiếp được |
| sortOrder | number (int) | không | Thứ hạng trong phạm vi khóa học này — hai chủ đề ở hai khóa học khác nhau đều có thể là 0. Từ 0, không đảm bảo liên tục và không đảm bảo duy nhất. Chỉ để tham khảo — thứ tự đúng là thứ tự mảng |
| lessonCount | number (int) | không | Số bài học đã đăng trong chủ đề (bài draft không tính), nên khớp đúng lessons.length. Có thể là 0 |
| hasEndQuiz | boolean | không | true nếu chủ đề có ít nhất một câu hỏi đã đăng ở quiz cuối chủ đề. Dùng để hiện nhãn "có bài kiểm tra cuối" mà không cần gọi §1.4 |
| updatedAt | string (ISO) | không | Lần cuối bản ghi chủ đề được sửa (tiêu đề, mô tả, ảnh bìa, trạng thái, thứ tự). ⚠️ Không đổi khi nội dung một bài học bên trong đổi — xem §7 |
| lessons | array | không (có thể []) | Mục lục bài học đã đăng, đã sắp thứ tự. Chỉ có id, slug, title, coverImageUrl, sortOrder, updatedAt — không có contentHtml, không có quizzes |
Test — GET /api/v1/courses/{slug}
Khối "Xem trước mục lục" vẽ cây chủ đề → bài học của khóa học và cho phép
làm thử quiz cuối khóa. Bấm nút slug → §1.4 ở một chủ đề để nạp nhanh slug đó
vào khối test chi tiết chủ đề.
1.4 · Chi tiết một chủ đề
/api/v1/topics/{slug}
Toàn bộ nội dung một chủ đề trong một request: tất cả bài học kèm HTML và quiz, cộng quiz cuối chủ đề. Đây là endpoint nên dùng để tải một khoá học.
Tham số đường dẫn
| Tham số | Kiểu | Bắt buộc | Giải thích |
|---|---|---|---|
| slug | string | CÓ | slug của chủ đề lấy từ /api/v1/courses/{slug}. Phân biệt chữ hoa/thường. Sai, chủ đề đang draft, hoặc khóa học chứa nó đang draft → 404 topic_not_found |
Response 200
{
"id": "clx7k2h9d0000abcd1234efgh",
"course": {
"id": "clx7k2h9d9000abcd1234efgh",
"slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
"title": "Giao dịch hàng hóa phái sinh cơ bản"
},
"slug": "gioi-thieu-hang-hoa",
"title": "Giới thiệu hàng hóa",
"description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
"coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
"sortOrder": 0,
"updatedAt": "2026-08-21T07:30:00.000Z",
"lessons": [
{
"id": "clx7k2h9d0001abcd1234efgh",
"slug": "hop-dong-tuong-lai-la-gi",
"title": "Hợp đồng tương lai là gì?",
"coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
"sortOrder": 0,
"contentHtml": "<h2>Khái niệm</h2><p>Hợp đồng tương lai là…</p>",
"quizzes": [
{
"id": "clx7k2h9d0002abcd1234efgh",
"type": "single_choice",
"question": "Hợp đồng tương lai là gì?",
"explanation": "Xem lại phần Khái niệm.",
"options": [
{ "id": "clx7k2h9d0003abcd1234efgh", "text": "Một thoả thuận mua/bán trong tương lai", "isCorrect": true },
{ "id": "clx7k2h9d0004abcd1234efgh", "text": "Một loại cổ phiếu", "isCorrect": false }
]
}
],
"updatedAt": "2026-08-21T07:30:00.000Z"
}
],
"endQuiz": {
"questions": [
{
"id": "clx7k2h9d0005abcd1234efgh",
"type": "multiple_choice",
"question": "Chọn các phát biểu đúng:",
"explanation": null,
"options": [
{ "id": "clx7k2h9d0006abcd1234efgh", "text": "Phát biểu A", "isCorrect": true },
{ "id": "clx7k2h9d0007abcd1234efgh", "text": "Phát biểu B", "isCorrect": true },
{ "id": "clx7k2h9d0008abcd1234efgh", "text": "Phát biểu C", "isCorrect": false }
]
}
]
}
}
Field cấp chủ đề
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id, slug, title, description, coverImageUrl, sortOrder, updatedAt | — | — | Giống hệt CourseTopic ở §1.3 |
| course | object | không | Khóa học chứa chủ đề này — { id, slug, title }. Mỗi chủ đề luôn thuộc đúng một khóa học nên field này không bao giờ null. Đủ để vẽ breadcrumb / nút "về khóa học" mà không cần gọi thêm request |
| lessons | array | không (có thể []) | Bài học đã đăng, đã sắp theo sortOrder → thời điểm tạo. [] nếu chủ đề chưa có bài nào đăng |
| endQuiz | object | null | có | Bài kiểm tra cuối chủ đề (không phải cuối khóa — cái đó ở §1.3). null khi chủ đề không có câu hỏi cuối nào ở trạng thái đã đăng — phải kiểm tra null trước khi đọc .questions |
| endQuiz.questions | array | không | Mảng Quiz, đã sắp thứ tự. Khi endQuiz khác null thì luôn có ít nhất 1 phần tử |
lessonCount và hasEndQuiz (đã có
lessons.length và endQuiz !== null), cũng không có createdAt hay
status. Cấu trúc endQuiz bọc thêm một lớp { questions: [...] }
thay vì trả thẳng mảng, để sau này thêm được thuộc tính cấp bài kiểm tra (điểm đạt, giới hạn thời
gian…) mà không phá vỡ hợp đồng.
Mỗi phần tử lessons[] — đối tượng Lesson
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string | không | Khóa chính bền vững của bài học |
| slug | string | không | Định danh URL, duy nhất toàn hệ thống (không chỉ trong chủ đề), nên gọi được /api/v1/lessons/{slug} mà không cần biết chủ đề |
| title | string | không | Tên bài học, tiếng Việt có dấu |
| coverImageUrl | string | có | Ảnh bìa riêng của bài học, URL công khai. null nếu không đặt |
| sortOrder | number (int) | không | Thứ hạng trong chủ đề, từ 0. Không đảm bảo liên tục/duy nhất — dùng thứ tự mảng |
| contentHtml | string | không (có thể "") | Nội dung bài học dạng HTML đã sanitize ở máy chủ trước khi lưu. Chuỗi rỗng nếu bài học chưa có nội dung. Xem §3 để biết đúng những thẻ nào có thể xuất hiện |
| quizzes | array | không (có thể []) | Câu hỏi đã đăng thuộc riêng bài học này, đã sắp thứ tự. [] nếu bài học không có câu hỏi |
| updatedAt | string (ISO) | không | Lần cuối bản ghi bài học được sửa (tiêu đề, contentHtml, ảnh bìa, trạng thái, thứ tự). ⚠️ Không đổi khi chỉ sửa quiz của bài học đó |
Bài học không có field description; phần dẫn nhập nằm trong contentHtml.
Test — GET /api/v1/topics/{slug}
Khối "Xem trước nội dung" render contentHtml trong <iframe sandbox>
kèm CSS gợi ý ở §3, và cho phép làm thử quiz với hàm chấm điểm ở
§4.
1.5 · Chi tiết một bài học
/api/v1/lessons/{slug}
Một bài học đơn lẻ. Dùng khi người học mở sâu vào một bài (deep link) và bạn không muốn tải cả chủ đề.
Tham số đường dẫn
| Tham số | Kiểu | Bắt buộc | Giải thích |
|---|---|---|---|
| slug | string | CÓ | slug bài học. Trả 404 lesson_not_found nếu: slug không tồn tại, bài học đang draft, hoặc chủ đề / khóa học chứa nó đang draft |
Response 200
{
"id": "clx7k2h9d0001abcd1234efgh",
"slug": "hop-dong-tuong-lai-la-gi",
"title": "Hợp đồng tương lai là gì?",
"coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
"sortOrder": 0,
"contentHtml": "<h2>Khái niệm</h2><p>Hợp đồng tương lai là…</p>",
"topic": {
"id": "clx7k2h9d0000abcd1234efgh",
"slug": "gioi-thieu-hang-hoa",
"title": "Giới thiệu hàng hóa",
"course": {
"id": "clx7k2h9d9000abcd1234efgh",
"slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
"title": "Giao dịch hàng hóa phái sinh cơ bản"
}
},
"quizzes": [ … giống lessons[].quizzes ở §1.4 … ],
"updatedAt": "2026-08-21T07:30:00.000Z"
}
Các field id, slug, title, coverImageUrl,
sortOrder, contentHtml, quizzes, updatedAt giống hệt
Lesson ở §1.4. Khác biệt duy nhất: có thêm topic —
thông tin tối thiểu về chủ đề cha và khóa học của nó, đủ để vẽ trọn breadcrumb
Khóa học → Chủ đề → Bài học mà không phải gọi thêm request.
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| topic.id | string | không | id chủ đề cha |
| topic.slug | string | không | slug chủ đề cha — dùng để gọi /api/v1/topics/{slug} |
| topic.title | string | không | Tên chủ đề cha, để hiển thị |
| topic.course | object | không | Khóa học chứa chủ đề — { id, slug, title }. Không bao giờ null |
endQuiz) — muốn có thì
gọi endpoint chủ đề (§1.4) hoặc endpoint khóa học (§1.3).
Test — GET /api/v1/lessons/{slug}
Gợi ý: chạy test ở §1.4 trước, rồi bấm nút slug → trong khối
xem trước để nạp nhanh slug bài học vào đây.
1.6 · Danh sách thử thách
/api/v1/challenges
Danh sách toàn bộ thử thách đã đăng, đã sắp thứ tự. Thử thách là một bộ câu hỏi độc lập: không thuộc khóa học hay chủ đề nào, không có bài học — chỉ có câu hỏi. Dùng cho màn hình "chọn thử thách" tách khỏi phần khóa học.
Response 200
{
"data": [
{
"id": "clx9m1a2b0000abcd1234efgh",
"slug": "thu-thach-kien-thuc-hang-hoa",
"title": "Thử thách kiến thức hàng hóa",
"description": "Bộ câu hỏi tổng hợp toàn bộ kiến thức nền tảng.",
"coverImageUrl": "https://cdn.example.com/hvhh/thu-thach-1.jpg",
"sortOrder": 0,
"questionCount": 50,
"updatedAt": "2026-09-15T07:30:00.000Z"
}
],
"total": 1
}
Field cấp ngoài
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| data | array | không | Mảng thử thách, đã sắp theo sortOrder tăng dần. Rỗng nếu chưa đăng thử thách nào |
| total | number | không | Số phần tử trong data. Không phân trang nên luôn bằng data.length |
Mỗi phần tử trong data — đối tượng ChallengeSummary
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string | không | Khóa chính bền vững. Lưu theo field này, không lưu theo slug |
| slug | string | không | Định danh dạng URL, duy nhất toàn hệ thống. Dùng để gọi §1.7. Có thể đổi nếu tiêu đề thay đổi |
| title | string | không | Tên thử thách, tiếng Việt có dấu |
| description | string | có | Mô tả ngắn dạng văn bản thuần (không phải HTML). null khi để trống |
| coverImageUrl | string | có | URL tuyệt đối https:// tới ảnh bìa. null khi không có ảnh |
| sortOrder | number (int) | không | Thứ hạng sắp xếp, từ 0. Thứ tự đúng là thứ tự mảng |
| questionCount | number (int) | không | Số câu hỏi đã đăng. Câu hỏi còn draft không được tính và cũng không trả về ở §1.7. Có thể là 0 |
| updatedAt | string (ISO) | không | Lần cuối bản ghi thử thách được sửa. ⚠️ Không đổi khi câu hỏi bên trong đổi — xem §7 |
questionCount: 0.
Nếu muốn ẩn khỏi giao diện thì tự lọc bỏ các phần tử questionCount === 0 ở phía bạn.
Test — GET /api/v1/challenges
1.7 · Bộ câu hỏi của một thử thách
/api/v1/challenges/{slug}
Bộ câu hỏi của một thử thách. Đây là endpoint duy nhất của API có tham số query — mọi endpoint khác bỏ qua query string hoàn toàn.
Tham số đường dẫn
| Tham số | Kiểu | Giải thích |
|---|---|---|
| slug | string | slug thử thách. Trả 404 challenge_not_found nếu slug không tồn tại hoặc thử thách đang draft |
Tham số query
| Tham số | Kiểu | Mặc định | Giải thích |
|---|---|---|---|
| mode | full | random | full | full: trả toàn bộ câu hỏi đã đăng, đúng thứ tự đã sắp. random: trả cùng bộ câu hỏi đó nhưng đã xáo trộn ngẫu nhiên. Giá trị khác → 400 invalid_mode |
| limit | number (int ≥ 1) | (không giới hạn) | Số câu tối đa trả về. Chỉ áp dụng cho mode=random — ở mode=full tham số này bị bỏ qua. Không truyền thì random vẫn trả hết, chỉ khác thứ tự. limit lớn hơn số câu hiện có thì trả hết, không báo lỗi. Giá trị không phải số nguyên dương → 400 invalid_limit |
# Toàn bộ bộ câu hỏi, đúng thứ tự đã soạn
curl -H "X-API-Key: hvhh_xxxxxxxx" \
"https://hvhh.sfi.com.vn/api/v1/challenges/thu-thach-kien-thuc-hang-hoa"
# Bốc ngẫu nhiên 10 câu cho một lượt chơi
curl -H "X-API-Key: hvhh_xxxxxxxx" \
"https://hvhh.sfi.com.vn/api/v1/challenges/thu-thach-kien-thuc-hang-hoa?mode=random&limit=10"
Response 200
{
"id": "clx9m1a2b0000abcd1234efgh",
"slug": "thu-thach-kien-thuc-hang-hoa",
"title": "Thử thách kiến thức hàng hóa",
"description": "Bộ câu hỏi tổng hợp toàn bộ kiến thức nền tảng.",
"coverImageUrl": "https://cdn.example.com/hvhh/thu-thach-1.jpg",
"sortOrder": 0,
"updatedAt": "2026-09-15T07:30:00.000Z",
"mode": "random",
"totalQuestions": 50,
"returnedQuestions": 10,
"questions": [
{
"id": "clx9m1a2b0001abcd1234efgh",
"type": "single_choice",
"question": "Ký quỹ ban đầu là gì?",
"explanation": "Xem lại phần Ký quỹ.",
"options": [
{ "id": "clx9m1a2b0002abcd1234efgh", "text": "Khoản đặt cọc để mở vị thế", "isCorrect": true },
{ "id": "clx9m1a2b0003abcd1234efgh", "text": "Phí giao dịch", "isCorrect": false }
]
}
]
}
Các field id, slug, title, description,
coverImageUrl, sortOrder, updatedAt giống hệt
ChallengeSummary ở §1.6. Bốn field còn lại:
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| mode | full | random | không | Chế độ đã thực sự áp dụng — phản chiếu lại tham số query, tiện cho log và debug |
| totalQuestions | number (int) | không | Tổng số câu hỏi đã đăng của thử thách, không phụ thuộc limit. Dùng để hiện "10 / 50 câu" |
| returnedQuestions | number (int) | không | Số câu trong questions[] của chính response này. Ở mode=full luôn bằng totalQuestions; ở mode=random bằng min(limit, totalQuestions) |
| questions | array | không | Các câu hỏi, cấu trúc Quiz ở §2. [] nếu thử thách chưa có câu hỏi nào đã đăng |
mode=random không được cache. Response trả
Cache-Control: no-store và X-Cache: BYPASS, không có ETag.
Mỗi lượt gọi là một lần bốc mới — hai request giống hệt nhau vẫn cho hai bộ câu hỏi khác nhau, nên
đừng gửi If-None-Match cho chế độ này và đừng cache lại kết quả ở phía bạn nếu muốn
mỗi lượt chơi một đề khác. mode=full (kể cả khi không truyền mode) vẫn cache
và có ETag như các endpoint còn lại.
limit, nên mỗi câu hỏi có xác
suất được chọn như nhau và không bao giờ trùng trong cùng một response. Đáp án bên trong mỗi câu
không bị xáo — thứ tự options[] luôn theo đúng thứ tự đã soạn; muốn xáo đáp án thì
tự làm ở phía bạn.
Test — GET /api/v1/challenges/{slug}
Gợi ý: đặt mode=random + limit=5 rồi bấm Gửi request vài lần —
dòng tóm tắt cho biết bộ câu vừa bốc có khác lượt trước hay không, tức là random đang
chạy thật chứ không bị cache.
Đối tượng dùng chung: Quiz và QuizOption
Dùng ở cả năm chỗ: lessons[].quizzes[], endQuiz.questions[] của chủ đề,
endQuiz.questions[] của khóa học, quizzes[] của endpoint bài học, và
questions[] của thử thách (§1.7).
Cấu trúc hoàn toàn giống nhau ở cả năm chỗ — viết một hàm render dùng chung là đủ.
Quiz — một câu hỏi
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string | không | Khóa chính của câu hỏi. Dùng làm khóa khi lưu câu trả lời của người học ở phía bạn |
| type | "single_choice" | "multiple_choice" | không | single_choice: đúng một đáp án đúng → radio. multiple_choice: một hoặc nhiều đáp án đúng → checkbox. Đây là hai giá trị duy nhất; hãy xử lý giá trị lạ một cách an toàn (mặc định về checkbox) để API thêm loại mới không làm hỏng client |
| question | string | không | Nội dung câu hỏi, văn bản thuần (không phải HTML). Có thể dài nhiều dòng — hiển thị bằng white-space: pre-wrap nếu muốn giữ ngắt dòng. Không dùng v-html/innerHTML cho field này |
| explanation | string | có | Giải thích đáp án, văn bản thuần. null khi không có nội dung. Thường chỉ hiện sau khi người học đã trả lời |
| options | array | không | Các lựa chọn, đã sắp theo sortOrder tăng dần. Có thể rỗng ([]) nếu câu hỏi chưa có lựa chọn — hãy bỏ qua câu hỏi có options.length === 0 thay vì render một câu hỏi không trả lời được |
QuizOption — một lựa chọn
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | string | không | Khóa chính của lựa chọn. Đây là giá trị nên gửi/lưu khi ghi nhận người học chọn gì — đừng dùng chỉ số mảng (thứ tự có thể đổi) hay text |
| text | string | không | Nội dung lựa chọn, văn bản thuần |
| isCorrect | boolean | không | true nếu là đáp án đúng. Được trả về có chủ ý để bên thứ ba tự chấm điểm — kèm theo đó là hệ quả bảo mật ở §4 |
QuizOption không trả sortOrder (thứ tự mảng đã là thứ tự đúng) và không
trả quizId (đã nằm lồng trong quiz).
contentHtml — đặc tả và cách render
contentHtml là HTML nội dung bài học, đã được làm sạch (sanitize) ở máy chủ trước khi
lưu — vì nội dung này phân phối cho nhiều bên thứ ba, việc làm sạch được thực hiện một lần ở nguồn
thay vì tin rằng mọi bên tiêu thụ đều tự làm sạch. Đây là allowlist: bất cứ thứ gì không có trong danh sách dưới đây đã bị loại bỏ, nên bạn biết chắc
chuỗi này không bao giờ chứa <script>, <style>,
<form>, onclick, javascript: hay data: URI.
Thẻ có thể xuất hiện
| Nhóm | Thẻ |
|---|---|
| Khối văn bản | p br hr div span blockquote pre code |
| Tiêu đề | h1 h2 h3 h4 |
| Định dạng chữ | strong b em i u s sub sup |
| Danh sách | ul ol li |
| Liên kết | a |
| Media | img video source iframe |
| Bảng | table thead tbody tr td th colgroup col |
Thuộc tính có thể xuất hiện
| Thẻ | Thuộc tính | Ghi chú |
|---|---|---|
| a | href title target rel | Sanitizer luôn ép target="_blank" và rel="noopener noreferrer", kể cả link nội bộ |
| img | src alt title width height data-width data-align | src luôn là http/https tuyệt đối (thường là R2). alt có thể rỗng |
| video | src controls poster width height data-width data-align data-video-type | Video tải trực tiếp qua URL trong src |
| source | src type | Nằm trong video |
| iframe | src width height allow allowfullscreen title data-width data-align data-video-type | Video nhúng — chỉ https và chỉ các host ở bảng dưới |
| td / th | colspan rowspan colwidth | colwidth: danh sách số cách nhau bởi dấu phẩy (đơn vị px), do trình soạn thảo sinh ra |
| col | width style | — |
| Mọi thẻ | style | Chỉ các thuộc tính CSS ở bảng dưới |
Host được phép trong iframe src
www.youtube.com · youtube.com · www.youtube-nocookie.com ·
youtube-nocookie.com · player.vimeo.com
Mọi host khác đã bị loại bỏ từ lúc lưu, nên bạn có thể render iframe mà không cần kiểm tra
lại host. iframe bắt buộc https, không cho phép URL tương đối.
Thuộc tính CSS được phép trong style
| Thuộc tính | Giá trị hợp lệ |
|---|---|
| text-align | left right center justify |
| display | block inline-block |
| width, max-width | <số>px hoặc <số>% |
| height | auto, <số>px, <số>% |
| aspect-ratio | <số> / <số> (ví dụ 16 / 9) |
| border | 0 hoặc none |
| margin | chuỗi gồm chữ/số/./%/-/khoảng trắng |
| margin-left, margin-right | auto, 0, <số>px (đây là cách editor căn giữa/căn lề media) |
Các data-* (data-width, data-align, data-video-type)
là siêu dữ liệu của trình soạn thảo. Bố cục thật đã nằm trong style inline, nên bạn có thể bỏ
qua data-* — hoặc dùng data-align nếu muốn tự áp CSS thay vì dựa vào inline
style.
CSS tối thiểu để render tử tế
HTML này không kèm class nào, nên nó thừa hưởng CSS của trang bạn. Nếu trang dùng Tailwind Preflight
(hoặc bất kỳ CSS reset nào), h2/ul/table sẽ mất hết định dạng mặc
định. Hãy bọc nội dung trong một container và áp style cho các thẻ con — đây chính là CSS mà khối
"Xem trước nội dung" ở §1.4 / §1.5 đang dùng:
.hvhh-content { line-height: 1.7; }
.hvhh-content h1 { font-size: 1.5rem; font-weight: 700; margin: 1.5em 0 .5em; }
.hvhh-content h2 { font-size: 1.25rem; font-weight: 700; margin: 1.5em 0 .5em; }
.hvhh-content h3 { font-size: 1.1rem; font-weight: 600; margin: 1.25em 0 .5em; }
.hvhh-content p { margin: .75em 0; }
.hvhh-content ul { list-style: disc; padding-left: 1.5rem; margin: .75em 0; }
.hvhh-content ol { list-style: decimal; padding-left: 1.5rem; margin: .75em 0; }
.hvhh-content a { color: #e00303; text-decoration: underline; }
.hvhh-content blockquote { border-left: 3px solid #e5e7eb; padding-left: 1rem; color: #475569; }
.hvhh-content pre { background: #f1f5f9; padding: .75rem; border-radius: .5rem; overflow-x: auto; }
.hvhh-content code { font-family: ui-monospace, monospace; font-size: .9em; }
/* Media: chặn tràn khung trên mobile — inline style có thể đặt width cố định bằng px */
.hvhh-content :is(img, video, iframe) { max-width: 100%; height: auto; }
.hvhh-content iframe { aspect-ratio: 16 / 9; width: 100%; border: 0; }
/* Bảng: nội dung có bảng rộng, đừng để nó đẩy giãn cả trang */
.hvhh-content table { border-collapse: collapse; width: 100%; }
.hvhh-content :is(td, th) { border: 1px solid #e2e8f0; padding: .5rem; }
Bảng rộng nên bọc thêm một div có overflow-x: auto. Nếu dùng Tailwind Typography
thì class="prose max-w-none" xử lý hầu hết phần trên, chỉ cần thêm quy tắc cho
iframe.
Nếu vẫn muốn sanitize lại ở phía mình
Chính đáng — phòng thủ nhiều tầng. Nhưng cấu hình mặc định của DOMPurify loại bỏ
<iframe>, nên video YouTube/Vimeo trong bài học sẽ biến mất. Hãy khai báo tường minh:
import DOMPurify from 'dompurify'
const html = DOMPurify.sanitize(lesson.contentHtml, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'target', 'colwidth',
'data-width', 'data-align', 'data-video-type'],
})
Chấm điểm quiz
Đáp án đúng (isCorrect) nằm ngay trong response, theo đúng thiết kế: bên thứ ba tự chấm điểm
mà không cần thêm endpoint nào.
/**
* @param quiz một đối tượng Quiz từ API
* @param selected mảng id lựa chọn người học đã chọn
*/
function isAnswerCorrect(quiz, selected) {
const correct = quiz.options.filter((o) => o.isCorrect).map((o) => o.id)
const picked = [...new Set(selected)]
if (quiz.type === 'single_choice') {
// Đúng một đáp án đúng, và người học chỉ được chọn một.
return picked.length === 1 && correct.includes(picked[0])
}
// multiple_choice: phải chọn đủ và không chọn thừa.
return picked.length === correct.length && correct.every((id) => picked.includes(id))
}
Quy ước cần thống nhất trước khi làm giao diện:
single_choiceluôn có đúng mộtisCorrect: true(bảo đảm từ phía SFI khi tạo nội dung).multiple_choicecó từ mộtisCorrect: truetrở lên — không nhất thiết phải ≥ 2.- Không có điểm từng phần trong dữ liệu. Muốn chấm nửa điểm cho câu chọn đúng một phần thì đó là quy tắc phía bạn tự định nghĩa.
- Không có "điểm đạt" (
passingScore) trong dữ liệu. Ngưỡng đạt do phía bạn quyết định.
isCorrect — đúng như trang test này đang làm. Với bài
học tự luyện thì không sao. Nếu cần một kỳ kiểm tra có kết quả đáng tin (chứng chỉ, KPI đào tạo…), phải:
gọi API từ máy chủ của bạn → lược bỏ isCorrect trước khi trả về trình duyệt → chấm
điểm ở máy chủ.
Bảng mã lỗi
Mọi lỗi của /api/v1/* trả JSON dạng
{ "error": "<thông báo tiếng Việt>", "code": "<mã ổn định>" }.
Hãy dựa vào code để phân nhánh xử lý; error là câu tiếng Việt có thể đổi cách
diễn đạt.
| HTTP | code | error | Nguyên nhân | Nên làm gì |
|---|---|---|---|---|
| 401 | missing_api_key | Thiếu header X-API-Key. | Không gửi header, hoặc gửi rỗng | Lỗi cấu hình phía bạn — đừng thử lại, hãy báo lỗi triển khai |
| 401 | invalid_api_key | API key không hợp lệ. | Key sai, gõ nhầm, hoặc đã bị xoá khỏi hệ thống | Đừng thử lại. Kiểm tra biến môi trường, liên hệ SFI xin key mới |
| 403 | revoked_api_key | API key đã bị thu hồi. | Key còn tồn tại nhưng đã bị thu hồi | Đừng thử lại. Liên hệ SFI |
| 404 | course_not_found | Không tìm thấy khóa học. | Slug không tồn tại, hoặc khóa học đang draft | Với đồng bộ định kỳ: coi như khóa học đã bị ẩn, xoá khỏi cache phía bạn |
| 404 | topic_not_found | Không tìm thấy chủ đề. | Slug không tồn tại, chủ đề đang draft, hoặc khóa học cha đang draft | Như trên |
| 404 | lesson_not_found | Không tìm thấy bài học. | Slug không tồn tại, bài học đang draft, hoặc chủ đề / khóa học cha đang draft | Như trên |
| 404 | challenge_not_found | Không tìm thấy thử thách. | Slug không tồn tại, hoặc thử thách đang draft | Như trên |
| 400 | invalid_mode | Tham số mode chỉ nhận giá trị "full" hoặc "random". | ?mode= nhận giá trị lạ (§1.7) | Lỗi phía bạn — sửa URL, đừng thử lại |
| 400 | invalid_limit | Tham số limit phải là số nguyên lớn hơn 0. | ?limit= không phải số nguyên ≥ 1 (§1.7) | Như trên |
| 404 | (không có code) | Not found | Đường dẫn không thuộc API — sai path, sai method, thiếu /v1 | Sửa URL. Lưu ý response này không kiểm tra API key |
| 500 | (không có code) | Thông báo lỗi chung của máy chủ | Sự cố tạm thời phía máy chủ | Thử lại với backoff luỹ tiến |
Không có mã lỗi nào cho rate limit (429) vì hiện chưa có giới hạn tần suất.
Test — thử các nhánh lỗi
Mỗi nút gửi một request "sai" có chủ ý để bạn thấy đúng payload lỗi tương ứng.
Trường hợp biên
| Tình huống | API trả về |
|---|---|
| Chưa đăng khóa học / chủ đề nào | 200 với { "data": [], "total": 0 } — không phải 404 |
| Khóa học đã đăng nhưng chưa có chủ đề nào đăng | 200, topics: [], topicCount: 0 |
| Chủ đề đã đăng nhưng chưa có bài học nào đăng | 200, lessons: [], lessonCount: 0 |
| Chủ đề / khóa học không có quiz cuối | endQuiz: null |
Quiz cuối có câu hỏi nhưng tất cả đang draft | endQuiz: null và hasEndQuiz: false |
| Bài học chưa có nội dung | contentHtml: "" (chuỗi rỗng, không phải null) |
| Bài học không có câu hỏi | quizzes: [] |
| Câu hỏi không có lựa chọn nào | options: [] — hãy bỏ qua câu hỏi này khi render |
Bài học đã đăng nhưng chủ đề cha draft | 404 lesson_not_found — bài học "biến mất" theo chủ đề |
Chủ đề đã đăng nhưng khóa học cha draft | 404 topic_not_found. Bài học của nó cũng trả 404 — gỡ đăng một khóa học ẩn toàn bộ nhánh bên dưới |
| Chuyển một chủ đề sang khóa học khác | id và slug chủ đề không đổi; course trong response trỏ sang khóa học mới, và chủ đề nằm cuối topics[] của khóa học đó |
| Một chủ đề đang được đối tác dùng bị gỡ đăng | Request tiếp theo trả 404, và chủ đề mất khỏi topics[] của khóa học. Không có cơ chế thông báo trước |
Tiêu đề thay đổi khiến slug đổi | Slug cũ trả 404, slug mới xuất hiện trong response của khóa học. id không đổi — đây là lý do nên lưu theo id |
| Chưa đăng thử thách nào | 200 với { "data": [], "total": 0 } |
Thử thách đã đăng nhưng mọi câu hỏi đang draft | 200, questions: [], totalQuestions: 0, questionCount: 0 — không phải 404 |
?limit= lớn hơn số câu hiện có | Trả hết số câu đang có, không báo lỗi — returnedQuestions nhỏ hơn limit |
?limit= ở mode=full | Bị bỏ qua — full luôn trả toàn bộ |
Gọi ?mode=random hai lần liên tiếp | Hai bộ câu khác nhau. Nếu nhận được bộ giống hệt thì có cache nằm giữa bạn và API — xem §1.7 |
| Gửi query string bất kỳ | Bị bỏ qua, nhưng tạo thêm một entry cache riêng. Ngoại lệ: mode / limit ở §1.7 là tham số thật |
Gọi POST / PUT / DELETE | 404 {"error":"Not found"} |
Phát hiện nội dung mới (đồng bộ)
API không có webhook và không có endpoint "cái gì đã đổi từ lúc X". Ba cách, từ nhẹ tới nặng:
| Cách | Cơ chế | Chi phí | Hạn chế |
|---|---|---|---|
| ETag + If-None-Match | Poll endpoint bạn cần, 304 = chưa đổi | Thấp nhất (body rỗng khi không đổi) | Chỉ biết endpoint đó có đổi; JS chạy trong trình duyệt không đọc được ETag nên cách này phải làm ở máy chủ |
| GET /api/health | contentVersion tăng khi bất kỳ nội dung nào đổi | Rất thấp, không cần API key | Không biết cái gì đổi → phải tải lại phần cần dùng. Reset về 1 khi service restart nên phải coi "số khác lần trước" là có đổi, chứ không phải "số lớn hơn" |
| updatedAt | So với mốc bạn lưu | Phải tải danh sách/chi tiết mới so được | Xem cảnh báo ngay dưới |
updatedAt không lan truyền theo cây nội dung. Nó là mốc sửa của chính
bản ghi đó. Sửa contentHtml một bài học không làm updatedAt của chủ
đề đổi; sửa một câu hỏi không làm updatedAt của bài học đổi (quiz không trả
updatedAt). Điều này đúng ở cả cấp khóa học: sửa một bài học không làm
updatedAt của khóa học đổi. Vì vậy: đừng dùng courses[].updatedAt /
topics[].updatedAt để quyết định có tải lại chi tiết hay không — sẽ bỏ sót thay đổi. Hãy dùng ETag của chính endpoint chi tiết, hoặc
contentVersion của /api/health.
Mẫu đồng bộ gợi ý cho một hệ thống có máy chủ riêng
- Mỗi 1–5 phút gọi
GET /api/health. NếucontentVersionkhác lần trước → sang bước 2. - Gọi
GET /api/v1/courses(kèmIf-None-Match), rồi từngGET /api/v1/courses/{slug}để lấy mục lục + quiz cuối khóa, rồi từngGET /api/v1/topics/{slug}(kèmIf-None-Matchriêng cho từng slug) để lấy nội dung thật. Chỉ những endpoint trả200mới cần ghi lại. - Nếu có dùng thử thách: gọi thêm
GET /api/v1/challengesrồi từngGET /api/v1/challenges/{slug}ởmode=full(kèmIf-None-Match) —mode=randomkhông cóETagnên không dùng để đồng bộ được. - Slug từng có nhưng nay mất khỏi danh sách → đánh dấu ẩn ở phía bạn (đừng xoá hẳn nếu còn dữ liệu tiến độ người học tham chiếu tới nó).
Vì mặc định Cache-Control: max-age=0, must-revalidate, poll thường xuyên không tạo tải đáng
kể ở phía Học viện hàng hóa (đã có cache ở máy chủ) — nhưng vẫn tốn một round-trip mạng, nên đừng poll
mỗi giây.
/api/health không bật CORS, nên vòng poll ở bước 1 phải chạy ở máy chủ của
bạn, không chạy được trong trình duyệt — xem §1.1.
Những gì API không có
Nói rõ để bạn không mất thời gian đi tìm:
- Không phân trang, không lọc, không tìm kiếm, không sắp xếp theo tham số. Danh sách trả về toàn bộ; lọc/tìm kiếm làm ở phía bạn. Hai tham số
mode/limitở §1.7 chỉ để bốc ngẫu nhiên câu hỏi của một thử thách, không phải phân trang — không có con trỏ để lấy "trang sau". - Không có endpoint danh sách phẳng. Không có danh sách chủ đề hay bài học độc lập với khóa học — muốn toàn bộ cây thì lặp qua /api/v1/courses → /api/v1/courses/{slug} → /api/v1/topics/{slug}.
- Không có endpoint quiz riêng. Quiz chỉ đến kèm bài học, chủ đề, khóa học hoặc thử thách — câu hỏi của thử thách luôn đến theo cả bộ (§1.7).
- Không có endpoint thư viện media. Bên thứ ba chỉ thấy các URL đã nhúng trong
contentHtml/coverImageUrl. - Không ghi. Không tạo/sửa/xoá nội dung, không gửi kết quả làm bài lên hệ thống.
- Không lưu tiến độ hay điểm của người học. Học viện hàng hóa không biết người học là ai; tiến độ, điểm số, chứng chỉ do phía bạn lưu (khoá theo
idcủa khóa học/chủ đề/bài học/quiz). - Không có nội dung
draft. Không có tham số nào để xem bản nháp. - Không đa ngôn ngữ. Nội dung chỉ tiếng Việt, không có field locale.
- Không webhook. Xem §7.
- Không có
createdAttrong response nội dung (chỉ cóupdatedAt).
Bảo mật và vận hành
Giữ API key ở phía máy chủ. Key nhúng trong bundle JavaScript hay ứng dụng mobile là key công khai — bất kỳ ai cũng đọc được bằng DevTools hoặc giải nén app. Nếu buộc phải gọi từ trình duyệt, hãy hiểu rõ là bạn đang công khai toàn bộ nội dung học tập kèm đáp án, và chỉ chấp nhận điều đó nếu nội dung vốn được coi là công khai.
Danh sách kiểm tra khi tích hợp
- Key nằm trong biến môi trường / secret store, không commit vào repo.
- Mỗi hệ thống tiêu thụ một key riêng, đặt tên rõ ràng lúc tạo để biết đường thu hồi.
- Có xử lý
401/403: dừng gọi, ghi log, cảnh báo vận hành — không thử lại vòng lặp. - Có xử lý
404khi nội dung bị gỡ đăng, không để giao diện hỏng. - Kiểm tra
endQuiz !== null(ở cả cấp chủ đề và cấp khóa học) vàoptions.length > 0trước khi render. - Giữ nguyên thứ tự mảng, khoá dữ liệu theo
id(không theo slug, không theo chỉ số). - Nếu chấm điểm cần đáng tin: lược
isCorrectở tầng máy chủ của bạn (§4). - Nếu render trong trình duyệt: có CSS cho
contentHtml(§3) và cho phépiframenếu sanitize lại.
Xoay key: tạo key mới → cập nhật cấu hình phía tiêu thụ → xác nhận key mới đã hoạt động → thu hồi key cũ. Vì key mới cấp ngay lập tức có hiệu lực, không có thời gian chết.
Liên hệ: mọi việc cấp mới, xoay và thu hồi API key do đội Học viện hàng hóa — Saigon Futures thực hiện. Liên hệ đầu mối kỹ thuật đã trao đổi khi bắt đầu tích hợp.
localStorage của trình duyệt và gửi kèm mọi request test. Đây là công cụ dành cho developer
trên máy cá nhân — đừng mở trang này trên máy dùng chung, và nên bấm Xoá key khi xong.