SDD-042 — Learner ở đâu
Chỉ đạo chủ dự án 22.09.2026: "Tôi muốn thống kê learners theo quốc gia, rồi trong mỗi quốc gia thì thống kê theo tỉnh/thành phố/bang (là một dimension tiếp theo để thống kê)."
1. Việc mà tài liệu này giải
Câu hỏi nghe như một câu truy vấn, nhưng hôm nay hệ không trả lời được, vì hai lý do khác hẳn nhau:
Quốc gia không tồn tại ở đâu cả. Không bảng nào có cột ấy. Hệ ngầm hiểu mọi người ở Việt Nam, và điều đó chưa bao giờ được viết ra chỗ nào để kiểm lại.
Tỉnh/thành là chữ learner tự gõ.
learners.province(migration 0003) nhận bất cứ chuỗi nào dài hơn hai ký tự. NênHà Nội,hà nội,HN,Hanoilà bốn giá trị khác nhau.
Điểm thứ hai là điểm nguy hiểm. Một bảng GROUP BY province chạy được ngay hôm nay và trả về một bảng trông hoàn hảo — có thứ hạng, có con số, cộng lại đúng tổng. Chỉ có điều thứ hạng là bịa, và không có cách nào nhìn ra điều đó từ chính bảng ấy. Đây là lý do tài liệu này dài hơn mức một yêu cầu thống kê thường đáng.
Ngoài phạm vi: quận/huyện (chiều thứ ba), bản đồ, và thống kê theo thời gian.
2. Nguyên tắc
- "Chưa rõ" là một dòng có số, không phải một chỗ trống. §5.
- Chữ người dùng gõ không bị đè. Mã tỉnh đứng cạnh chuỗi gốc, không thay nó. §4.
- Không đoán gần đúng. Không khớp thì để trống và báo lại. §6.
- Nơi ở suy từ NGƯỜI, không chép xuống learner. §5.
3. Quốc gia: lấy từ thứ đã có sẵn
Mọi request đi qua Cloudflare đều mang sẵn mã quốc gia (cf.country). Ghi lại nó lúc đăng nhập là cách rẻ nhất: không hỏi learner thêm câu nào, và có dữ liệu cho cả người đã đăng ký từ lâu ngay lần họ quay lại.
Ba cột trên users (migration 0279):
| Cột | Việc |
|---|---|
country_code | ISO-3166 alpha-2, viết HOA |
country_source | geo (Cloudflare đoán) hoặc declared (người dùng tự khai) |
country_seen_at | Lần gần nhất thấy — để trả lời "số liệu này cũ tới mức nào" |
country_source là cột dễ bỏ qua nhất và cũng là cột quan trọng nhất. declared thắng geo mãi mãi: thiếu luật ấy thì một chuyến công tác hai tuần lặng lẽ đổi quốc gia của một người đã tự khai, và bảng thống kê trôi theo lịch đi lại của learner.
Hai chỗ khác phải nhớ, đều ở recordLoginCountry:
- Không có mã thì không đụng vào dòng. Ghi
NULLđè lên một mã đã có là xoá dữ liệu bằng một lượt đăng nhập. T1không phải một quốc gia — đó là mã Cloudflare trả cho lưu lượng Tor.
Lượt ghi nằm trong waitUntil, không await: thống kê thiếu một dòng là một bảng lệch một đơn vị; đăng nhập hỏng là một người không vào học được.
Giới hạn phải nói ra: VPN và người đang đi xa sẽ bị ghi sai. Đây là cái giá của việc không hỏi thêm câu nào, và nó chấp nhận được vì declared có sẵn đường đè lên khi cần.
4. Tỉnh/thành: mã, bí danh, và chữ gốc giữ nguyên
Ba bảng/cột (migration 0279):
| Việc | |
|---|---|
provinces | Danh mục, một bảng cho MỌI quốc gia — chỉ đạo nói "tỉnh/thành phố/bang", nên chiều thứ hai phải chịu được cả state của Mỹ. Khác biệt giữa chúng là chữ NHÃN, không phải cấu trúc |
province_aliases | Mọi cách người ta thật sự gõ, quy về một mã |
learners.province_code | Mã, đứng cạnh learners.province chứ không thay nó |
Vì sao nay mới dựng bảng tỉnh, sau khi migration 0221 đã từ chối. 0221 viết: "một bảng cứng sẽ sai ngay trong năm nay", vì Việt Nam vừa nhập tỉnh. Câu ấy đúng lúc nó được viết. Hai điều đã đổi: lượt sáp nhập đã xong (2025, còn 34 đơn vị), và bảng này có bí danh — thứ 0221 không có, và là lý do thật khiến một bảng cứng lúc ấy nguy hiểm.
Bí danh chia ba nhóm, nhóm thứ ba là nhóm làm nên giá trị của cả bảng:
| Nhóm | Ví dụ |
|---|---|
| Tên chính đã chuẩn hoá | ha noi |
| Viết tắt hay gặp | hn, hcm, tphcm, sg |
| Tên tỉnh CŨ trước sáp nhập | binh duong → TP. Hồ Chí Minh · ha tay → Hà Nội |
Chuẩn hoá nằm ở MỘT hàm có test (geography/normalize.ts), không rải trong câu SQL. Bốn bước và thứ tự có lý do: bỏ dấu (trước khi cắt tiền tố, vì "Thành phố" có dấu) · chữ thường · cắt tiền tố hành chính lặp lại · bỏ ký tự không phải chữ số.
Chữ đ xử lý riêng vì NFD không tách nó — nó là một ký tự Unicode độc lập, không phải d + dấu. Bỏ sót chỗ này thì "Đà Nẵng" ra à nng và không khớp bí danh nào, một lỗi im lặng đúng ở chỗ đắt nhất: Đà Nẵng, Đồng Nai, Đắk Lắk và Đồng Tháp đều bắt đầu bằng nó.
5. Đếm: hai chiều, và nơi ở suy từ người
Learner có hai dạng, và chúng biết khác nhau về nơi ở:
| Dạng | Nơi ở lấy từ |
|---|---|
| Có tài khoản riêng | chính users của họ |
Hồ sơ con do bố mẹ tạo (user_id NULL) | người giám hộ — nhà ở đâu thì con ở đó |
Phép suy nằm ở tầng đọc, không nhân đôi xuống một cột trên learners: một cột chép lại sẽ lệch vào đúng ngày bố mẹ chuyển nhà và chỉ một trong hai chỗ được cập nhật.
Gom hai tầng ở bộ nhớ chứ không bằng hai câu GROUP BY lồng nhau: quy mô ở đây là vài nghìn dòng, và một câu duy nhất thì chỉ có một chỗ để sai.
"Chưa rõ" là một dòng có số. Learner chưa khai và chưa từng đăng nhập không có quốc gia. Nhóm ấy phải hiện thành một dòng, vì đó là con số nói cho người đọc biết bảng này đáng tin tới đâu. Bỏ nó đi thì tổng vẫn ra một số tròn trịa và không ai biết nó đang thiếu một phần ba. Cùng lý lẽ cho tỉnh: khai quốc gia nhưng chưa khớp tỉnh thì vẫn nằm trong tổng của quốc gia ấy, ở dòng "chưa rõ" của nó.
"Chưa rõ" luôn xếp cuối, dù đông tới đâu: nó không phải một nơi chốn.
Chỉ đếm learner active và paused. Người đã lưu trữ không còn là learner của Nemo12.
6. Dọn dữ liệu cũ
backfillProvinceCodes gán mã cho hồ sơ đã khai bằng chữ tự gõ. Nằm ở code chứ không ở một câu SQL trong migration, vì SQLite không có unaccent — viết lại phép chuẩn hoá bằng SQL là viết lại một hàm đã có test bằng thứ không test được, và hai bản sẽ lệch vào đúng ngày ai đó sửa một bên.
Chỉ đụng dòng province_code IS NULL, nên chạy lại là cách đúng để vét nốt phần còn lại sau khi thêm bí danh mới, và nó không bao giờ đè lên mã đã gán.
Đầu ra có giá trị nhất không phải con số matched mà là unmatched — danh sách chuỗi không khớp, kèm số lần, viết bằng chính chữ người dùng đã gõ. Đó là danh sách bí danh cần thêm. Không có nó thì "chưa rõ" chỉ là một con số và không ai biết phải làm gì để nó nhỏ đi.
Không khớp thì để NULL. Một phép đoán gần đúng ở đây sẽ gán "Hà Nam" vào "Hà Nội" rồi không ai biết — và con số sai ấy trông y hệt con số đúng.
Từ SRC-989, mã còn được gán ngay lúc lưu hồ sơ (lookupProvinceCode), nên backfill chỉ còn việc dọn dữ liệu cũ chứ không phải chạy định kỳ mãi mãi.
7. Chặn lớp sai ở đầu vào
Ô gõ tự do đổi thành bộ chọn (ProvinceSelect, learn), ở cả màn hồ sơ lẫn màn khai lần đầu. Dọn một lớp sai mãi về sau thì tốn hơn là không sinh ra nó.
Bộ chọn không đóng hoàn toàn: dòng cuối là "Nơi khác" mở ra một ô gõ. Một bộ chọn đóng thì learner ở nước ngoài không khai được gì và sẽ chọn bừa một tỉnh cho xong — một dòng sai do ép buộc còn tệ hơn một dòng "chưa rõ" trung thực. Chữ gõ tay ấy vẫn đi qua bảng bí danh, nên "Sài Gòn" gõ tay vẫn đếm đúng.
API danh mục hỏng thì component rơi về ô gõ tự do, không hiện một ô chọn rỗng: đây là ô nằm giữa luồng khai hồ sơ lần đầu, và chặn nó là chặn learner vào học.
8. Bề mặt
| API | GET /v1/mentor/geography — sau requireSession, vai mentor/staff/admin |
GET /v1/public/provinces?country=VN — danh mục hành chính, công khai | |
| Trang | dolphin.nemo12.com/geography — "Where learners are", tiếng Anh theo luật của cổng mentor |
Hai lối vào, và cả hai đều có lý do đứng ở đó:
| Lối vào | Vì sao |
|---|---|
| Menu trái của dolphin, ngay sau Dashboards | Hai mục cùng trả lời "cả cohort trông thế nào", chỉ khác chiều nhìn |
| Một link cạnh bộ chọn trên chính trang Dashboards | Người đã đứng ở đó là người đang hỏi đúng câu ấy |
Không đặt ở hàng lối tắt trang chủ cổng. Hàng ấy có luật viết sẵn "hai thẻ, không phải tám", với tiêu chí "hai việc thường xuyên nhất của cả cổng" - và thống kê nơi ở là việc thỉnh thoảng làm. Thêm thẻ thứ ba ở đó là làm hàng lối tắt trượt dần thành bản sao thứ hai của menu, đúng thứ luật ấy sinh ra để chặn.
Không có route nào cho người ngoài đọc số liệu learner, kể cả số liệu tổng hợp. Từ chối trả 401 chứ không 403, theo luật chung của repo: 403 nói ra rằng tài nguyên có thật, và với dữ liệu trẻ em thì chính điều đó đã là một rò rỉ.
Trên trang, độ phủ đứng trước thứ hạng: tổng learner, số đã định vị được, và một câu nói rõ nhóm chưa rõ là ai. Không có phần trăm nào cho từng tỉnh chừng nào nhóm chưa rõ còn lớn — một tỷ lệ tính trên con số một phần ba là phỏng đoán thì đọc ra như một con số chính xác mà không phải.
9. Điều chưa chốt
| Câu hỏi | Ghi chú |
|---|---|
| Danh sách 34 tỉnh sau sáp nhập | Viết theo hiểu biết tới 09.2026; cần chủ dự án soát lại một lượt trước khi tin vào thứ hạng |
| Bí danh cho quận/huyện | Đã có vài cái hay gặp (nha trang, da lat, vinh). Thêm dần theo unmatched |
| Quốc gia ngoài Việt Nam | provinces chịu được, nhưng chưa nạp bang/tỉnh nước nào khác. Nạp khi có learner thật ở đó |
| Chạy backfill trên production | Chưa có đường bấm; xem §10 |
10. Việc còn lại
| # | Việc |
|---|---|
backfillProvinceCodes trên production | |
| 2 | Bộ chọn tỉnh ở marlins (hồ sơ phụ huynh) — hiện mới đổi ở learn |
| 3 | Ô cho learner tự khai quốc gia (country_source = 'declared') |
11. Đường chạy backfill (SRC-1071)
Khu Province codes trong Ops của admin, hai lượt gọi tách đôi:
GET /v1/admin/geography/province-backfill | chạy khô — đếm và liệt kê, không ghi dòng nào |
POST /v1/admin/geography/province-backfill | chạy thật |
Tách đôi có chủ ý. Người vận hành cần nhìn được còn bao nhiêu hồ sơ chưa khớp, và chúng gõ là gì trước khi quyết định chạy. Gộp làm một thì cách duy nhất để biết mình có nên chạy hay không là đã chạy rồi — trên hồ sơ learner thật.
Hai đường dùng chung một hàm, chỉ khác cờ dryRun. Viết một hàm đếm riêng là mở đường cho ngày hai hàm lệch nhau, và lúc ấy màn xem trước hứa một đằng còn lượt chạy làm một nẻo.
Nút bấm tay, không phải cron. Đây là việc dọn dữ liệu cũ và nó tự cạn: từ SRC-989 mỗi lượt lưu hồ sơ đã tự gán mã, nên số hồ sơ còn thiếu chỉ đi xuống. Một cron chạy mãi cho một việc có điểm kết thúc là một việc chạy mãi mà không ai còn nhớ vì sao.
Lượt chạy vào sổ (audit_log, action geography.province_backfill) kèm ba con số. Một lượt ghi hàng loạt lên hồ sơ learner mà không biết ai chạy là một câu hỏi không trả lời được vào đúng ngày có người thắc mắc vì sao hồ sơ của con mình đổi tỉnh.
unmatched là đầu ra đáng giá nhất, và màn hình trình bày nó như một danh sách việc chứ không phải một danh sách lỗi: mỗi dòng là một cách người dùng thật sự gõ mà bảng bí danh chưa biết. Thêm vào province_aliases bằng một migration rồi bấm lại là cách con số "chưa rõ" nhỏ dần.
Nút chạy tự khoá khi matched = 0: không còn gì để gán thì không có gì để bấm.
Trace
| REQ | Section |
|---|---|
| REQ-VIS-16 (quốc gia) | §3 |
| REQ-VIS-16 (tỉnh/thành) | §4, §7 |
| REQ-VIS-16 (đếm hai chiều) | §5 |
| REQ-VIS-16 (dữ liệu cũ) | §6 |
| REQ-VIS-16 (bề mặt) | §8 |