---
url: https://docs.nemo12.com/architecture/sdd-020-school-registry.md
description: >-
  Registry trường chuyên (SDD-020): mở rộng target_schools, quản lý trong
  Dolphin và trang công khai cho từng trường.
---

# SDD-020 — Registry trường chuyên: quản lý & trang công khai

[SDD-014](sdd-014-exam-acquisition.md) §2 đã thiết kế registry trường chuyên toàn quốc từ 2026-08-14 nhưng **chưa có dòng code nào** — `target_schools` vẫn đúng 5 cột của migration 0004 và `specialized_programs` chưa tồn tại. Tài liệu này **thi hành** phần đó, rồi dựng thêm hai lớp mà SDD-014 không cần nhưng SRC-203 yêu cầu: **người của trường** (§4) và **thành tích/số liệu theo năm học** (§5), cùng ba trang công khai (§6-7).

Phân vai giữa hai tài liệu, để về sau không ai phải đoán:

| | SDD-014 | SDD-020 (tài liệu này) |
| --- | --- | --- |
| Quan tâm | trường là **nguồn đề thi** | trường là **nơi con sẽ thi vào** |
| Đọc bởi | Coral (staff), pipeline crawl | Dolphin (quản lý), nemo12.com (công khai) |
| Sở hữu cột | `source`, provenance crawl | `slug`, `status`, mô tả, ảnh, thành tích |

Cả hai đọc **cùng một bảng** `target_schools`.

## 1. Nguyên tắc

1. **Một danh sách trường duy nhất.** `exams.target_school_id` đã trỏ vào `target_schools` từ migration 0004. Dựng bảng `schools` mới cho "phần giới thiệu" là tạo ra hai danh sách trường — rồi tới ngày một đề thi gắn vào trường ở danh sách A trong khi trang giới thiệu đọc danh sách B, và không ai biết bên nào đúng. Nên §2 **mở rộng tại chỗ**.
2. **Con số phải có năm học và nguồn.** Một tỉ lệ chọi không kèm năm là một con số vô nghĩa; kèm năm mà không kèm nguồn là một con số không kiểm chứng được. Cả hai đều là cột `NOT NULL` (§5).
3. **Thiếu thì để trống, cấm điền 0.** Đúng luật đã chốt ở Dashboard ([SRC-199](../intake.md)): `0` nghĩa là "đo được và bằng không", khác hẳn "không có dữ liệu". Trên bảng so sánh, ô trống nói thật; ô `0` nói dối theo hướng bất lợi cho trường đó.
4. **Nemo12 không xếp hạng trường** (Q-137). Trang so sánh đặt các số cạnh nhau và để người đọc tự kết luận; không có cột "điểm tổng", không có thứ tự "tốt nhất".
5. **Chỉ nêu tên người đã đồng ý** (§4).

## 2. Trường (REQ-SCH-15) — mở rộng `target_schools`

```text
target_schools (0004):  id · name · province · kind(chuyen|chon|thuong) · source
target_schools (0047 thêm):
  slug UNIQUE?          -- URL công khai /specialist-schools/{slug}
  short_name?           -- "Ams", "KHTN" — tên hiện trên thẻ và bảng so sánh
  managed_by?           -- so | dai_hoc            (SDD-014 §2)
  website? · established_year?
  summary?              -- 1-2 câu, hiện trên thẻ
  description?          -- phần dài, hiện ở trang trường
  logo_media_id? · hero_media_id? → media(id)      (SDD-019 §2)
  status                -- draft | published | archived, MẶC ĐỊNH 'draft'
  display_order · updated_at
```

**`status` mặc định `draft`** dù bảng đã có sẵn dữ liệu từ trước: các dòng cũ do pipeline đề thi sinh ra chỉ có tên + tỉnh, chưa ai rà soát, và một trang công khai hiện ra tên trường trống trơn còn tệ hơn không có trang. Ai muốn trường lên sóng thì mở `#/truong` bổ sung thông tin rồi publish — một hành động có chủ ý.

**Cổng publish**: cần `slug` + `province` + `summary`. `slug` sinh tự động từ tên (bỏ dấu tiếng Việt) nhưng sửa được; đổi `slug` của trường đã publish là gãy link cũ nên cổng quản lý cảnh báo.

```text
specialized_programs: id · school_id → target_schools(id) · subject_code
  · name_vi          -- "Chuyên Toán"
  · intake_size?     -- chỉ tiêu thường niên
  · notes? · status · display_order
```

`subject_code` cố ý **không** khoá ngoại vào `subjects` của knowledge graph: lớp chuyên có Sinh, Sử, Địa, Nga, Nhật… trong khi graph mới có Toán/Văn/Anh/Lý/Hoá/Tin. Bắt khoá ngoại nghĩa là không nhập nổi lớp Chuyên Sinh cho tới ngày có content môn Sinh — tức lấy giới hạn nội dung của mình áp lên sự thật ngoài đời.

