Bài 16 · Nâng cao · 24 phút
Kho giftcard & giao mã an toàn
Biên soạn bởi Nguyễn Anh Tuấn
Bán hàng số (giftcard Roblox/Steam/Amazon) đúng cách: admin/manager nhập kho theo lô và đánh SKU; khi khách thanh toán xong thì cấp phát đúng một mã từ kho một cách atomic (Postgres FOR UPDATE SKIP LOCKED) để không bán trùng hay oversell. Giao mã qua hàng đợi (BullMQ): email chỉ gửi LINK, không gửi mã; khách đăng nhập đúng tài khoản mới xem được mã, và lần xem đầu được ghi viewed_at + log (ai/khi nào/IP) làm bằng chứng giao hàng để kháng khiếu nại, tránh lộ mã và mất hàng.
Cho web bán hàng của mèo con bán thêm một loại hàng đặc thù: giftcard số - Roblox, Steam, Amazon. Khác hàng thường ở một điểm cốt lõi: nhiều khách cùng mua "Steam 100k", nhưng mỗi khách phải nhận một mã riêng lấy từ kho. Bán trùng một mã cho hai khách (oversell), để lộ mã, hay mất mã - đều là mất tiền và mất uy tín.
Bài này dựng trọn luồng hàng số an toàn: nhập kho theo lô, cấp phát mã không trùng khi thanh toán xong, và giao mã qua link xem có kiểm soát để có bằng chứng giao hàng. Nó ghép lại nhiều thứ đã học - dữ liệu (PostgreSQL), webhook (SePay), khoá & hàng đợi (BullMQ), đăng nhập (Better-auth).
Kho hàng số quản tới từng mã. Nhập theo lô (batch) - mỗi lô là một mớ mã cùng SKU (chủng loại, vd STEAM-100K). Mỗi mã có trạng thái: còn trong kho hay đã bán.
schema kho giftcard (Drizzle) - mã lưu MÃ HOÁ, không để plaintext
giftcard_batch(id, sku, loai, menh_gia, nha_cung_cap,
so_luong_nhap, ngay_nhap, nguoi_nhap)
giftcard_code(id, batch_id, sku,
code_enc, -- mã đã mã hoá (không plaintext)
trang_thai, -- 'in_stock' | 'sold'
order_id UNIQUE, -- một mã chỉ thuộc MỘT đơn
viewed_at) -- lần khách xem mã đầu tiên - ▸Lô (batch): một lần nhập kho, gắn một SKU; SKU = mã định danh chủng loại.
- ▸Mỗi mã là một dòng riêng, có trạng thái in_stock/sold - quản tới từng mã.
- ▸order_id đặt UNIQUE: một mã không bao giờ thuộc hai đơn (chống bán trùng ở tầng database).
- ▸Mã giftcard là bí mật - lưu mã hoá, không để plaintext; không bao giờ ghi mã vào log.
gõ trong ô Claude Code
Tạo hai bảng Drizzle cho kho giftcard số, đặt trong file src/db/schema/giftcard.ts:
1) giftcard_batch (lô nhập kho): id, sku (text), loai (text), menh_gia (integer),
nha_cung_cap (text), so_luong_nhap (integer), ngay_nhap (timestamp default now),
nguoi_nhap (text, id user nhập).
2) giftcard_code (từng mã): id, batch_id (FK tới giftcard_batch), sku (text),
code_enc (text, mã giftcard đã MÃ HOÁ - không lưu plaintext),
trang_thai (text, chỉ 'in_stock' hoặc 'sold', mặc định 'in_stock'),
order_id (FK tới đơn hàng, đặt ràng buộc UNIQUE để một mã chỉ thuộc một đơn),
viewed_at (timestamp, nullable - lần khách xem mã đầu tiên).
Thêm index trên (sku, trang_thai) để cấp phát mã nhanh.
Sau đó chạy drizzle-kit generate rồi migrate.
Xong khi: hai bảng tồn tại trong database, cột order_id của giftcard_code có
ràng buộc UNIQUE, và code_enc lưu ở dạng mã hoá chứ không phải plaintext. Tới lượt mèo con
Nhập kho là việc của admin hoặc manager (theo phân quyền Casbin ở bài Trang quản trị). Trong admin, dán/đính kèm danh sách mã của một SKU, hệ thống tạo một lô và nạp từng mã vào kho (mã hoá khi lưu).
- ▸Một lần nhập = một lô: chọn SKU/loại/mệnh giá, dán danh sách mã, hệ thống đếm số lượng.
- ▸Chỉ admin/manager được nhập kho; mọi lần nhập ghi nguoi_nhap + thời gian (audit).
- ▸Bỏ qua mã trùng (đã có trong kho) để không nhập đôi; báo lại số nhập thành công.
gõ trong ô Claude Code
Làm trang admin "Nhập kho giftcard" tại route /admin/giftcard/nhap-kho.
- Form có: chọn SKU + loai + menh_gia + nha_cung_cap, và một textarea để dán
danh sách mã (mỗi dòng một mã).
- Chỉ cho admin hoặc manager vào trang này (dùng phân quyền Casbin đã có); user
thường mở vào bị chặn.
- Server action xử lý: tạo một dòng giftcard_batch (ghi nguoi_nhap = id user đang
đăng nhập và ngay_nhap), rồi với mỗi dòng mã: mã hoá rồi insert vào giftcard_code
với trang_thai = 'in_stock', batch_id và sku tương ứng.
- BỎ QUA mã đã có trong kho (trùng) để không nhập đôi; cuối cùng báo số mã nhập
thành công và số bị bỏ qua.
- so_luong_nhap của batch = số mã nạp thành công.
Xong khi: đăng nhập bằng admin hoặc manager nhập được một lô, tồn kho theo SKU
tăng đúng bằng số mã vừa nạp, và mỗi lần nhập đều ghi lại nguoi_nhap + thời gian. Tới lượt mèo con
Khi webhook SePay xác nhận đã thanh toán, hệ thống cấp phát đúng một mã còn trong kho cho đơn. Đây là chỗ dễ bán trùng nếu làm ẩu (hai đơn cùng giành một mã) - đúng họ với race condition ở bài khoá số dư. Cấp phát phải ATOMIC:
cấp phát atomic (Postgres) - mỗi request giành một mã khác nhau, không trùng
UPDATE giftcard_code SET trang_thai = 'sold', order_id = :don
WHERE id = (
SELECT id FROM giftcard_code
WHERE sku = :sku AND trang_thai = 'in_stock'
ORDER BY id
FOR UPDATE SKIP LOCKED -- khoá dòng, request khác BỎ QUA lấy mã khác
LIMIT 1
)
RETURNING id, code_enc; -- rỗng = hết hàng → KHÔNG trừ tiền / hoàn tiền - ▸FOR UPDATE SKIP LOCKED: mỗi request khoá và giành một mã riêng, không trùng và không phải xếp hàng chờ nhau.
- ▸order_id UNIQUE là lưới an toàn cuối: dù logic lỗi, database vẫn chặn một mã rơi vào hai đơn.
- ▸Cấp phát idempotent theo đơn: webhook gửi lại không cấp thêm mã thứ hai cho cùng một đơn.
- ▸Hết mã (RETURNING rỗng): không trừ tiền rồi để khách chờ - chặn mua từ trước hoặc hoàn tiền.
gõ trong ô Claude Code
Viết hàm capPhatMa(orderId, sku) cấp phát một mã giftcard cho đơn sau khi webhook
SePay xác nhận đã thanh toán. Đặt trong service xử lý đơn.
- Cấp phát ATOMIC bằng một câu UPDATE Postgres: UPDATE giftcard_code SET
trang_thai='sold', order_id = orderId WHERE id = (SELECT id FROM giftcard_code
WHERE sku = sku AND trang_thai='in_stock' ORDER BY id FOR UPDATE SKIP LOCKED
LIMIT 1) RETURNING id, code_enc.
- Idempotent theo đơn: trước khi cấp, kiểm tra đơn này đã có mã chưa (đã có
giftcard_code.order_id = orderId thì trả về mã cũ, KHÔNG cấp mã thứ hai). Webhook
gửi lại nhiều lần vẫn chỉ một mã cho một đơn.
- Nếu RETURNING rỗng (hết mã trong kho): trả về trạng thái "hết hàng", KHÔNG trừ
tiền / kích hoạt hoàn tiền, không để khách chờ mã.
Sau đó viết một test mô phỏng 20 đơn cùng mua một SKU đang còn 5 mã, chạy đồng
thời.
Xong khi: không có hai đơn nào nhận trùng một mã (đúng 5 đơn được mã, 15 đơn báo
hết hàng), webhook gửi lại không sinh mã thứ hai cho cùng đơn, và lúc hết kho thì
báo hết hàng chứ không trừ tiền. Tới lượt mèo con
Có mã rồi, đừng nhồi mã vào email. Đẩy một job vào hàng đợi (BullMQ) gửi email chứa một LINK tới trang xem mã trên shop - không phải mã. Email dễ bị chuyển tiếp hay lọt hộp thư khác; link tới trang có đăng nhập thì kiểm soát được ai xem.
email chỉ chứa link - mã KHÔNG nằm trong email
Đơn #1042 đã sẵn sàng. Xem mã giftcard của bạn tại:
https://meoshop.vn/don-hang/1042/ma
(Cần đăng nhập đúng tài khoản đã mua để xem mã.) gõ trong ô Claude Code
Sau khi capPhatMa cấp mã xong, đẩy một job vào hàng đợi BullMQ (queue tên
"giftcard-email") để gửi email cho khách. Worker xử lý job sẽ gửi email với nội
dung: báo đơn đã sẵn sàng và một LINK tới trang xem mã, dạng
https://meoshop.vn/don-hang/{orderId}/ma kèm câu nhắc cần đăng nhập đúng tài khoản
đã mua.
QUAN TRỌNG: payload của job và nội dung email TUYỆT ĐỐI không chứa mã giftcard
(không truyền code_enc, không truyền mã giải ra) - chỉ truyền orderId và email
khách. Mã chỉ xem được ở trang /don-hang/:id/ma.
Xong khi: email khách nhận được chỉ có đường dẫn tới đơn, và mở mã nguồn email
(hoặc payload job trong Redis) không thấy mã giftcard ở bất cứ đâu. Tới lượt mèo con
Trang xem mã là chốt chặn. Nó yêu cầu đăng nhập, và chỉ đúng người mua (đơn thuộc tài khoản đó) mới thấy mã. Lần mở đầu tiên, hệ thống ghi viewed_at và một dòng log (ai, khi nào, IP) - đó là bằng chứng giao hàng.
trang xem mã - mã giả (kiểm đúng user + ghi bằng chứng)
# GET /don-hang/:id/ma (bắt buộc đăng nhập)
don = tim_don(id)
if don.user_id != phien.user_id:
return 403 # không phải chủ đơn → chặn
ma = giai_ma(don.giftcard.code_enc)
if don.giftcard.viewed_at is None:
don.giftcard.viewed_at = now()
ghi_log("giftcard_viewed", phien.user, don, ip) # bằng chứng
return ma - ▸Bắt buộc đăng nhập + kiểm đơn thuộc đúng user (authorization), không chỉ ẩn link.
- ▸Lần xem đầu ghi viewed_at → biết khách ĐÃ lấy mã hay chưa.
- ▸Ghi log sự kiện xem (ai/khi nào/IP) làm bằng chứng kháng khiếu nại "chưa nhận mã".
- ▸Mã chỉ ra ở response của trang này; không vào email, không vào log.
gõ trong ô Claude Code
Dựng route /don-hang/[id]/ma hiển thị mã giftcard cho khách:
- Bắt buộc đăng nhập (dùng Better-auth đã có); chưa đăng nhập thì chuyển sang
trang đăng nhập.
- Lấy đơn theo id, kiểm tra don.user_id phải khớp user của phiên đăng nhập. Nếu
KHÔNG phải chủ đơn thì trả 403 (không chỉ ẩn link - chặn ở server).
- Giải mã code_enc của giftcard thuộc đơn rồi hiển thị mã.
- Nếu giftcard.viewed_at đang null thì lần này set viewed_at = now() và ghi một
dòng audit log sự kiện "giftcard_viewed" gồm: user id, order id, thời điểm, IP.
Các lần xem sau không ghi đè viewed_at.
- Mã chỉ xuất hiện ở response của trang này, không ghi vào log.
Xong khi: tài khoản KHÁC mở link bị chặn 403; chủ đơn xem được mã; sau lần xem
đầu, trạng thái đơn cho thấy "đã lấy mã" kèm thời điểm (viewed_at), và có dòng log
giftcard_viewed với IP làm bằng chứng. Tới lượt mèo con
Gom lại, mèo con có một luồng hàng số không mất hàng và có bằng chứng: mỗi mã chỉ bán một lần, giao qua link kiểm soát, và mọi lần xem đều có dấu vết. Khi khách khiếu nại "chưa nhận mã", mèo con có viewed_at + log thời điểm/IP để đối chứng.
bất biến để test - chống bán trùng & oversell
mỗi giftcard_code.order_id là DUY NHẤT # không 2 đơn chung 1 mã
ton_kho(sku) == nhap(sku) − sold(sku) # tồn khớp, không âm
không cấp phát khi tồn = 0 (không oversell)
mã giftcard KHÔNG xuất hiện trong email hay log (chỉ ở trang xem mã) gõ trong ô Claude Code
Dùng một subagent chuyên bảo mật / data-integrity rà soát phần cấp phát mã
(capPhatMa) và route /don-hang/[id]/ma, rồi viết test cho các bất biến sau:
1) Mỗi giftcard_code.order_id là duy nhất - không có hai đơn cùng một mã (test
cấp phát đồng thời nhiều đơn cùng SKU).
2) Với mỗi SKU: ton_kho(sku) == tổng nhập(sku) − số mã sold(sku), và tồn không
bao giờ âm.
3) Không cấp phát khi tồn = 0 (không oversell): đơn tới khi hết kho bị từ chối,
không trừ tiền.
4) Mã giftcard KHÔNG xuất hiện trong nội dung email, payload job BullMQ, hay
audit log - chỉ ra ở response trang xem mã.
Xong khi: chạy bộ test thấy mô phỏng mua đồng thời không sinh mã trùng, tồn kho
luôn khớp công thức nhập − sold và không âm, và không có chỗ nào lộ mã ra ngoài
trang xem mã. Tới lượt mèo con
Bước tiếp theo
Câu hỏi thường gặp
Hàng vật lý: nhiều khách mua "cùng một sản phẩm" và bạn giao hàng giống nhau. Hàng số như giftcard: mỗi lần bán cùng một loại (vd Steam 100k) nhưng phải giao MỘT MÃ DUY NHẤT lấy từ kho - không được bán trùng một mã cho hai khách, không được để lộ hay mất mã. Vì thế kho phải quản tới từng mã, và lúc cấp phát phải an toàn dưới đồng thời.
SKU (Stock Keeping Unit) là mã định danh một CHỦNG LOẠI hàng trong kho, vd STEAM-100K, ROBLOX-200K, AMZ-10USD. Mỗi lô nhập kho gắn một SKU; mỗi SKU có nhiều mã giftcard riêng lẻ. Khi khách mua loại nào, hệ thống cấp một mã còn trong kho thuộc đúng SKU đó.
Vì email dễ bị chuyển tiếp, lưu lại, lọt vào hộp thư người khác - mã lộ là mất hàng. Gửi link tới trang xem mã trên shop thì: chỉ đúng người mua (đã đăng nhập) mới xem được, bạn biết khách đã lấy mã hay chưa (lần mở đầu), và có log thời điểm/IP làm bằng chứng khi khách khiếu nại "chưa nhận được mã".
Dùng một câu lệnh cấp phát ATOMIC ở database: chọn đúng một mã còn trong kho, khoá nó lại để request khác không lấy trúng (Postgres: FOR UPDATE SKIP LOCKED), rồi đổi trạng thái sang đã bán và gắn order. Đây chính là họ với bài toán khoá số dư ở bài Hàng đợi BullMQ & khoá số dư - "một mã chỉ bán cho một khách" cùng nguyên lý "không trừ lố".