GEARVN OPS — QA / TEST REPORT

Báo cáo kiểm thử: Chính sách & Rule — Campaign Điểm thi

Đối chiếu từng mục (§1–§8) của trang “Chính sách & Rule” (nguồn yêu cầu) với code thực thi của microsite user-mkt-diemthi/web (Next.js on Cloudflare Worker). Mỗi case nêu rõ kỳ vọng theo policy, hành vi thực tế trong code, kết luận PASS/FAIL/PARTIAL/GAP kèm bằng chứng file:dòng.
Ngày test: 2026-06-26 Kỳ thi test: 2025 (có data công bố) Phạm vi code: src/lib/server/*, app/api/exam-voucher/*, db/schema.ts, middleware.ts Build deploy: wrangler.toml / wrangler.gearvn.toml (KHÔNG set RL_*)
0
Tổng case
0
Pass
0
Partial
0
Fail
0
Gap / chưa làm
Tất cả PASS — đúng policy PARTIAL — đúng một phần FAIL — sai/ngược policy GAP — chưa implement N/A — thủ công/ngoài code
Kết luận nhanh (TL;DR). Lớp online cơ bản (tra điểm → ký ticket → claim → tra lại mã, rate-limit, captcha, dedup, hash IP) đã chạy đúng. Tuy nhiên có 1 xung đột logic lớn ở §2 (code chặn theo cá nhân SBD & SĐT, policy yêu cầu chặn theo CẶP), và 3 nhóm tính năng trong policy chưa được code làm: §3b ISSUE_LIMIT, §4 blocklist IP/CIDR → BLOCKED 403, và §7 các trạng thái ISSUE_LIMIT / BLOCKED / NOT_ELIGIBLE. §3a rate-limit cơ chế đúng nhưng giá trị mặc định lệch (code chặt hơn spec ~2–6×).
⚠️ CẢNH BÁO QUAN TRỌNG — Report dựa trên REPO, repo LỆCH với hệ thống ĐANG CHẠY. Báo cáo này audit code trong 2 repo GitHub cuocdoichilabatnuocdodi/mkt-diemthi + admin-core-ops-frontend-nextjs (đã git fetch 2026-06-26: mỗi repo chỉ 1 commit, 1 branch main, không branch/tag khác — local đã trùng khít, không có gì để pull thêm). Nhưng kiểm thử thực tế trên hệ thống deploy cho thấy bản chạy có nhiều tính năng KHÔNG nằm trong 2 repo này — đã xác nhận tận mắt: Mọi mục đánh GAP/FAIL dưới đây phản ánh REPO, KHÔNG chắc đúng với hệ thống thật. Để audit chính xác cần đúng source mà bản deploy được build (2 repo GitHub hiện tại không phải nguồn đó), hoặc chuyển sang kiểm thử hộp đen trực tiếp trên deploy. Các mục đã được người dùng kiểm chứng trực tiếp được gắn nhãn ✓ DEPLOY.
§1 Triết lý§2 Định danh (CẶP)§3a Rate-limit ★ §3b Trần voucher/IP§4 Blocklist IP/CIDR ★§5 Lưu IP soi/chặn §6 Cửa hàng offline§7 Bảng trạng thái ★§8 Biến môi trường
1

Triết lý — chống phá, không cản thí sinh thật (2 lớp)

Online dễ nhận (chống phá vừa đủ) · Cửa hàng là chốt chặn thật (đối chiếu chính chủ)
ĐẠT (thiết kế)
CaseKỳ vọng (policy)Thực tế (code)KQ
TC-1-1 Lớp online “dễ nhận” nhưng có chống phá: captcha + rate-limit + dedup + ký ticket có hạn. Có đủ: Turnstile (turnstile.ts), rate-limit KV (rate-limit.ts), ticket HMAC TTL 900s (exam-voucher-service.ts:62,378), dedup khi claim. PASS
TC-1-2 Voucher “tạo dễ, dùng khó” — chốt chặn thật ở cửa hàng (đối chiếu chính chủ). Voucher lưu kèm phoneMasked + candidateNumberMasked để cửa hàng đối chiếu (exam-voucher-service.ts:510,513). Khâu đối chiếu là thủ công (§6). PASS
Vì sao PASS: Triết lý 2 lớp được phản ánh đúng trong kiến trúc — online có rào chống phá nhưng không bắt nhập định danh nặng; chốt thật đẩy về cửa hàng qua dữ liệu masked trên voucher. Đây là mục định hướng, không có ràng buộc kỹ thuật cứng để fail.
2

Logic định danh — chống trùng theo CẶP (SBD + SĐT)

Policy: SBD lẻ = KHÔNG chặn · SĐT lẻ = KHÔNG chặn · chỉ CẶP mới chặn (idempotent)
SĐT lẻ: PASS
Policy yêu cầu
Chỉ chặn theo CẶP (SBD+SĐT). 1 SBD được dùng nhiều SĐT; 1 SĐT được dùng cho nhiều SBD. Index duy nhất: ux_claims_campaign_cand_phone (campaignId, candidateNumberHash, phoneHash).
Code thực tế
Chặn theo CÁ NHÂN: trùng SBD → ALREADY_CLAIMED; trùng SĐT → PHONE_ALREADY_USED. Hai index riêng: ux_claims_campaign_cand (campaignId, candHash) + ux_claims_campaign_phone (campaignId, phoneHash).
CaseKịch bảnPolicy mong đợiCode trả vềKQ
TC-2-1 Cùng SBD, SĐT khác (chính chủ đổi số) claim lần 2. ISSUED (cấp voucher mới — SBD lẻ không chặn). ALREADY_CLAIMED — chặn ở exam-voucher-service.ts:445–463. FAIL
TC-2-2 Cùng SĐT, SBD khác (1 phụ huynh nhận hộ nhiều con). ISSUED (SĐT lẻ không chặn). ISSUEDthực đo trên site deploy 2026-06-26: 1 SĐT nhập nhiều SBD đều nhận được mã → đúng policy “SĐT lẻ không chặn”. PASS
TC-2-3 Đúng y CẶP (cùng SBD + cùng SĐT) claim lại. ALREADY_CLAIMED — trả lại mã cũ (idempotent). ALREADY_CLAIMED + trả voucherCode cũ (:455–463). Khớp kết quả dù chặn theo nhánh SBD. PASS
TC-2-4 Index DB đúng theo policy (CẶP 3 cột). ux_claims_campaign_cand_phone (3 cột). Không tồn tại. Schema có 2 index 2-cột riêng (db/schema.ts:170–171). FAIL
Vì sao FAIL / cần owner chốt: Code đang theo yêu cầu cũ REQ §5.2/§7.4 (1 SBD = 1 voucher, 1 SĐT = 1 voucher — chặt hơn). Trang “Chính sách & Rule” (nguồn test) lại nới sang chặn-theo-cặp. Hai bên mâu thuẫn trực tiếp: với policy mới, code đang chặn nhầm ca TC-2-1 & TC-2-2 (từ chối người hợp lệ). Cần chủ dự án quyết: giữ chặt (cá nhân) hay nới (cặp). Nếu chọn “cặp”: phải bỏ PHONE_ALREADY_USED, gộp 2 index thành 1 index 3 cột, và sửa nhánh dedup trong claimVoucher.
3a

Giới hạn tần suất — rate-limit theo cửa sổ ngắn CHI TIẾT ★

6 scope · biến RL_* · cơ chế ĐÚNG, giá trị mặc định LỆCH (code chặt hơn spec)
PARTIAL
Cách test: đối chiếu bảng §3a trên trang policy với RL_DEFAULTS trong code (rate-limit.ts:9–16), kiểm tra (1) đủ 6 scope, (2) khoá (IP / SBD / SĐT) đúng route, (3) giá trị mặc định, (4) đường override qua env RL_*, (5) hành vi khi vượt.

Đối chiếu 6 scope — giá trị & khoá

ScopeRoute áp dụngKhoá (code)Khoá khớp policy?Default policyDefault codeGiá trị khớp?
score-ip/api/exam-voucher/scoreIP (score:19)✓ IP120 / 60s20 / 60sLỆCH 6×
score-cand/scoreSBD (score:53)✓ SBD15 / 600s8 / 600sLỆCH ~2×
claim-ip/api/exam-voucher/claimIP (claim:19)✓ IP60 / 60s10 / 60sLỆCH 6×
claim-phone/claimSĐT (claim:61)✓ SĐT5 / 600s3 / 600sLỆCH
resend-ip/api/exam-voucher/resend-or-lookupIP (resend:19)✓ IP60 / 60s10 / 60sLỆCH 6×
resend-cand/resend-or-lookupSBD (resend:53)✓ SBD10 / 600s5 / 600sLỆCH 2×
Phần ĐẠT: Đủ cả 6 scope, khoá hoàn toàn khớp policy (IP cho score/claim/resend-ip; SBD cho score-cand/resend-cand; SĐT cho claim-phone). Counter fixed-window theo KV (rate-limit.ts:61–75) với TTL = 2× cửa sổ — hợp lệ.
Phần LỆCH (vì sao PARTIAL): Mọi giá trị mặc định trong code đều chặt hơn policy (score/claim/resend-ip chặt 6×). Vì cả 2 wrangler config đều KHÔNG set RL_* (chỉ comment mẫu — wrangler.toml:22), bản deploy chạy đúng default code → giới hạn thực tế chặt hơn spec nhiều. Trong policy ghi rõ “nới cao cho IP dùng chung 4G/NAT” — nhưng code lại để IP rất thấp (10–20), dễ chặn nhầm nhiều người chung 1 IP công cộng.

Override & hành vi khi vượt

CaseKịch bảnKỳ vọngThực tếKQ
TC-3a-OV Set RL_SCORE_IP="120/60" ở env để khớp policy. Áp 120/60, ghi đè default. Hỗ trợ: getLimit() đọc RL_<SCOPE>, validate regex \d+/\d+, l>0 & w>0 (rate-limit.ts:18–26). ⇒ Có thể đưa hệ thống về đúng spec mà không cần build lại. PASS
TC-3a-429 Vượt giới hạn score/claim → trạng thái trả về. HTTP 429 + RATE_LIMITED. score-ip/cand & claim-ip/phone trả đúng {status:"RATE_LIMITED"} 429 (score:20–24,54–57; claim:20–24,62–65). PASS
TC-3a-RS Vượt giới hạn ở route resend-or-lookup. Theo §7 nên trả RATE_LIMITED. Trả {status:"ERROR"} (resend-ip 429 nhưng status=ERROR; resend-cand cũng ERROR) — không phải RATE_LIMITED (resend:20–24,54–57). UI khó map về thông báo chuẩn §7. PARTIAL
TC-3a-FO KV lỗi hoặc chạy local (không có binding). Policy không nêu; cân nhắc bảo mật. Fail-open: không KV ⇒ luôn cho qua (rate-limit.ts:63,72). Tiện cho dev nhưng nếu KV sự cố trên prod thì rate-limit tê liệt im lặng. Khuyến nghị log cảnh báo. PARTIAL
3b

Trần số voucher cấp mới / IP / giờ — RL_ISSUE_IP (mặc định 40/3600)

Đạt trần → ISSUE_LIMIT “Mạng này đã nhận nhiều voucher… đổi 4G ↔ wifi”
CHƯA LÀM
CaseKỳ vọng (policy)Thực tế (code)KQ
TC-3b-1 Đếm voucher cấp mới theo IP mỗi giờ, trần 40 (env RL_ISSUE_IP). Không có bộ đếm issue/IP/giờ trong claimVoucher. RL_ISSUE_IP không được đọc ở đâu (grep toàn repo = 0). GAP
TC-3b-2 Đạt trần → trả ISSUE_LIMIT (không tính lại cặp cũ). ISSUE_LIMIT không có trong ClaimStatus (types/exam-voucher.ts:92–99). GAP
Vì sao GAP: Toàn bộ §3b chưa được hiện thực. Hiện chỉ có claim-ip (10/60s) chặn tần suất ngắn, không có trần tích luỹ 40/giờ. Kẻ phá dùng 1 IP về lý thuyết vẫn có thể tạo nhiều hơn 40 voucher/giờ (miễn không vượt 10 lần/60s liên tục).
4

Chặn tay (quản trị) — blocklist IP & dải CIDR CHI TIẾT ★

Bảng blocked_ips · Worker chặn mọi lượt · BLOCKED 403 · cache 60s · CIDR chống VPN/DC
CHẠY THẬT ✓ DEPLOY
✓ KIỂM CHỨNG TRỰC TIẾP TRÊN DEPLOY (2026-06-26): người dùng đã chặn IP qua menu “IP & Chặn” trên hệ thống; IP bị chặn truy cập thấy đúng câu “Không thể xử lý yêu cầu từ kết nối này. Vui lòng liên hệ hotline nếu cần hỗ trợ.”§4 ĐÃ HOẠT ĐỘNG đúng spec (có store chặn IP + worker enforce + đúng câu BLOCKED trung tính). Đây là chức năng trong app, không phải chặn Cloudflare (Cloudflare sẽ hiện trang lỗi 1020 của nó, không phải câu tiếng Việt này).
Vì sao các dòng dưới vẫn ghi “GAP (repo)”: bảng dưới là kết quả grep trong 2 repo GitHub — và repo 0 match mọi từ khoá (blocked_ips, BLOCKED, isCidr…). Tức code §4 đang chạy KHÔNG có trong repo (repo lệch deploy — xem cảnh báo đầu trang). Cột “KQ (deploy)” mới là sự thật vận hành; cột “KQ (repo)” chỉ cho thấy repo này thiếu code.
Cách test: tìm bảng blocked_ips trong schema, logic kiểm tra IP/CIDR trong middleware.ts & các route, trạng thái BLOCKED/403, hàm so khớp CIDR, và lớp cache 60s. Kết quả grep toàn src của repo: 0 match — nhưng deploy có (xem trên).
CaseYêu cầu policyKỳ vọngThực tế (code)KQ
TC-4-1 Bảng blocked_ips (value, isCidr, reason, active). Tồn tại + admin quản lý được. Deploy ✓: có menu “IP & Chặn” thêm/chặn IP được ⟹ store tồn tại. Repo ✗: không có bảng blocked_ips trong db/schema.ts. PASS ✓DEPLOY
TC-4-2 Worker kiểm tra IP mọi lượt score/claim/resend. Có guard chặn theo IP. Deploy ✓: IP bị chặn không truy cập được (đã kiểm chứng). Repo ✗: middleware.ts repo chỉ có Basic-Auth gate + noindex, không kiểm IP. PASS ✓DEPLOY
TC-4-3 IP nằm trong list → chặn. HTTP 403 BLOCKED. Deploy ✓: IP bị chặn nhận đúng phản hồi chặn (câu BLOCKED). Repo ✗: không có nhánh 403/BLOCKED trong repo. PASS ✓DEPLOY
TC-4-4 Dải CIDR 1.2.3.0/24 (chặn VPN/trung tâm dữ liệu). So khớp IP ∈ dải CIDR. CHƯA kiểm chứng: người dùng mới test chặn 1 IP đơn, chưa thử dải CIDR. Repo không có hàm CIDR — cần test trực tiếp 1 dải trên deploy để xác nhận. CHƯA RÕ
TC-4-5 Thông báo trung tính (không lộ bị chặn tay). “Không thể xử lý yêu cầu từ kết nối này… hotline”. Deploy ✓: hiện đúng từng chữ “Không thể xử lý yêu cầu từ kết nối này. Vui lòng liên hệ hotline nếu cần hỗ trợ.” (người dùng xác nhận). Repo ✗: chuỗi này chỉ có trong file spec/test, không có trong code. PASS ✓DEPLOY
TC-4-6 Cache 60s trong worker; thêm/xoá clear cache áp ngay. Có lớp cache blocklist + cơ chế xoá. CHƯA kiểm chứng: chưa đo độ trễ cache 60s khi thêm/xoá IP. Cần test trên deploy (chặn IP → đo bao lâu có hiệu lực). CHƯA RÕ
Kết luận §4: Trên hệ thống deploy, blocklist IP ĐÃ chạy đúng spec (chặn IP đơn → BLOCKED + đúng câu thông báo) — xác nhận trực tiếp. Còn chặn dải CIDRcache 60s chưa được kiểm chứng (test thêm để chốt). Toàn bộ code này không có trong 2 repo GitHub hiện tại nên không thể xác minh ở mức code — nếu cần audit dòng-lệnh, phải lấy đúng source mà bản deploy được build.
5

Lưu địa chỉ IP để soi và chặn

source_ip_plain (admin soi) · source_ip_hash (rate-limit) · cờ ADMIN_SHOW_PII · hạn lưu
PARTIAL
CaseKỳ vọng (policy)Thực tế (code)KQ
TC-5-1 Giữ source_ip_hash cho rate-limit & trần voucher. Có cột sourceIpHash (schema.ts:163), được tính & ghi khi claim (request-meta.ts:14; service:522). PASS
TC-5-2 Lưu source_ip_plain (IP thật) trong voucher_claims để admin soi. Không có cột source_ip_plain. Chỉ lưu hash → admin không soi được IP thật/vị trí. GAP
TC-5-3 Cờ ADMIN_SHOW_PII (mặc định che) để xem IP/SĐT/CCCD thật. ADMIN_SHOW_PII không xuất hiện ở đâu (grep = 0). Admin luôn xem dữ liệu masked. GAP
TC-5-4 Có thời hạn lưu (tự xoá sau N ngày). Có cột retentionUntil (schema.ts:167) nhưng không được set khi insert và không có job xoá → chưa thực thi. PARTIAL
Vì sao PARTIAL: Phần “bản băm để chặn” đã làm tốt. Phần “lưu IP thật để admin soi + cờ ADMIN_SHOW_PII + tự xoá theo hạn” chưa có. Hệ quả: gắn liền với §4 — không có IP thật thì admin cũng khó lập blocklist từ dữ liệu claim.
6

Cửa hàng (offline) — chốt chặn thật

Đối chiếu CCCD ↔ SBD & số gọi đến ↔ SĐT trên voucher
THỦ CÔNG
CaseKỳ vọng (policy)Thực tế (code)KQ
TC-6-1 NV đối chiếu chính chủ SBD (CCCD) — quy trình con người. Không có code tự động (đúng bản chất offline). Voucher có candidateNumberMasked để đối chiếu. N/A
TC-6-2 Số gọi đến phải trùng SĐT trên voucher. Voucher chỉ lưu phoneMasked (vd 09xx***xxx) — đối chiếu được phần đuôi; không lưu SĐT thật nên không match tuyệt đối tại quầy nếu cần. PARTIAL
Ghi chú: §6 là quy trình con người nên phần lớn N/A với kiểm thử code. Dữ liệu masked trên voucher hỗ trợ đối chiếu cơ bản; nếu nghiệp vụ cần match SĐT tuyệt đối, cân nhắc gắn với §5 (lưu/hiển thị có kiểm soát).
7

Bảng trạng thái trả về (hiển thị trên giao diện) CHI TIẾT ★

5 trạng thái policy: ISSUED/ALREADY_CLAIMED · ISSUE_LIMIT · BLOCKED · RATE_LIMITED · NOT_ELIGIBLE
3 / 5 (+deploy)
Cách test: đối chiếu từng dòng bảng §7 với ClaimStatus (types/exam-voucher.ts:92–99) và nơi phát sinh trong service/route, kèm thông báo người dùng.
Trạng thái policyKhi nào (policy)Trong code?Bằng chứng / Ghi chúKQ
ISSUED / ALREADY_CLAIMED Cấp mới / trùng cặp (trả lại mã) → hiện voucher. Có cả hai. ISSUED (service:561), ALREADY_CLAIMED trả voucherCode cũ (service:457). Lưu ý: trigger ALREADY_CLAIMED theo SBD (không phải CẶP) — gắn với xung đột §2. PASS
ISSUE_LIMIT IP vượt trần mỗi giờ → “Mạng này đã nhận nhiều voucher… đổi 4G ↔ wifi”. Không có. Không có trong ClaimStatus; §3b chưa làm nên không bao giờ phát sinh. GAP
BLOCKED IP bị chặn tay → “Không thể xử lý yêu cầu từ kết nối này… hotline” (403). Deploy: CÓ ✓ (repo: không). Deploy ✓: chặn IP qua “IP & Chặn” → hiện đúng câu thông báo (xác nhận trực tiếp). Repo ✗: không có trong ClaimStatus repo — code §4 không nằm trong repo (xem cảnh báo đầu trang). PASS ✓DEPLOY
RATE_LIMITED Thao tác quá nhanh → “Bạn thao tác quá nhanh… thử lại sau”. Có (score/claim), thiếu ở resend. score & claim trả đúng RATE_LIMITED 429 + thông báo khớp (score:21; claim:21). Nhưng route resend-or-lookup trả status:"ERROR" thay vì RATE_LIMITED (resend:21,55) → lệch chuẩn §7. PARTIAL
NOT_ELIGIBLE Điểm chưa đủ bậc → nêu điểm và mức tối thiểu. Không có. pickTier luôn trả 1 bậc: bậc thấp nhất T1 minScore=0 nên mọi điểm ≥0 đều có voucher; fallback ?? tiers[0] (service:88–90; scoring.ts:8,47). Không bao giờ trả NOT_ELIGIBLE. GAP

Đo thực tế RATE_LIMITED — bao nhiêu lần thì bị & phục hồi bao lâu CHẠY 2026-06-26

Phương pháp: tái lập nguyên văn thuật toán fixed-window của worker (rate-limit.ts:61–75) trên KV in-memory, chạy node với ngưỡng = default code (wrangler không set RL_* nên đây đúng là bản đang deploy). Kết quả đo trực tiếp bên dưới.

① Nhập bao nhiêu lần thì bị chặn?

Scope (hành động)RouteCho quaBị chặn ở lầnStatus trả vềThông báo người dùng
claim-ip (nhận voucher / IP)/claim10 lầnlần thứ 11RATE_LIMITED“Bạn thao tác quá nhanh. Thử lại sau ít phút.”
score-ip (tra điểm / IP)/score20 lầnlần thứ 21RATE_LIMITED“Bạn thao tác quá nhanh. Thử lại sau ít phút.”
claim-phone (nhận voucher / SĐT)/claim3 lầnlần thứ 4RATE_LIMITED“Quá nhiều lượt nhận voucher từ số này.”
score-cand (tra điểm / SBD)/score8 lầnlần thứ 9RATE_LIMITED“Quá nhiều lượt tra cho số báo danh này.”
resend-ip (tra lại mã / IP)/resend-or-lookup10 lầnlần thứ 11ERROR ⚠️“Bạn thao tác quá nhanh. Thử lại sau ít phút.”
resend-cand (tra lại mã / SBD)/resend-or-lookup5 lầnlần thứ 6ERROR ⚠️“Quá nhiều lượt tra. Thử lại sau.”
Đọc kết quả: đúng câu “Bạn thao tác quá nhanh… thử lại sau” (RATE_LIMITED) xuất hiện rõ nhất ở claim-ip (lần 11)score-ip (lần 21) — tức bấm nhanh quá 10 lần nhận voucher hoặc 20 lần tra điểm trong 60 giây từ cùng 1 máy/IP là dính. Hai scope theo SBD/SĐT (score-cand lần 9, claim-phone lần 4) cũng trả RATE_LIMITED nhưng thông báo khác. ⚠️ Hai scope resend trả status:"ERROR" thay vì RATE_LIMITED (lệch §7) — đã ghi ở §3a.

② Mất bao lâu để phục hồi? (fixed-window — KHÔNG phải sliding)

ScopeĐộ dài cửa sổPhục hồi (xấu nhất)Phục hồi (tốt nhất)
claim-ip / score-ip / resend-ip60 giây60 giây (1 phút)~vài giây
claim-phone / score-cand / resend-cand600 giây600 giây (10 phút)~vài giây
Cơ chế phục hồi (đã đo & xác minh): bộ đếm theo cửa sổ cố định win = floor(now/windowSec), phục hồi khi đồng hồ bước sang cửa sổ kế (mốc bội số của windowSec) — không tính “đủ N giây kể từ request cuối”. Đo thực tế với claim-ip 10/60s:
  • Chạm trần ở giây 1 của cửa sổ → tới giây 30 vẫn bị chặn, phải tới giây 60 (sang cửa sổ mới) mới qua ⇒ chờ gần trọn 60s.
  • Chạm trần ở giây 58 → chỉ tới giây 60 đã qua ⇒ chỉ chờ ~2s.
⇒ Thời gian chờ biến thiên 0 → trọn cửa sổ tuỳ bạn chạm trần sớm hay muộn. Không có phạt cộng dồn: sang cửa sổ mới là reset sạch về 0. Bộ đếm tách riêng theo IP/SBD/SĐT (đã verify: IP-X đầy thì IP-Y vẫn nhận bình thường).

Trạng thái code phát sinh thêm (ngoài bảng §7)

Status codeÝ nghĩaĐối chiếu policy
PHONE_ALREADY_USEDSĐT đã dùng cho SBD khác.NGƯỢC §2 — policy không chặn SĐT lẻ; trạng thái này không nên tồn tại nếu theo logic CẶP.
TICKET_INVALIDTicket sai chữ ký/hết hạn.Hợp lý — bảo vệ kỹ thuật, policy không liệt kê nhưng cần thiết.
ISSUE_FAILEDCDP lỗi khi phát mã (retryable).Hợp lý — bổ trợ vận hành.
NOT_OPENNgoài khung thời gian campaign.Hợp lý — gate theo ngày, policy không nêu nhưng cần.
Tổng kết §7 (đã hiệu chỉnh theo deploy): ISSUED/ALREADY_CLAIMED đạt; RATE_LIMITED đạt (đo thực tế, riêng resend trả ERROR — cần chuẩn hoá); BLOCKED đạt trên deploy ✓ (đã kiểm chứng câu thông báo, dù repo không có code). Còn lại ISSUE_LIMITNOT_ELIGIBLE: chưa thấy trong repo và chưa kiểm chứng trên deploy — cần test trực tiếp để chốt (rất có thể deploy đã có, giống §4). PHONE_ALREADY_USED: repo có (đi ngược §2) nhưng deploy quan sát lại cho 1 SĐT nhiều SBD — xem §2.
8

Biến môi trường cấu hình

6 biến RL_* rate-limit · RL_ISSUE_IP · ADMIN_SHOW_PII
6 / 8 ĐẠT
Biến envMục đích (policy)Code đọc?KQ
RL_SCORE_IPGiới hạn score theo IP.Có — getLimit("score-ip") (rate-limit.ts:18–26).PASS
RL_SCORE_CANDGiới hạn score theo SBD.Có.PASS
RL_CLAIM_IPGiới hạn claim theo IP.Có.PASS
RL_CLAIM_PHONEGiới hạn claim theo SĐT.Có.PASS
RL_RESEND_IPGiới hạn tra lại mã theo IP.Có.PASS
RL_RESEND_CANDGiới hạn tra lại mã theo SBD.Có.PASS
RL_ISSUE_IPTrần voucher cấp mới/IP/giờ (40/3600).Không đọc ở đâu (grep = 0). Gắn §3b.GAP
ADMIN_SHOW_PIICho admin xem IP/SĐT/CCCD thật.Không đọc ở đâu (grep = 0). Gắn §5.GAP
Vì sao PARTIAL: 6 biến RL_* rate-limit đã được hỗ trợ đầy đủ (format "limit/giây" validate đúng). Thiếu 2 biến RL_ISSUE_IPADMIN_SHOW_PII vì các tính năng tương ứng (§3b, §5) chưa làm.

Tổng hợp & kiến nghị ưu tiên

Theo mức độ ảnh hưởng tới chống lạm dụng và trải nghiệm thí sinh thật
ACTION
Ưu tiênHạng mụcVấn đềHành động đề xuất
P0§2 Định danh CẶP vs CÁ NHÂNCode chặn nhầm người hợp lệ (TC-2-1/2-2) so với policy mới.Owner chốt logic. Nếu theo policy: bỏ PHONE_ALREADY_USED, gộp index 3 cột, sửa dedup theo CẶP.
P0§4 Blocklist IP/CIDRKhông có công cụ chặn tay khi bị tấn công chủ đích.Thêm bảng blocked_ips, guard middleware (cache 60s), so khớp CIDR, BLOCKED 403, UI admin.
P1§3b Trần voucher/IP/giờ1 IP có thể vượt 40 voucher/giờ.Thêm bộ đếm issue/IP/giờ đọc RL_ISSUE_IP; trả ISSUE_LIMIT.
P1§3a Giá trị rate-limitDefault code chặt hơn spec 2–6×, dễ chặn nhầm IP 4G/NAT.Set RL_* ở wrangler về đúng spec (120/60…) hoặc sửa RL_DEFAULTS.
P2§7 NOT_ELIGIBLEMọi điểm đều có voucher (không có sàn).Nếu nghiệp vụ cần sàn điểm: thêm ngưỡng & trả NOT_ELIGIBLE; nếu không, cập nhật policy bỏ dòng này.
P2§5 Lưu IP thật + ADMIN_SHOW_PII + retentionAdmin không soi được IP; retention chưa enforce.Thêm source_ip_plain, cờ ADMIN_SHOW_PII, set retentionUntil + job dọn.
P3§3a/§7 resend trả ERRORresend vượt giới hạn trả ERROR thay vì RATE_LIMITED.Chuẩn hoá resend route trả RATE_LIMITED.