Technical - ntphiep/zhesen-main GitHub Wiki
Cập nhật 2026-09-23, đọc từ code tại commit trên nhánh master. Khi trang này và code mâu
thuẫn thì code đúng.
| Thành phần | Version | Ghi chú |
|---|---|---|
| Next.js | 16.3.5 | App Router, Server Component mặc định |
| React | 19.3.0 | |
| TypeScript | 5.9.3 |
strict, không dùng any
|
| Tailwind CSS | 4.3.3 | Cấu hình nằm trong app/globals.css, không có tailwind.config.js
|
| Zod | 4.6.5 | Mọi dữ liệu từ Supabase đi qua .parse()
|
| ts-fsrs | 5.4.2 | Thuật toán lên lịch ôn tập |
| @supabase/ssr | 0.12.7 | Client cho server và cho browser tách riêng |
| Vitest | 5.0.1 | 140 file test, 1.107 test |
| ESLint | 9.39.5 |
Hosting là Vercel, function pin ở icn1 Seoul. Database là Supabase tự host trên một EC2 tại
ap-northeast-2 Seoul sau CloudFront, cutover ngày 2026-09-23 từ gói Free của Supabase Cloud
(Decisions mục 1, issue #57).
graph TB
subgraph Browser
LP["LookupPair, hai LookupPanel<br/>client component"]
WL["Wordlist, Practice<br/>client component"]
end
subgraph "Vercel icn1 Seoul"
RSC["Server Component<br/>app/**/page.tsx"]
API["Route Handler<br/>/dictionary/search<br/>/dictionary/translate<br/>/api/ai"]
PX["proxy.ts<br/>chỉ match route đọc session"]
UC["unstable_cache<br/>tag 'lex', 604800s"]
end
subgraph "AWS ap-northeast-2: CloudFront → EC2 (Envoy, GoTrue, PostgREST, Postgres 17 + PGroonga)"
PG[("Postgres<br/>schema lex + public")]
AUTH["GoTrue auth"]
end
CDN["Vercel CDN<br/>full route cache"]
AZ["Azure AI Translator"]
LP --> API
WL --> PX
RSC --> UC
API --> UC
API --> AZ
UC --> PG
PX --> AUTH
RSC --> CDN
CDN --> LP
Ranh giới quan trọng: lib/dictionary/ và lib/grammar/ chỉ đọc, dùng anon client và được
cache. lib/wordlist/ và lib/auth/ ghi theo từng người dùng, không bao giờ được cache.
Một EC2 t4g.medium tại ap-northeast-2, không mở cổng vào công khai, chỉ vào qua SSM
Session Manager. CloudFront đứng trước Envoy qua VPC origin, một CloudFront Function ở lớp
viewer-request chỉ cho qua /auth/v1/, /rest/v1/ và /ai/v1/, còn lại trả 403.
Instance chạy thêm container zhesen-9router, model router của trợ lý AI. 9router trên máy
as-p33 của owner vẫn chạy riêng, và website không gọi tới nó. Envoy chuyển /ai/v1/ tới API
/v1/ của container, nơi 9router tự kiểm API key. Dashboard của 9router có CloudFront
distribution riêng (terraform output router_url), nối thẳng tới cổng 20128 qua một VPC origin
thứ hai, và chỉ có màn hình đăng nhập của 9router chắn trước. Mật khẩu dashboard nằm ở SSM
/zhesen/prod/router_password. Mỗi lần container khởi động, entrypoint xoá mật khẩu mà 9router
tự lưu, nên mật khẩu trong SSM luôn là mật khẩu đúng. Postgres có /dev/shm 256 MB
(shm_size), vì mức 64 MB mặc định làm hash join song song lỗi.
| Việc | Cách làm |
|---|---|
| Mở dashboard 9router | Vào /admin/router, chép mật khẩu rồi mở link. Trang này cũng liệt kê các model trong combo mà trợ lý AI dùng. Khi app không dùng được: chạy pwsh infra/supabase/bin/router-tunnel.ps1, giữ cửa sổ đó mở, rồi vào http://localhost:20128/dashboard. |
| Đổi mật khẩu dashboard | Sửa router_password trên /admin/secrets. Trang ghi SSM, render lại .env và tạo lại container zhesen-9router. Mật khẩu đặt trong trang Settings của 9router mất ở lần khởi động sau. |
| Nối client kiểu OpenAI (Cursor, Hermes) | Base URL https://dzt4vtlz9hm79.cloudfront.net/ai/v1, API key lấy ở mục API Keys của dashboard. |
| Nối client kiểu Anthropic (Claude Code) |
ANTHROPIC_BASE_URL=https://dzt4vtlz9hm79.cloudfront.net/ai, cùng API key. |
| Backup |
bin/backup.sh chép /opt/zhesen/9router/db/data.sqlite, gồm API key và token đăng nhập provider, vào s3://zhesen-db-backups-<account>/9router/ lúc 03:30 UTC. |
| Khôi phục | Dừng zhesen-9router, chép file backup về /opt/zhesen/9router/db/data.sqlite, rồi chạy lại container. |

Cutover chạy infra/supabase/bin/migrate.sh full rồi resync-users --force, xác minh số
dòng và md5 của lex.entries khớp hai bên trước khi đổi NEXT_PUBLIC_SUPABASE_URL và anon
key trên Vercel.

Tài khoản là email cộng mật khẩu, không có bước xác nhận qua mail: GoTrue chạy với
GOTRUE_MAILER_AUTOCONFIRM=true và không cấu hình SMTP, nên không có magic link và không có
đặt lại mật khẩu. Người dùng ẩn danh gắn email và mật khẩu vào đúng tài khoản đang giữ sổ tay
trong một bước, giữ nguyên user id. Làm lại khi có người dùng thật.
Backup là bản pg_dump hằng đêm lên S3, giữ 30 ngày; không còn EBS snapshot.
sequenceDiagram
participant U as Người dùng
participant S as LookupPanel
participant R as /dictionary/search
participant C as unstable_cache
participant P as Postgres
U->>S: gõ từ vào một trong hai ô
S->>R: GET ?q=...&dir=vi, hoặc không có dir cho chiều ngoại ngữ
R->>R: rate limit theo IP
R->>C: cachedSearch(q)
alt cache hit
C-->>R: kết quả đã lưu
else cache miss
C->>P: lex.search hoặc lex.search_vi, một chiều
P-->>C: tối đa 24 dòng mỗi ngôn ngữ
end
R-->>S: JSON, Cache-Control cho browser<br/>CDN-Cache-Control cho CDN
S-->>U: hiển thị
Mỗi ô gọi đúng một chiều qua searchOneDirection (lib/dictionary/search.ts:120): ô nào
người dùng gõ vào quyết định lex.search hay lex.search_vi chạy, không đoán và không gọi
cả hai. Header cache đặt riêng cho browser và cho CDN, vì s-maxage không nói gì với browser.
graph LR
A["Bấm lưu từ"] --> B["ensureSession<br/>lib/supabase/session.ts"]
B -->|"chưa có session"| C["từ chối, không tạo account"]
B --> D["insert public.user_words"]
E["Làm xong một practice mode"] --> F["gradeForMode<br/>lib/practice/grading.ts"]
F --> G["gradeWordById<br/>lib/wordlist/review.ts"]
G --> H["ts-fsrs tính lịch mới"]
H --> I["update cột fsrs_* trên user_words"]
Không chỗ nào tạo account ẩn danh nữa, vì account ẩn danh nằm trong cookie của một browser và
mất theo cookie đó. Lưu từ cần account email. Account ẩn danh tạo từ trước vẫn ghi được, và
các trang cần account đưa nó sang /register để gắn email mà giữ nguyên sổ tay. Tra cứu
không cần account. Cả sáu practice mode đều phải ghi vào lịch FSRS; riêng speaking không bao giờ báo
fail, vì speech recognition nhận nhầm do tiếng ồn hoặc microphone sẽ xoá tiến độ thật.
erDiagram
languages ||--o{ entries : "lang"
entries ||--o{ senses : "entry_id"
entries ||--o{ pronunciations : "entry_id"
entries ||--o{ examples : "entry_id"
entries ||--o{ inflections : "entry_id"
entries ||--o{ lex_relations : "entry_id"
senses ||--o{ examples : "sense_id"
sources ||--o{ entries : "source_id"
entries ||--o{ user_words : "entry_id"
auth_users ||--o{ user_words : "user_id"
auth_users ||--|| profiles : "id"
auth_users ||--o{ review_log : "user_id"
Schema lex giữ dữ liệu từ điển, chỉ đọc với người dùng. Schema public giữ bốn bảng:
languages, user_words, review_log, profiles.
| Bảng | Nội dung | Khoá chính |
|---|---|---|
lex.entries |
Một mục từ. id dạng en:hello
|
id text |
lex.senses |
Nghĩa của một entry, có gloss_vi và gloss_en
|
id text |
lex.pronunciations |
IPA và audio_url. source_ref giữ tên file trên Wikimedia Commons, trang file đó ghi tác giả và giấy phép. Cột attribution vẫn chưa dùng |
id bigint |
lex.examples |
Câu ví dụ, gắn vào entry hoặc sense |
id bigint |
lex.inflections |
Dạng biến đổi của một từ: số nhiều, thì quá khứ, cách viết khác, lỗi chính tả thường gặp |
id bigint |
lex.lex_relations |
Đồng nghĩa, trái nghĩa, phái sinh, cùng gốc, và từ Open English WordNet: khái niệm rộng hơn, hẹp hơn, cùng nhóm. related_entry_id trỏ tới entry đích khi entry đó có |
id bigint |
lex.gloss_terms |
Term tách từ gloss_vi, phục vụ tra ngược. Sinh lại bằng trigger, không ghi tay |
(entry_id, term) |
public.user_words |
Từ người dùng lưu, kèm 10 cột fsrs_*
|
id uuid |
public.review_log |
Mỗi ngày người dùng có ôn hay không | (user_id, day) |
public.profiles |
Role learner hoặc admin, tên hiển thị |
id uuid |
public.admin_audit |
Một dòng cho mỗi lần admin sửa tài khoản hoặc nội dung, chỉ ghi qua hàm, RLS chặn update và delete |
id bigint |
admin.host_samples |
Số đo server và container mỗi 5 giây, giữ 1 giờ, chỉ đọc qua admin.host_samples_since
|
at timestamptz |
Bảng của người dùng bật Row Level Security, policy đều dạng user_id = auth.uid().
Sáu cột srs_* của cơ chế ôn tập cũ đã drop ở migration 0040.
graph LR
Q["Người dùng gõ"] --> F{"Gõ vào ô nào?"}
F -->|"ô ngoại ngữ"| FW["lex.search"]
F -->|"ô tiếng Việt"| RV["lex.search_vi"]
FW --> P1["PGroonga trên headword<br/>và traditional"]
FW --> P2["pg_trgm trên headword_normalized"]
FW --> P3["tsvector zhesen_en, zhesen_es"]
RV --> P4["btree prefix range<br/>trên lex.gloss_terms"]
SG["lex.suggest, gợi ý khi không khớp"] --> P5["pg_trgm trên headword_normalized"]
Không có bước đoán ngôn ngữ. Ô người dùng gõ vào quyết định hàm nào chạy, vì "an", "ban" và "con" đều là headword thật ở cả tiếng Việt lẫn tiếng Anh.
lex.search_vi không còn dùng trigram từ migration 0048: nó khớp bảng lex.gloss_terms
bằng khoảng prefix trên btree. lex.suggest là chỗ duy nhất còn gọi trigram.
PGroonga là lý do search tiếng Trung hoạt động: lex.search('习') trả về 学习 dù 习 không
phải prefix. Không dịch vụ managed Postgres nào khác có extension này, nên nó ràng buộc lựa
chọn hạ tầng. Xem Decisions mục 1.
idx_scan của index PGroonga luôn bằng 0 kể cả khi nó đang chạy. Đọc execution plan, không
đọc counter đó.
| Lớp | Đặt ở đâu | Thời hạn |
|---|---|---|
| Data cache |
unstable_cache trong lib/dictionary/cached.ts, tag lex
|
604800 giây, trừ getCachedWordOfDay giữ 3600 |
| Full route cache |
revalidate = 604800 (LEX_REVALIDATE) cộng generateStaticParams trên mỗi dynamic route |
604800 giây |
| CDN |
CDN-Cache-Control trên /dictionary/search và /dictionary/entry
|
s-maxage=3600 |
| Browser |
Cache-Control đặt riêng |
max-age=0, must-revalidate |
Cửa sổ nâng từ một giờ lên một tuần ngày 2026-09-22 (lib/dictionary/cached.ts:17): từ điển
là kho tĩnh, chỉ đổi khi chạy load script, và POST /api/revalidate xoá tag lex ngay lúc
đó. Không có gì gọi route này tự động, nên đây vẫn là bảo đảm freshness duy nhất: dữ liệu mới
nạp mà quên gọi tay có thể mất tới một tuần mới xuất hiện.
proxy.ts chỉ match /account, /wordlist, /practice, /login, /register, /auth.
Matcher rộng hơn sẽ bắt cả file trong public/ và gắn Set-Cookie vào response lẽ ra cache
được, khiến Vercel từ chối cache nó.
Trong browser dùng auth.getSession(); trên server dùng auth.getUser(). Phân quyền nằm ở
requirePermanentAccount phía server và ở RLS, không nằm ở UI.
Mọi trang dưới /admin chỉ mở khi profiles.role = 'admin' (requireAdmin, lib/auth/admin.ts); một learner đã đăng nhập nhận trang 404 chứ
không phải redirect, để /admin không lộ ra là tồn tại, và mọi route dưới /api/admin/
kiểm tra lại bằng adminUser trong cùng file rồi trả 404. proxy.ts khớp thêm /admin/:path* và /api/admin/:path*,
app/robots.ts chặn /admin khỏi index.
PostgREST giờ lộ thêm schema admin: mỗi hàm bên trong tự gọi admin.assert_admin() trước,
từ chối người gọi ẩn danh hoặc không phải admin, và revoke all ... from public trên cả
schema lẫn từng hàm. Migration 0056 tới 0064 dựng schema này cùng bảng
public.admin_audit ở trên.
Phần AWS của bảng điều khiển dùng role zhesen-vercel-health, nhận quyền bằng Vercel OIDC
(infra/terraform/modules/vercel), không dùng access key dài hạn. Role đọc CloudWatch, S3 và
Cost Explorer, bật tắt đúng một instance, và chạy lệnh trên instance đó qua SSM Run Command.
SSM chỉ trả về 24.000 ký tự đầu của mỗi luồng output, nên kết quả dài hơn bị cắt và trang nói rõ.
Số đo server và container lấy từ container zhesen-sampler trên instance. Nó ghi một dòng vào
admin.host_samples mỗi 5 giây và chỉ giữ 1 giờ (migration 0072). Trang đọc bảng này qua
PostgREST, đo từ laptop mỗi lần 0,5 tới 1,4 giây thay vì 3,4 tới 3,9 giây qua SSM. Khi dòng mới
nhất cũ hơn 20 giây, trang quay về đọc một lần qua SSM và không có biểu đồ 1 giờ.
Thao tác làm gián đoạn hoặc chạy lệnh tuỳ ý (tắt, khởi động lại, SQL được ghi, lệnh shell)
cần gõ lại tên đối tượng và đăng nhập trong 10 phút gần nhất, đọc từ claim amr của access
token. SQL chỉ đọc và khôi phục bản dump chỉ cần lần đăng nhập gần đây. Route ghi một dòng
admin_audit qua admin.record trước khi làm, không ghi được thì không làm, rồi gửi cảnh báo
tới Slack hoặc Telegram. Bật máy khi database đã tắt là việc của /rescue.
Change type trên /admin/infra stop instance, đổi type trong 4 loại t4g.small tới t4g.xlarge
rồi start lại, gián đoạn khoảng 2 tới 3 phút. Giá mỗi loại lấy từ AWS Pricing API, cache 24
giờ. Terraform bỏ qua instance_type (ignore_changes), nên một lần apply không đổi type về
giá trị trong terraform.tfvars.
Cảnh báo không còn gửi email. Owner lưu Slack webhook hoặc Telegram bot trên /admin/infra,
giá trị nằm trong SecureString /zhesen/prod/alert_channels. Thao tác admin gửi thẳng tới đó.
Alarm CloudWatch, budget và /rescue đi qua SNS topic zhesen-alerts, topic này có một HTTPS
subscription tới /api/alerts/sns; route kiểm chữ ký SNS và chỉ nhận đúng topic đó.
Khôi phục chạy nền trên instance, tải bản dump từ S3 vào một database restore_<ngày dump>_<giờ chạy>, và từ chối khi đã có 2 bản như vậy, vì mỗi bản chiếm ổ đĩa bằng database thật.
/admin/secrets liệt kê mọi key của dự án: nơi lưu (SSM, Vercel env hay cả hai và bên nào
đang có hiệu lực), đã đặt hay chưa, version, ngày đổi và 4 ký tự cuối. Giá trị không nằm trong
HTML của trang. Xem nguyên giá trị cần đăng nhập trong 10 phút, ghi audit secret.reveal trước
khi đọc, rồi gửi cảnh báo, và tự ẩn sau 30 giây. Đổi key cần thêm gõ lại tên, ghi
secret.update không kèm giá trị. Key trên instance đổi xong thì chạy render-env.sh và
docker compose up -d cho đúng các service đọc biến đó (INSTANCE_SERVICES,
lib/admin/secrets.ts). postgres_password đổi mật khẩu 12 role trong Postgres trước.
jwt_secret ký lại anon key và service_role key, cập nhật anon key trên Vercel rồi redeploy,
nên từ chối chạy khi chưa có vercel_token. Tham số String do Terraform quản lý chỉ hiện, không
sửa, vì lần apply sau sẽ ghi đè.
/rescue nằm ngoài matcher của proxy.ts và không gọi Supabase. Nó so khoá nhập vào với
SecureString /zhesen/prod/admin_rescue_secret trong SSM, đặt cookie ký HMAC sống 30 phút, và
chỉ cho xem trạng thái và bật máy.
Model key chỉ nằm phía server. Mọi lời gọi đi qua POST /api/ai, và chỉ tài khoản email và
mật khẩu được gọi: người khác nhận 401, còn GET /api/ai trả enabled: false để nút ẩn đi.
Năm task khai ở lib/ai/tasks.ts: enrich, coach, suggest, tags, chat. aiConfig()
trả null là trạng thái hợp lệ: khi deploy không tới được router thì các nút AI biến mất thay vì
hỏng.
Router là 9router trên EC2, gọi qua https://<cloudfront>/ai/v1. lib/secrets.ts đọc
ai_base_url, ai_api_key, ai_model, key Azure và revalidate_secret từ SSM trong một lần
gọi, cache 60 giây, và quay về Vercel env khi SSM thiếu tham số hoặc lỗi. ai_model là combo
zhesen của 9router, chạy lần lượt ag/gemini-3.8-flash, ag/gemini-3.8-flash-low,
orca/deepseek/deepseek-v4-flash-free, kr/glm-5, ag/gpt-oss-120b-medium; không có model
Claude.
Bản dịch cả đoạn trong ô tra cứu không đi qua đây. POST /dictionary/translate
(app/dictionary/translate/route.ts) gọi thẳng Azure AI Translator qua
azureTranslatorConfig() (lib/translate/config.ts:19), tách khỏi aiConfig(): hai dịch
vụ hỏng độc lập nhau. Giới hạn 5.000 ký tự một lần gọi, một request cho mọi ngôn ngữ đích.
graph LR
V["verify<br/>lint, tsc, 848 test"] --> B["build<br/>next build"]
V --> D["deploy<br/>Vercel production"]
B --> D
M["migrations<br/>số không trùng<br/>lệnh destructive phải khai báo"] --> D
Push lên master chạy cả bốn job. deploy chỉ chạy khi verify, build và migrations
đều xanh.
| Lệnh | Việc |
|---|---|
npm run verify |
lint, rồi tsc --noEmit, rồi toàn bộ test. Đây là định nghĩa của "đã kiểm tra" |
npm run dev |
Dev server ở port 3000 |
npm run build rồi npm run start
|
Bắt buộc trước khi bấm thử bất kỳ thay đổi UI nào |
npx vitest related --run <file> |
Chỉ chạy test import file đó |