Technical - ntphiep/zhesen-main GitHub Wiki

Technical

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.

Stack

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).

Kiến trúc

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
Loading

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.

Hạ tầng

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.

Kiến trúc AWS

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.

Các bước migration

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.

Luồng tra cứu

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ị
Loading

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.

Luồng lưu từ và ôn tập

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"]
Loading

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.

Database

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"
Loading

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.

Search chạy bằng gì

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"]
Loading

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 đó.

Cache

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.

Auth

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.

Admin

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.

AI assistant

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.

CI và deploy

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
Loading

Push lên master chạy cả bốn job. deploy chỉ chạy khi verify, build và migrations đều xanh.

Lệnh

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 đó
⚠️ **GitHub.com Fallback** ⚠️