Bản đồ kỹ thuật đầy đủ để nối kho quà UrBox vào hệ sinh thái loyalty của BLUM PRO: component topology, pipeline ký RSA-SHA256, đồng bộ catalog 2 tầng, giao dịch đổi quà có khoá hàng, máy trạng thái chống mất tiền kép và ánh xạ schema dữ liệu.
System context & component topology — từ client tới cổng UrBox
Ba vùng tin cậy: Client → BLUM Backend (NestJS) → UrBox Gateway. Kho dữ liệu nằm sau backend, không bao giờ lộ ra client.
POST /api/rewards/redeem. Client chỉ gửi rewardId + qty; không bao giờ gửi giá điểm, mã quà UrBox hay transaction_id — backend tự sinh.user_id và mutex cho job sync.cartPayVoucher kèm header Signature.transaction_id cũ cho các đơn còn PENDING; không bao giờ tạo mã mới.UrBoxClientService (env/secret manager, không commit). app_secret nằm cùng chỗ. Client và Admin Portal không có đường nào chạm tới hai bí mật này — mọi request đổi quà đều đi qua backend.7 bước tạo signature cho cartPayVoucher
Chỉ 7 trường được đưa vào chữ ký. Sai một trường, sai thứ tự, hay lệch kiểu dữ liệu → UrBox trả 211 / 212.
ttphone, shorten, pin vào payload ký sẽ làm chữ ký lệch.JSON.stringify của JS giữ thứ tự chèn — phải sort trước khi stringify, không dựa vào may mắn.isSendSms: 0 (number) khác "0" (string) trong chuỗi băm.dataBuy phải giữ đúng thứ tự item như lúc gửi; chỉ sort khoá của object, không sort phần tử mảng.JSON.stringify(obj) trần, không truyền tham số format.json_encode sau ksort; Node.js dùng JSON.stringify sau Object.keys().sort(). Hai bên cho ra cùng chuỗi chỉ khi không có ký tự Unicode đặc biệt và không có số thực. Với dữ liệu tiếng Việt, hãy đảm bảo cả hai phía dùng UTF-8 không escape (JSON_UNESCAPED_UNICODE phía PHP).sha256(encoded) ở môi trường sandbox và so với chữ ký verify bằng public key cùng cặp — nếu verify fail thì lỗi nằm ở bước 1–5, không phải ở UrBox.Catalog Sync — 4 lane, 4 endpoint, upsert 2 bảng
Đồng bộ một chiều UrBox → BLUM. Không có chữ ký, nhưng phải phân trang vì giới hạn 500 quà/request.
rewards.sku = 'URBOX:' || urbox_gift_id — chạy lại bao nhiêu lần cũng ra một bản ghi.status='INACTIVE'. Xoá cứng sẽ mồ côi redemption_orders cũ.page_no tới totalPage.reward_translations cho vi; nếu UrBox không trả bản dịch, copy bản vi làm nguồn và đánh dấu fallback trong log.content / note là HTML thô — phải sanitize trước khi render ở mobile.images / images_rectangle; UrBox khuyến nghị dùng ảnh của họ thay vì tự thiết kế.Redemption sequence — khoá hàng, trừ điểm, gọi UrBox, lưu voucher
Điểm bị trừ trước khi gọi UrBox (trong cùng transaction), nhưng đơn ở trạng thái PENDING cho tới khi có voucher thật.
SELECT … FOR UPDATE trên đúng hàng user_profiles giữ khoá tới COMMIT. Redis lock là lớp phụ, không thay thế được row lock.PENDING là bằng chứng để hoàn điểm khi lỗi nghiệp vụ xác định.isSendSms (không phải send_sms), dataBuy[].priceId (không phải gift_id), site_user_id, transaction_id, campaign_code, app_id, app_secret.Đường nào an toàn, đường nào phải chờ đối soát
Nguyên tắc duy nhất: không bao giờ hoàn điểm và tạo transaction_id mới cùng lúc trên một đơn chưa xác định.
| Điều kiện nhận được | Ý nghĩa | Hành động | Trạng thái đơn |
|---|---|---|---|
done=1, pay=2 | Đã thanh toán & xuất quà thành công | Lưu code_link_gift[] vào voucher_payload, trả cho user | COMPLETED |
done=1, pay=1 | Đơn đã tạo nhưng chưa thanh toán / chưa xuất quà | Giữ nguyên, đưa vào hàng đợi đối soát | PENDING |
HTTP 504 / 408 / timeout 60s | Không rõ UrBox đã xử lý hay chưa | Gọi lại cùng transaction_id; nếu đã tồn tại UrBox trả quà đã xuất | PENDING |
done=0 + 220/221/223/225/226/407 | Lỗi nghiệp vụ xác định, chắc chắn chưa xuất quà | refundPoints() + ghi REDEEM_REFUND | CANCELLED |
done=0 + 210/211/212/213 | Lỗi xác thực / chữ ký — lỗi hệ thống của BLUM | Không hoàn điểm tự động; alert on-call, giữ PENDING | PENDING |
done=0 + 306/309 | Ví UrBox của đối tác không đủ tiền | Alert tài chính; hoàn điểm cho user | CANCELLED |
done=0 + 408 | “Hệ thống hiện tại không tạo được đơn hàng” — không xác định | Không hoàn điểm ngay; đối soát trước | PENDING |
transaction_id là cách duy nhất xác định sự thật.OrderStatus trong schema hiện tại là PENDING | PROCESSING | SHIPPED | DELIVERED | CANCELLED — không có COMPLETED và HOLDING_RECONCILE. Cần migration bổ sung giá trị enum trước khi implement (xem Sơ đồ 6).UrBox payload ↔ BLUM_PRO tables · cột hiện có vs cột mở rộng
Bên trái là shape dữ liệu UrBox trả về; bên phải là bảng BLUM hiện có. Đường nét đứt là cột chưa tồn tại và phải thêm.
| UrBox field | Kiểu | Bảng BLUM | Cột đích | Trạng thái |
|---|---|---|---|---|
Brand.id | string | product_categories | slug = 'urbox-brand-' || id | có sẵn |
Brand.title | string | product_category_translations | name | có sẵn |
Gift.id (priceId) | string | rewards | sku = 'URBOX:' || id | có sẵn |
Gift.id | string | rewards | external_gift_id | cần thêm |
Gift.brand_id | string | rewards | external_brand_id | cần thêm |
Gift.cat_id | string | rewards | category_id → product_categories.id | có sẵn |
Gift.title | string | reward_translations | name (locale vi) | có sẵn |
Gift.content / note | HTML | reward_translations | description (sanitized) | có sẵn |
Gift.price | int (VNĐ) | rewards | required_points (quy đổi 1:1 hoặc tỷ lệ cấu hình) | có sẵn |
Gift.stock | 1 / 2 | rewards | status (ACTIVE khi stock=1) | có sẵn |
Gift.image / images | URL | rewards | image_url | có sẵn |
Gift.code_display_type | 1–5 | rewards | — | metadata, có thể lưu trong voucher_payload |
Cart.id | string | redemption_orders | provider_order_id | cần thêm |
Cart.transaction_id | string | redemption_orders | provider_transaction_id (dùng đối soát) | cần thêm |
Cart.pay | 1 / 2 | redemption_orders | status — map sang enum mới | cần mở rộng enum |
Cart.money_total | int | redemption_orders | voucher_payload.total | JSONB |
code_link_gift[].code | string | redemption_orders | voucher_payload.codes[] | cần thêm |
code_link_gift[].pin | string | redemption_orders | voucher_payload.codes[].pin | cần thêm |
code_link_gift[].serial | string | redemption_orders | voucher_payload.codes[].serial | cần thêm |
code_link_gift[].link | URL | redemption_orders | voucher_payload.codes[].link | cần thêm |
code_link_gift[].expired | dd/mm/yyyy | redemption_orders | voucher_payload.codes[].expired | cần thêm |
code_link_gift[].code_image | URL | redemption_orders | voucher_payload.codes[].code_image | cần thêm |
| — | int | point_transactions | points_earned = −(pts × qty) | có sẵn |
| — | text | point_transactions | source = 'REDEEM' / 'REDEEM_REFUND' | có sẵn |
qr_code NOT NULL + UNIQUE. point_transactions.qr_code là TEXT NOT NULL và có unique index. Giao dịch đổi quà không có QR, nên codebase đã dùng sentinel 'REDEEM_' || order.id (và 'REDEEM_REFUND_' || order.id khi hoàn). Đây là pattern sẵn có trong RewardsService, không phải cột cần sửa.PointSource đã có REDEEM. Migration 20260803000000_redeem_point_sources đã thêm REDEEM và REDEEM_REFUND; RewardsService.redeem() ghi source: 'REDEEM' và refundPoints() ghi 'REDEEM_REFUND'. Không cần migration cho enum này.voucher_payload JSONB thay vì bảng redemption_vouchers. Một đơn có thể trả nhiều code (tối đa 30 item). Lưu nguyên khối cart.code_link_gift[] dạng JSONB giữ được tính toàn vẹn với response gốc, tránh phải migrate mỗi khi UrBox thêm field. Chỉ tách bảng riêng khi cần truy vấn/tra cứu theo code ở quy mô lớn — lúc đó thêm index GIN hoặc bảng phụ dẫn xuất.5 endpoint · request/response mẫu · copy nhanh
parent_id=2 → quà eVoucher, parent_id=136 → quà vật lý. Không truyền thì mặc định parent_id=2.
{
"app_id": "20",
"app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
"parent_id": 2,
"lang": "vi"
}
// Response 200
{
"done": 1,
"msg": "success",
"microtime": "0.25283s",
"status": 200,
"data": [
{
"images": "https://632466.sgp1.digitaloceanspaces.com/.../640/1560252025_5cff8e7988ef1.png",
"id": "117",
"title": "Dessert"
},
{
"images": "https://632466.sgp1.digitaloceanspaces.com/.../640/1560252345_5cff8fb9ee50e.png",
"id": "10",
"title": "Restaurant"
}
]
}
cat_id; hỗ trợ per_page + page_no. Không truyền cat_id → trả toàn bộ.
{
"app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
"app_id": "20",
"cat_id": "8"
}
// Response 200
{
"done": 1,
"msg": "success",
"microtime": "0.20396s",
"status": 200,
"data": {
"items": [
{
"banner": "",
"description": "KFC là cụm từ viết tắt của Kentucky Fried Chicken...",
"images": "https://632466.sgp1.digitaloceanspaces.com/.../160/1667293902_6360e2cea56f4.png",
"id": "531",
"cat_id": "10",
"title": "KFC",
"gift_count": 1,
"cat_title": "Nhà Hàng",
"parent_cat_id": "8"
}
],
"textTitle": "Tất cả thương hiệu",
"brand_count": "3",
"totalPage": 1
}
}
page_no + per_page. Thêm stock=1 để nhận cả quà hết hàng kèm trạng thái tồn.
{
"app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
"app_id": "20",
"brand_id": 500,
"cat_id": 157,
"field": "content,note,office",
"stock": 1,
"page_no": 1,
"per_page": 100,
"lang": "vi"
}
// Response 200 (rút gọn)
{
"done": 1,
"msg": "success",
"status": 200,
"data": {
"items": [
{
"id": "4837",
"brand": "500",
"brand_id": "500",
"cat_id": "157",
"cat_title": "Nha khoa thẩm mỹ",
"gift_id": "2362",
"title": "E-voucher Chăm sóc nha khoa kiểm tra, chụp film 3D và trám 3 răng",
"type": "10",
"price": "500000",
"point": "500000",
"stock": 1,
"quantity": "98",
"image": "https://632466.sgp1.digitaloceanspaces.com/.../640/1663670122_6329976a386a0.png",
"expire_duration": "Tối thiểu 30 ngày",
"code_display": "QR code",
"code_display_type": 1,
"brand_name": "ORACLE BEAUTY CLINIC VIETNAM",
"brand_online": "1",
"parent_cat_id": "4",
"code_quantity": "98"
}
]
},
"totalPage": 1
}
id. Dùng field=content,note,office để lấy mô tả, điều kiện sử dụng và danh sách cửa hàng.
{
"app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
"app_id": "20",
"id": "709",
"lang": "vi"
}
// Response 200 (rút gọn)
{
"done": 1,
"msg": "success",
"status": 200,
"data": {
"id": "709",
"brand": "ORACLE BEAUTY CLINIC VIETNAM",
"brand_id": "500",
"cat_id": "157",
"parent_cat_id": "4",
"gift_id": "2362",
"title": "E-voucher Chăm sóc nha khoa",
"type": "10",
"price": "500000",
"code_display": "QR code",
"code_display_type": 1,
"expire_duration": "Tối thiểu 30 ngày",
"brand_online": "1",
"content": "<p>LÝ DO BẠN NÊN TẨY TRẮNG RĂNG...</p>",
"note": "<ul><li>Khách hàng đến và xác nhận e-voucher...</li></ul>",
"office": [
{
"id": "167435",
"brand_id": "500",
"code": "ESYN",
"address": "79 Ngô Thời Nhiệm, Phường 6, Quận 3, Thành phố Hồ Chí Minh",
"title_city": "Hồ Chí Minh",
"phone": "",
"isApply": "2"
}
]
}
}
Signature (Base64 của RSA-SHA256 trên 7 trường đã sort). Idempotent theo transaction_id: gọi lại cùng mã sẽ trả về đơn đã tạo.
// Request body
{
"app_id": "20",
"app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
"campaign_code": "UG516704",
"site_user_id": "341721226",
"ttphone": "0979999067",
"transaction_id": "BLUM-8f2c41d9-20261023",
"isSendSms": 0,
"shorten": 0,
"dataBuy": [
{ "priceId": "2913", "quantity": 1 }
]
}
// Header
Content-Type: application/json
Signature: MEUCIQDx...Base64...==
// Response 200 — thành công, đã xuất quà
{
"done": 1,
"msg": "success",
"microtime": "1.41339s",
"status": 200,
"data": {
"pay": 2,
"transaction_id": "00000000160test67891",
"cart_created": "1681271943",
"linkCart": "https://sand.urbox.dev/cart/...html?cart_no=199305",
"linkCombo": "",
"cart": {
"id": "199305",
"cartNo": "199305",
"money_total": "50000",
"money_ship": "0",
"link_gift": [
"https://sand.urbox.dev/nhan-qua/3655c208fb2c1468b4368a2e89e8efcf.html"
],
"code_link_gift": [
{
"cart_detail_id": "393790",
"code_display": "Barcode, QRcode",
"code_display_type": 5,
"link": "https://sand.urbox.dev/nhan-qua/3655c208fb2c1468b4368a2e89e8efcf.html",
"code": "UB931542726",
"pin": "",
"serial": "4053627",
"priceId": "89",
"gift_id": "39",
"token": "3655c208fb2c1468b4368a2e89e8efcf",
"expired": "11/06/2023",
"expired_time": 1686502799,
"code_image": "https://dev.urbox.dev/v1/api/qrbarcode?code=UB931542726",
"price": 50000
}
]
}
}
}
// Response 200 — transaction đã tồn tại nhưng đơn thất bại trước đó
{
"done": 1,
"msg": "success",
"status": 200,
"data": {
"pay": 1,
"transaction_id": "2031010820240751AM",
"cart_created": "1722473500",
"linkCart": "",
"cart": {
"id": "39075519",
"cartNo": "39075519",
"money_total": "100000",
"link_gift": null
}
}
}
// → KHÔNG có code_link_gift ⇒ chưa có quà ⇒ giữ PENDING, đối soát
/2.0/cart/cartPayVoucher và /4.0/gift/lists — không phải /4.0/cart/cartPayVoucher hay /2.0/gift/getlist. Hãy đối chiếu lại với UrBox trước khi hard-code; nếu môi trường PROD cấp URL khác, đưa vào biến môi trường URBOX_BASE_URL.type · code_display_type · pay · trạng thái
type — loại quàBảng đầy đủ theo tài liệu UrBox. Với tích hợp eVoucher của BLUM, chỉ một số giá trị là khả dụng.
| Giá trị | Loại quà | Dùng cho BLUM |
|---|---|---|
1 | Voucher tiền mặt | có thể |
2 | Giftset | không |
3 | Combo | không |
4 | Thẻ balance | có thể |
5 | Thẻ điện thoại | không |
7 | Thẻ điểm | không |
8 | Topup điểm | không |
9 | Vật lý | không |
10 | Item (sản phẩm cụ thể) | có thể |
11 | Voucher khuyến mãi | có thể |
12 | Bảo hiểm | cần xác nhận |
14 | Lượt quay số | không |
15 | Premium Service | cần xác nhận |
16 | Deal | không |
19 | Link quà UrCard | không |
22 | Thẻ quà UrCard | không |
24 | Quà lockitem SKU | không |
Giá trị 13 không xuất hiện trong bảng chính thức của UrBox — nếu gặp trong dữ liệu thật, log lại và xác nhận với UrBox thay vì tự suy diễn.
code_display_type — hình thức mãQuyết định cách mobile render voucher. Text code luôn hiển thị với 3 hình thức đầu.
| Giá trị | Hình thức | Render trên mobile |
|---|---|---|
1 | QR code | QR + text code |
2 | Barcode | Barcode 128 + text code |
3 | Vật lý | Chỉ hiển thị hướng dẫn nhận quà |
4 | Text code | Chỉ text, cho phép copy |
5 | Barcode + QR code | Hiển thị cả hai + text code |
Tài liệu chính thức chỉ định nghĩa 1–5. Các giá trị 6, 7 không có trong spec — phải xử lý phòng thủ bằng fallback “hiển thị text code + link”.
pay — trạng thái thanh toán| Giá trị | Ý nghĩa | Xử lý |
|---|---|---|
1 | Chưa thanh toán / chưa xuất quà | Giữ PENDING, đối soát |
2 | Đã thanh toán — quà đã xuất | Lưu voucher, COMPLETED |
pay. Theo tài liệu UrBox: 1 = chưa thanh toán, 2 = đã thanh toán. Một số tài liệu nội bộ ghi ngược (coi pay=1 là thành công) — nếu implement theo chiều sai, hệ thống sẽ trả voucher rỗng cho user và không bao giờ đối soát.Mã từ tài liệu UrBox + phân loại xử lý phía BLUM
Cột “Xử lý BLUM” là quyết định thiết kế, không phải nội dung tài liệu UrBox.
| Status | Mô tả (UrBox) | Phân loại | Xử lý BLUM |
|---|---|---|---|
200 | Thành công | OK | Kiểm tra thêm pay trước khi coi là xong |
210 | Không tìm thấy app_id hoặc app_secret | Cấu hình | Không hoàn điểm; alert on-call; kiểm tra secret |
211 | Access Urbox không đúng | Cấu hình | Như trên — sai credential hoặc IP chưa whitelist |
212 | Thông tin xác thực không chính xác | Chữ ký | Kiểm tra pipeline ký (Sơ đồ 2); giữ PENDING |
213 | Xảy ra lỗi xác thực | Chữ ký | Như trên |
220 | Kho quà đang hết, vui lòng quay lại sau | Tạm thời | Không hoàn điểm ngay; retry có backoff |
221 | Không tìm thấy sản phẩm mua | Nghiệp vụ | CANCELLED + hoàn điểm |
222 | Không tìm thấy sản phẩm | Nghiệp vụ | CANCELLED + hoàn điểm |
223 | Quà đã hết hạn, chọn quà khác | Nghiệp vụ | CANCELLED + hoàn điểm; set rewards.status=INACTIVE |
224 | Không tìm thấy quà tặng | Nghiệp vụ | CANCELLED + hoàn điểm |
225 | Sản phẩm đang hết, bỏ khỏi giỏ | Nghiệp vụ | CANCELLED + hoàn điểm; sync lại stock |
226 | Quà không nằm trong chương trình | Nghiệp vụ | CANCELLED + hoàn điểm; rà lại giftset |
304 | Email không đúng định dạng | Input | Validate trước khi gọi — lỗi phía BLUM |
306 / 309 | Hệ thống khách hàng không đủ tiền | Tài chính | CANCELLED + hoàn điểm + alert ví UrBox |
307 | Request thiếu campaign_code | Cấu hình | Lỗi lập trình — giữ PENDING, alert |
308 | Mã chương trình không thuộc app_id | Cấu hình | Như trên |
403 | Không tìm thấy mã giao dịch | Đối soát | Đối soát thủ công theo transaction_id |
404 | Số lượng phải lớn hơn 0 | Input | Validate trước khi gọi |
405 | Vui lòng chọn quà muốn tặng trước | Input | Validate dataBuy không rỗng |
406 | Mã khuyến mại không đúng | Cấu hình | Kiểm tra campaign_code; giữ PENDING |
407 | Số lượng sản phẩm không đủ | Nghiệp vụ | CANCELLED + hoàn điểm |
408 | Hệ thống hiện tại không tạo được đơn hàng | Không xác định | Không hoàn điểm ngay — đối soát trước |
409 | Không tìm thấy dữ liệu | Đối soát | Đối soát theo transaction_id |
601–610 | Lỗi địa chỉ / tỉnh thành / quận huyện / phường xã / SĐT | Vật lý | Không áp dụng cho eVoucher (luồng giao hàng vật lý) |
-1 (system busy), 999 (holding), 401, 500, 504 không có trong bảng chính thức của UrBox. Chúng thường xuất hiện ở tầng gateway/hạ tầng. Nguyên tắc xử lý vẫn giữ nguyên: nếu không chắc đơn đã tạo hay chưa → giữ PENDING và đối soát, không hoàn điểm, không đổi transaction_id.408, 409, 403, lỗi mạng) đều phải qua đối soát trước.Những điểm brief nội bộ khác tài liệu UrBox — phải chốt trước go-live
| Hạng mục | Brief nội bộ ghi | Tài liệu UrBox xác nhận | Kết luận |
|---|---|---|---|
| Endpoint đổi quà | /4.0/cart/cartPayVoucher |
/2.0/cart/cartPayVoucher |
Dùng 2.0, xác nhận lại với UrBox |
| Endpoint danh sách quà | /2.0/gift/getlist |
/4.0/gift/lists |
Brief sai — dùng 4.0/gift/lists |
| Endpoint thương hiệu | /2.0/brand/get |
/4.0/gift/brand |
Brief sai — dùng 4.0/gift/brand |
| Tham số danh mục | parent_id=0, type=12 |
parent_id=2 (eVoucher) hoặc 136 (vật lý); không có type |
Brief sai — dùng parent_id=2 |
Ý nghĩa pay |
pay=1 = thành công; pay=4 = chờ |
1 = chưa thanh toán; 2 = đã thanh toán |
Brief sai — đảo chiều sẽ mất tiền |
| Tên tham số SMS | send_sms |
isSendSms |
Brief sai |
code_display_type |
1..7 | 1..5 | Chỉ 5 giá trị được định nghĩa |
| Bước 4 pipeline ký | “escape " thành \"” |
Spec không yêu cầu escape thủ công | Không escape trong Node.js — dùng JSON.stringify trần |
| Trạng thái đơn | COMPLETED, HOLDING_RECONCILE |
Enum hiện tại không có hai giá trị này | Phải migration bổ sung enum |
| Mã lỗi | -1, 999, 401, 500, 504 |
Không có trong bảng chính thức | Xử lý phòng thủ, không map cứng |
pay, và danh sách mã lỗi thực tế trên PROD.campaign_code tách riêng cho sandbox và PROD.PENDING > 2 phút.cartPayVoucher hai lần cùng transaction_id → phải nhận cùng một cart.id.REDEEM_REFUND.qty ≤ 30 và tổng item ≤ 30 mỗi request.