## 3. Quản lý trong Dolphin (REQ-SCH-15)

`dolphin #/truong` — danh sách theo tỉnh, thêm/sửa trường, quản lý lớp chuyên. Cùng cổng vai `requireMentor` (mentor · staff · admin) với phần còn lại của Dolphin, và cùng nằm sau Cloudflare Access.

Sửa một trường **không** ảnh hưởng đề thi đã gắn vào nó — quan hệ là khoá ngoại theo `id`, không theo tên. Xoá trường thì **cấm** nếu còn đề hoặc còn người trỏ vào; thay bằng `status='archived'`.

## 4. Người của trường (REQ-SCH-16)

```text
school_students: id · school_id → target_schools(id)
  · full_name · program_id? → specialized_programs(id) · program_label?
  · cohort_year?        -- khoá vào (2018)
  · grad_year?          -- năm tốt nghiệp
  · kind               -- alumni | current, MẶC ĐỊNH 'alumni'
  · highlight?          -- một dòng: "HCV Olympic Tin học quốc tế 2023"
  · achievements?       -- nhiều dòng
  · current_org?        -- đang học/làm ở đâu
  · photo_media_id? → media(id)
  · mentor_profile_id? → mentor_profiles(id)   -- cùng một người
  · sources_json · verification_status(unverified|verified|disputed)
  · verification_note? · verified_at? · verified_by?     -- ghi chú là NỘI BỘ
  · consent · consent_note?
  · featured · status(draft|published|archived) · display_order · timestamps
```

**Hai cổng chặn publish**, cả hai thi hành trong service chứ không chỉ nhắc trên UI:

> `status='published'` chỉ đặt được khi `consent=1` **và** `verification_status='verified'`.

Cổng thứ hai đến từ chỉ đạo chủ dự án 2026-08-17 (*"Mọi thông tin đều cần được kiểm chứng và rà soát lại, rồi mới đưa thông tin vào hệ thống"*) khi gửi danh sách cựu học sinh đầu tiên. Nó đáng có mặt trong schema chứ không phải trong quy trình làm việc, vì đây là **lời khẳng định về người có thật**: một dòng sai năm sinh hay sai huy chương trên trang công khai không chỉ là dữ liệu xấu mà là nói sai về một cá nhân. `sources_json` giữ danh sách nguồn đã đối chiếu; `verification_status='disputed'` dành cho mảnh thông tin mà các nguồn nói khác nhau — **giữ lại và đánh dấu**, không im lặng chọn một bên.

Ba quyết định đáng ghi lại:

* **"Học sinh của trường" ở đây là người được nêu tên công khai, không phải sổ điểm danh** (Q-136). Chỉ đạo nói "thêm hoặc sửa thông tin của các student của từng trường", còn phần công khai nói "danh sách các cựu học sinh tiêu biểu" — nên bảng này phục vụ đúng cái thứ hai. Bê danh sách ~1.500 học sinh đang học của một trường chuyên vào D1 rồi đẩy lên trang công khai là hồ sơ trẻ vị thành niên, thuộc REQ-SEC-04, không phải một tính năng marketing. `kind='current'` giữ lại cho trường hợp một em đang học được nêu tên (đoạt giải), và em đó vẫn phải qua cổng `consent` như mọi người khác.
* **`consent` là cổng publish, không phải ghi chú.** Nêu tên và thành tích một người có thật lên trang công khai mà không hỏi họ là việc không sửa lại được sau khi trang đã lên.
* **`mentor_profile_id` nối hai chiều với [SDD-019](sdd-019-mentor-albums.md).** Một người vừa là cựu học sinh Ams vừa là mentor của Nemo12 thì chỉ có **một** hàng sự thật cho mỗi vai, và trang trường hiện huy hiệu "Mentor của Nemo12" mở sang hồ sơ. Đây chính là chỗ hai chỉ đạo SRC-201 và SRC-203 gặp nhau: câu "mentor đều là cựu học sinh trường chuyên" đọc từ `mentor_profiles.alumni_school_id` → `target_schools`, cùng bảng mà trang trường đang đọc.

## 5. Thành tích & số liệu theo năm học (REQ-SCH-17)

```text
school_achievements: id · school_id → target_schools(id)
  · school_year        -- "2024-2025", BẮT BUỘC
  · category           -- hsg_quoc_gia | hsg_tinh | olympic_quoc_te | dai_hoc | khac
  · title · detail? · quantity?
  · source_url? · source_note?
  · status · display_order · created_at

school_year_stats: (school_id, school_year, program_id?) PK
  · applicants? · seats? · cutoff_score? · ratio?   -- đều cho phép NULL
  · source_url? · source_note? · updated_at
```

