Kiến trúc một store hàng số với thanh toán crypto
Kiến trúc mình dùng khi xây một store bán hàng số nhận crypto, cổng fiat, chuyển khoản P2P và ví nội bộ: tồn kho theo từng dòng, một đường hoàn tất đơn idempotent, HD wallet, khoá tỷ giá và cách xử lý trả thiếu, trả dư, sai mạng.
- Architecture
- Backend
- Crypto
Bán hàng số (gift card, mã nạp, tài khoản, key phần mềm) nghe có vẻ dễ hơn bán hàng vật lý vì không phải giao hàng. Thực tế ngược lại: không có bước giao hàng để che lỗi. Tiền vừa về là khách muốn nhận mã ngay. Mọi thứ một store bình thường có thể xử lý dần, như còn hàng không, tiền về thật chưa, món này thuộc về ai, đều phải đúng trong vài giây. Sai thì không phải giao chậm mà là giao cùng một mã cho hai người.
Bài này ghi lại kiến trúc mình dùng khi xây Jinx, một store hàng số nhận thanh toán bằng crypto, cổng thanh toán fiat, chuyển khoản P2P và ví nội bộ. Mình viết theo hướng tổng quát để bạn áp dụng cho hệ thống tương tự.
Bức tranh tổng thể#
┌──────────────┐ ┌───────────────┐
khách ───▶ │ Storefront │ │ Admin panel │ ◀─── vận hành
└──────┬───────┘ └───────┬───────┘
│ REST /v1 + WebSocket
▼ ▼
┌────────────────────────────────────┐
│ API (APP_ROLE=api) │ checkout, giỏ hàng, đơn
└───────┬───────────────────┬────────┘
│ │ enqueue
PostgreSQL Redis + queue
(source of truth) │
▲ ▼
┌───────┴────────────────────────────┐
│ Worker (APP_ROLE=worker) │ theo dõi chain, forward tiền,
└───────┬──────────┬─────────┬───────┘ gửi mail, đối soát, cron
▼ ▼ ▼
Blockchain Payment Mailbox
RPC / API gateway (P2P)Ba nguyên tắc định hình toàn bộ thiết kế:
- Một nguồn sự thật. Storefront và admin đều không có database riêng, cả hai gọi cùng một API. Giá, tồn kho, giảm giá luôn giống nhau dù khách hay admin đang xem.
- Tách API và worker. Cùng một image, khởi động theo biến môi trường APP_ROLE. API chỉ phục vụ HTTP/WebSocket; worker chạy queue và cron. Mạng blockchain nghẽn hay mail provider chậm không bao giờ làm nút "Thêm vào giỏ" chậm theo.
- Mọi kênh thanh toán đổ về một đường hoàn tất đơn. Dù khách trả bằng gì, khi thanh toán được xác nhận thì luồng phía sau giống hệt nhau.
Tồn kho: mỗi món hàng là một dòng#
Sai lầm phổ biến nhất là lưu tồn kho dạng số đếm (stock = 42). Với hàng số, mỗi món là một bí mật riêng: một mã, một tài khoản. Vì vậy mình lưu mỗi món thành một dòng tồn kho với trạng thái:
AVAILABLE ──reserve──▶ RESERVED ──payment confirmed──▶ SOLD
▲ │
└──── hết hạn giữ ─────┘- Hai checkout đồng thời có thể cùng trừ một con số, nhưng không thể cùng giữ một dòng đã RESERVED. Dùng UPDATE ... WHERE status = 'AVAILABLE' (hoặc SELECT ... FOR UPDATE SKIP LOCKED) để việc giao trùng không thể xảy ra về mặt cấu trúc, chứ không chỉ "hiếm khi xảy ra".
- Mỗi item trong đơn trỏ tới đúng dòng đã cấp. Vài tháng sau khách hỏi "tôi nhận mã nào" thì chỉ cần tra một bản ghi.
- Cái giá phải trả là những lượt giữ hàng bị bỏ dở. Một cron chạy vài phút một lần trả các dòng RESERVED quá hạn về AVAILABLE. Đây không phải việc dọn dẹp phụ: thiếu nó, mỗi giỏ hàng bị bỏ quên sẽ âm thầm "khoá" một món có thể bán.
Vòng đời đơn hàng#
PENDING ──(đúng một payment thoả mãn)──▶ PAID ──▶ cấp hàng ──▶ gửi mail ──▶ COMPLETED
│
└── hết hạn / huỷ ──▶ EXPIRED (trả dòng tồn kho)Một đơn ở PENDING cho tới khi đúng một payment object thoả mãn nó. Khi xác nhận, backend làm theo thứ tự: chuyển dòng tồn kho sang SOLD, giao nội dung, gửi biên nhận, hoàn tất đơn. Bước này phải idempotent: webhook gửi lại, worker chạy lại sau khi crash, hai nguồn xác nhận cùng tới, tất cả đều không được giao hàng hai lần. Cách đơn giản nhất là chuyển trạng thái có điều kiện (WHERE status = 'PENDING') trong cùng transaction với việc cấp hàng.
Thanh toán crypto tự quản lý#
Bạn có thể dùng processor bên thứ ba (Coinbase Commerce, NOWPayments, BTCPay Server…), và với nhiều store đó là lựa chọn hợp lý. Ở Jinx mình chọn tự quản lý để kiểm soát phí, số coin hỗ trợ và trải nghiệm. Các thành phần chính:
1. Mỗi payment một địa chỉ riêng (HD wallet). Địa chỉ được sinh từ một ví HD (BIP32/BIP44) theo chỉ số tăng dần. Vì mỗi địa chỉ chỉ thuộc về một payment, giao dịch đến tự cho biết nó thuộc đơn nào, khách không phải ghi chú mã đơn. Server sinh địa chỉ chỉ cần public key mở rộng (xpub). Khoá bí mật để chuyển tiền nên tách khỏi API, chỉ worker forward tiền được dùng, và giữ số dư ở ví nóng càng thấp càng tốt.
2. Khoá tỷ giá có thời hạn. Khi tạo payment, quy đổi giá USD sang số coin cần trả và giữ tỷ giá đó trong một khoảng (ví dụ 15–30 phút). Nguồn tỷ giá nên có một provider chính và một provider dự phòng: lỗi tra tỷ giá không được phép làm hỏng một giao dịch bán.
3. Chain monitor. Worker theo dõi các địa chỉ đang chờ, qua RPC node hoặc API của explorer, và đếm số xác nhận. Mỗi chain cần ngưỡng riêng, vì thời gian ra block và rủi ro reorg khác nhau: BTC chậm và cần nhiều xác nhận hơn, Solana hay các mạng L2 nhanh hơn.
4. Forward tiền. Sau khi đủ xác nhận, worker gom tiền từ địa chỉ của payment về ví tổng. Việc này chạy trong queue riêng, có retry, và tách khỏi việc hoàn tất đơn. Khách nhận hàng ngay khi thanh toán được xác nhận, không phải chờ tiền được gom.
5. Các trường hợp lệch. Đây là chỗ tốn công nhất:
| Tình huống | Cách xử lý |
|---|---|
| Trả thiếu | Giữ đơn PENDING đến hết hạn, cho phép trả bù vào cùng địa chỉ, hoặc cộng phần đã trả vào ví nội bộ |
| Trả dư | Hoàn tất đơn, cộng phần dư vào ví nội bộ |
| Trả sau khi hết hạn tỷ giá | Tính lại theo tỷ giá hiện tại, thiếu thì xử lý như trả thiếu |
| Gửi đúng coin nhưng sai mạng (USDT ERC-20 vs TRC-20) | Phòng từ UI: hiển thị rõ mạng, mỗi mạng một địa chỉ riêng; xử lý thủ công khi vẫn xảy ra |
| Trả hai lần | Payment thứ hai thành tiền trong ví nội bộ, không tạo đơn mới |
6. Đơn vị và độ chính xác. Không bao giờ dùng float cho tiền. Lưu theo đơn vị nhỏ nhất của từng tài sản (satoshi, wei, lamport) hoặc dùng kiểu decimal, và nhớ rằng mỗi token có số chữ số thập phân khác nhau (USDT, USDC thường là 6, ETH là 18).
Các kênh thanh toán khác#
Crypto không phải kênh duy nhất, và mỗi kênh có cách xác nhận riêng:
| Kênh | Xác nhận bằng |
|---|---|
| Crypto | Chain monitor đếm xác nhận trên địa chỉ riêng của payment |
| Cổng fiat (thẻ, Apple Pay, Google Pay, Cash App) | Webhook từ gateway, xác thực chữ ký; thông tin thẻ không bao giờ đi qua hệ thống |
| Telegram Stars | Webhook từ Bot API |
| Chuyển khoản P2P (không có API) | Cron đọc hộp thư, phân tích email "bạn vừa nhận tiền", khớp với payment theo mã ghi chú sinh sẵn |
| Ví nội bộ (store credit) | Trừ số dư ngay trong transaction |
Kênh P2P là ví dụ điển hình của việc làm việc với thứ không có API. Mình khớp theo mã ghi chú thay vì theo số tiền, vì hai khách có thể chuyển cùng số tiền nhưng không thể có cùng mã.
Ví nội bộ là kênh khiến các kênh còn lại dễ chịu hơn. Khách nạp một lần, các lần mua sau xác nhận tức thì. Thời gian chờ xác nhận blockchain chuyển từ "chi phí mỗi đơn" thành "chi phí một lần". Ví cũng là nơi tự nhiên để chứa tiền trả dư, tiền hoàn và thưởng giới thiệu.
Queue và cron#
Tất cả việc chậm và theo đợt đều chạy trong worker qua queue (Bull trên Redis):
- Chain polling và forward tiền: retry có backoff, không chặn checkout.
- Gửi email: biên nhận, mã hàng, thông báo restock.
- Đối soát P2P: đọc hộp thư định kỳ.
- Cron: trả hàng giữ quá hạn, hết hạn đơn, báo cáo định kỳ.
Mỗi job cần idempotent và có khoá theo payment hoặc order, để hai worker không xử lý cùng một payment.
Bảo mật và vận hành#
- Chống bot và gian lận: Cloudflare Turnstile ở đăng ký và checkout, giới hạn số lượng mỗi sản phẩm, rate limit theo IP và tài khoản.
- Tài khoản admin: bắt buộc 2FA (TOTP), phân quyền theo nhóm vai trò định nghĩa một lần và dùng chung giữa API và admin.
- Email không duy nhất toàn cục: người vận hành store cũng có thể là khách. Mình dùng unique index theo từng cổng (khách / staff), và mọi truy vấn người dùng đều phải lọc theo vai trò, không bao giờ tìm chỉ bằng email.
- Health check không lộ cấu hình: trả về "đã cấu hình hay chưa", không bao giờ in tên biến hay giá trị.
- Triển khai gọn: Docker Compose trên một VPS (Postgres, Redis, migrator chạy trước, API, worker, các frontend, nginx, certbot). Chỉ nginx mở port ra ngoài, backup Postgres hằng đêm.
- Bẫy hay gặp: biến môi trường của frontend Next.js được "nướng" vào lúc build. Đổi giá trị thì phải build lại, restart sẽ trông như thành công nhưng không đổi gì.
Checklist nếu bạn xây hệ thống tương tự#
- Tồn kho theo từng dòng, có trạng thái, giữ hàng có hạn và cron trả hàng.
- Một đường hoàn tất đơn duy nhất, idempotent, cho mọi kênh thanh toán.
- Tách API và worker; mọi việc chậm đi qua queue.
- Crypto: địa chỉ riêng mỗi payment, khoá tỷ giá có hạn, ngưỡng xác nhận theo chain, tỷ giá có nguồn dự phòng.
- Xử lý rõ trả thiếu, trả dư, trả trễ, sai mạng; dùng ví nội bộ làm "đệm".
- Không dùng float cho tiền; lưu theo đơn vị nhỏ nhất.
- Khoá bí mật tách khỏi API, ví nóng giữ số dư thấp.
- Ghi log và lưu đủ dữ liệu để trả lời được câu "khách này đã nhận mã nào, trả bằng giao dịch nào".
Kết#
Nhìn từ ngoài, phần thanh toán là phần "kỹ thuật" nhất: năm kênh, một kênh tự quản lý ví, một kênh dựng lại từ email vì không có API. Nhưng quyết định giữ cho store luôn đúng lại nhỏ hơn nhiều: mỗi món hàng là một dòng, một dòng chỉ được giữ một lần, và mọi thứ khác được sắp xếp để không gì có thể phá vỡ điều đó.
Nếu bạn đang xây một hệ thống tương tự và muốn trao đổi thêm, cứ nhắn mình.
Enjoyed this? Get the next one by email.
Keep reading
2FA in NestJS Application & ReactJS using Google Authenticator
This document provides a step-by-step guide on implementing two-factor authentication (2FA) in a NestJS application with ReactJS using Google Authenticator. It covers initializing the NestJS project with basic login password authentication, creating the authentication module, implementing local authentication strategy, adding JWT management, and integrating 2FA. The document includes code snippets and instructions for each step.
Reading opensource to become more professional
In the modern technology world, reading open source code is becoming increasingly popular and important for developers. This not only helps them improve their programming skills, but also saves them time and enhances their careers. Below are some benefits of daily reading of open source code for developers.