* **`school_year` là chuỗi `"YYYY-YYYY"`, không phải số.** Năm học Việt Nam vắt qua hai năm dương lịch; ép về một số nguyên là buộc mọi chỗ đọc phải tự nhớ quy ước "2024 nghĩa là 2024-2025", và sớm muộn có chỗ nhớ khác.
* **`ratio` (tỉ lệ chọi) lưu thẳng chứ không luôn tính từ `applicants/seats`**, vì nguồn công bố thường chỉ đưa tỉ lệ. Khi có đủ cả hai, service tính lại và **cảnh báo nếu lệch** thay vì im lặng ghi đè — hai nguồn nói khác nhau là thông tin, không phải lỗi.
* Không cột nào ở đây `NOT NULL` ngoài khoá và `school_year`: một trường có thể công bố điểm chuẩn mà không công bố số dự thi. Ô trống là câu trả lời hợp lệ (nguyên tắc §1.3).

## 6. Trang công khai (REQ-BRD-09)

| Route | Nội dung |
| --- | --- |
| `/specialist-schools` | mọi trường `published`, lọc theo tỉnh và loại; mỗi thẻ: logo · tên ngắn · tỉnh · số lớp chuyên · một dòng |
| `/specialist-schools/{slug}` | giới thiệu · lớp chuyên · **thành tích nhóm theo năm học, mới nhất trước** · cựu học sinh tiêu biểu (mentor Nemo12 có huy hiệu riêng) · số liệu tuyển sinh |
| `/specialist-schools/so-sanh?ids=a,b,c` | 2-4 trường trên cùng bộ chỉ số |

Bộ chỉ số của trang so sánh — **cố định, ít, và mỗi ô kèm năm học**:

tỉnh · loại · số lớp chuyên · chỉ tiêu (năm gần nhất) · tỉ lệ chọi (năm gần nhất) · điểm chuẩn cao nhất trong các lớp chuyên (năm gần nhất) · số thành tích quốc gia/quốc tế trong 3 năm gần nhất · số cựu học sinh tiêu biểu đã publish.

Hai chỉ số cuối **cố ý là "số bản ghi Nemo12 đang có"**, không phải "thành tích thật của trường", và trang nói rõ điều đó ngay dưới bảng. Một trường ít bản ghi trong hệ có thể chỉ là trường chưa ai nhập, và để người đọc hiểu nhầm con số đó thành chất lượng trường là cách tạo ra một bảng xếp hạng giả mà §1.4 vừa từ chối làm.

## 7. API

| Method | Path | Quyền |
| --- | --- | --- |
| GET | `/v1/public/schools?province=&kind=` | công khai |
| GET | `/v1/public/schools/{slug}` | công khai |
| GET | `/v1/public/schools/compare?ids=` | công khai (2-4 id) |
| GET · POST | `/v1/showcase/schools` | 🔐 role mentor/staff/admin |
| PATCH · DELETE | `/v1/showcase/schools/{id}` | 🔐 như trên |
| POST | `/v1/showcase/schools/{id}/programs` · PATCH · DELETE `/v1/showcase/programs/{id}` | 🔐 như trên |
| POST | `/v1/showcase/schools/{id}/students` · PATCH · DELETE `/v1/showcase/students/{id}` | 🔐 như trên |
| POST | `/v1/showcase/schools/{id}/achievements` · DELETE `/v1/showcase/achievements/{id}` | 🔐 như trên |
| PUT | `/v1/showcase/schools/{id}/stats` | 🔐 như trên |

Nhánh công khai chỉ trả dòng `status='published'`, và với người thì thêm `consent=1`. `consent_note` cùng `source_note` là ghi chú nội bộ — **không** ra API công khai, cùng lý do với `alumni_note` ở [SDD-019](sdd-019-mentor-albums.md) §4.

## 8. Cái chưa làm

| Việc | Vì sao để lại |
| --- | --- |
| Seed registry toàn quốc bằng AI | SDD-014 §2 đã đặc tả (crawl + cross-check ≥2 nguồn); đợt này dựng bảng và cổng nhập tay trước, seed là việc của pipeline |
| Xếp hạng / điểm tổng trường | Cố ý không làm (Q-137) |
| Danh sách học sinh đang theo học | Child-privacy, ngoài phạm vi (Q-136) |
| Nối `specialized_programs` ↔ `learner_exam_targets` | Mục tiêu thi của learner hiện lưu tên trường dạng chuỗi; nối lại là một đợt migrate dữ liệu riêng |

## Trace

REQ-EXAM-11 · REQ-SCH-15→§2-3 · REQ-SCH-16→§4 · REQ-SCH-17→§5 · REQ-BRD-09→§6-7.
US-88→§3 · US-89→§4-5 · US-90→§6 · US-91→§6.
Quyết định ✍️: Q-135 (mở rộng `target_schools` thay vì bảng mới) · Q-136 (phạm vi "student") · Q-137 (không xếp hạng trường).
