Appearance
🔌 HƯỚNG DẪN FRONTEND KẾT NỐI BACKEND (gghut-core)
Tài liệu cho FE tích hợp với core BE. Cập nhật theo Phase 0.
1 · BASE URL
https://gghut-core-24817855140.asia-southeast1.run.app- Region:
asia-southeast1(Singapore), gần Neon DB. - ⚠️ Cold start: Cloud Run free tier ngủ khi không dùng. Request đầu sau khi ngủ có thể mất vài giây. Sau đó nhanh bình thường.
- Health check:
GET /livez(process sống),GET /readyz(DB connect được).
2 · RESPONSE FORMAT (chuẩn cho MỌI endpoint)
jsonc
// Thành công
{
"data": { ... }, // payload chính
"meta": { "page": 1, "page_size": 20, "total": 42 } // chỉ có ở list
}
// Lỗi
{
"error": {
"code": "SLOT_FULL", // machine-readable — FE switch theo code này
"message": "Campaign đã hết slot" // human-readable
}
}HTTP status đi kèm: 400 (validation) · 401 (chưa đăng nhập/token hết hạn) · 403 (không đủ quyền) · 404 (không tìm thấy) · 409 (xung đột trạng thái) · 500 (lỗi server).
Quy tắc FE: luôn đọc
error.codeđể xử lý logic, đừng parsemessage.
3 · AUTH FLOW (JWT)
BE dùng access token (15 phút) + refresh token (7 ngày).
3.1 Register / Login → nhận cặp token
http
POST /api/v1/auth/register
Content-Type: application/json
{ "email": "user@example.com", "password": "password123", "full_name": "Tên" }http
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "user@example.com", "password": "password123" }Response (200/201):
json
{
"data": {
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 900,
"user": { "id": 1, "email": "user@example.com", "full_name": "Tên", "is_admin": false }
}
}3.2 Gọi API có auth → gắn Authorization header
Authorization: Bearer <access_token>js
// fetch example
const res = await fetch(`${BASE}/api/v1/orders`, {
headers: { Authorization: `Bearer ${accessToken}` },
});3.3 Access token hết hạn (401) → refresh
Khi nhận 401, gọi refresh để lấy access token mới, rồi retry request cũ:
http
POST /api/v1/auth/refresh
Content-Type: application/json
{ "refresh_token": "<refresh_token>" }Response giống 3.1 (có access_token mới).
Flow chuẩn cho FE (viết 1 lần, dùng chung):
js
async function api(path, options = {}) {
const doFetch = (token) =>
fetch(`${BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
let res = await doFetch(getAccessToken());
if (res.status === 401) {
// thử refresh
const ok = await refreshTokens(); // gọi /auth/refresh, lưu token mới
if (ok) res = await doFetch(getAccessToken());
}
return res.json();
}
refresh_tokenhết hạn (7 ngày) hoặc bị revoke →/auth/refreshtrả401→ FE đưa user về trang login.
3.4 Logout + Me
http
POST /api/v1/auth/logout # body: { "refresh_token": "..." } (cần auth)
GET /api/v1/auth/me # thông tin user hiện tại (cần auth)3.5 Lưu token ở FE + Remember me
FE (cả storefront lẫn admin) lưu token trong cookies qua js-cookie, không dùng localStorage để giảm bề mặt tấn công XSS:
| Cookie | "Ghi nhớ đăng nhập" ✅ (mặc định) | Bỏ chọn ghi nhớ |
|---|---|---|
access_token | ~15 phút (khớp TTL token BE) | session cookie |
refresh_token | 7 ngày (khớp JWT_REFRESH_TTL BE) | session cookie |
remember_me (marker) | 7 ngày | session cookie |
Hành vi:
- Bỏ chọn "Ghi nhớ đăng nhập" khi login → cả 3 cookie là session cookie, đóng browser là đăng xuất.
- Access token hết hạn giữa phiên → interceptor tự gọi
/auth/refreshrồi ghi lại cookie theo đúng chế độ đã chọn lúc đăng nhập (đọc từ markerremember_me). - Refresh token hết hạn/bị revoke → xoá cookie + về trang login như flow 3.3.
- Logout → xoá sạch cả 3 cookie.
- TTL cookie FE cố tình khớp TTL token của BE. Muốn kéo dài thời gian "ghi nhớ" quá 7 ngày phải tăng env
JWT_REFRESH_TTLở BE.
4 · DANH SÁCH ENDPOINT
Auth (public)
| Method | Path | Mô tả |
|---|---|---|
| POST | /api/v1/auth/register | Đăng ký |
| POST | /api/v1/auth/login | Đăng nhập |
| POST | /api/v1/auth/refresh | Đổi access token |
Auth (cần đăng nhập)
| Method | Path | Mô tả |
|---|---|---|
| POST | /api/v1/auth/logout | Đăng xuất (revoke refresh) |
| GET | /api/v1/auth/me | Thông tin user hiện tại |
Campaign (public)
| Method | Path | Mô tả |
|---|---|---|
| GET | /api/v1/campaigns | List campaign (mặc định trả OPEN + CLOSED + CANCELLED, ẩn DRAFT; lọc bằng ?status=all (trả đủ cả DRAFT) hoặc 1/nhiều trạng thái phân tách dấu phẩy ?status=DRAFT,OPEN, paging ?page=&page_size=) |
| GET | /api/v1/campaigns/{slug} | Chi tiết campaign (kèm tiers + remaining slots) |
Campaign object:
json
{
"id": 1, "title": "Áo Thun GroupBuy", "slug": "ao-thun-groupbuy",
"description": "Áo thun cotton",
"status": "OPEN", // DRAFT | OPEN | CLOSED | CANCELLED | FAILED
"banner_url": "https://pub-xxxx.r2.dev/campaigns/1/banner.jpg",
"deposit_per_unit": 50000, // tiền cọc / 1 sản phẩm (VND)
"default_price": 180000, // giá gốc hiển thị (tuỳ chọn)
"promo_price": 165000, // giá khuyến mãi hiển thị (tuỳ chọn)
"settlement_window_h": 72,
"shipping_fee": 0,
"capacity": 100, "taken": 30, "remaining": 70,
"moq": 10, // = qty_threshold của tier thấp nhất — số lượng tối thiểu để chốt giá
"moq_reached": true, // taken đã đạt MOQ chưa (BE computed)
"current_unit_price": 150000, // giá hiệu lực hiện tại: tier cao nhất đã đạt → fallback promo_price → default_price (BE computed)
"closes_at": "2026-09-01T00:00:00Z",
"opened_at": "2026-08-20T00:00:00Z",
"created_at": "2026-08-18T09:30:00Z",
"tiers": [
{ "id": 1, "qty_threshold": 10, "unit_price": 150000 },
{ "id": 2, "qty_threshold": 50, "unit_price": 120000 }
],
"images": [
{ "id": 10, "url": "https://pub-xxxx.r2.dev/campaigns/1/3f9a….jpg", "sort_order": 1 },
{ "id": 11, "url": "https://pub-xxxx.r2.dev/campaigns/1/b7c2….png", "sort_order": 2 }
]
}⚠️ List vs detail:
GET /campaigns(list) KHÔNG trảtierslẫnimages. ChỉGET /campaigns/{slug}(chi tiết) mới có đủ. Các field tuỳ chọn (banner_url,images,closes_at,default_price,promo_price…) bị lược bỏ hẳn khỏi JSON khi chưa có giá trị (không trảnull). Ngược lạimoq,moq_reached,current_unit_pricelà field computed — BE trả ở cả list lẫn detail để card không phải fetch detail.
Trạng thái campaign: ngoài DRAFT/OPEN/CLOSED/CANCELLED có thêm FAILED — campaign hết giờ mà tổng qty cọc không đạt MOQ → hệ thống tự chuyển FAILED và đưa mọi đơn DEPOSIT_HELD vào hàng hoàn cọc (REFUND_PENDING). Chi tiết luồng: docs/groupbuy-flow.md.
Order (cần đăng nhập)
| Method | Path | Mô tả |
|---|---|---|
| POST | /api/v1/orders | Đặt slot (giữ 15 phút). Body: { "campaign_id": 1, "qty": 30 } |
| GET | /api/v1/orders | Đơn của tôi (paging) |
| GET | /api/v1/orders/{id} | Chi tiết đơn (chỉ chủ đơn hoặc admin) |
Đặt slot — lỗi thường gặp:
SLOT_FULL(409): hết slot → hiển thị "đã hết chỗ"INVALID_CAMPAIGN_STATE(409): campaign chưa mở/đã đóng
Order object:
json
{
"id": 5, "code": "GB-US28BP", "user_id": 2, "campaign_id": 1,
"status": "RESERVED",
// RESERVED | EXPIRED | DEPOSIT_HELD | SETTLEMENT_DUE | CONFIRMED
// | SHIPPED | DELIVERED | REFUND_PENDING | REFUNDED | CANCELLED
"qty": 30,
"deposit_due": 1500000, "deposit_paid": 0,
"settlement_paid": 0, // tổng tiền phần còn lại đã chuyển (cộng dồn qua webhook settlement)
"expires_at": "2026-08-25T04:15:00Z", // hết hạn giữ slot
"final_unit_price": null, "amount_due": null, // chỉ có sau khi settle
"payment": { // chỉ có khi đơn còn chờ cọc (RESERVED)
"bank_code": "VCB", "account_number": "1017588888", "account_holder": "CONG TY GG",
"content": "GB-US28BP", // nội dung CK = mã đơn
"qr_url": "https://vietqr.app/img?acc=1017588888&amount=1500000&bank=VCB&des=GB-US28BP&template=compact"
},
"created_at": "2026-08-25T04:00:00Z"
}Vòng đời đơn + tự động hoá (chi tiết đầy đủ ở docs/groupbuy-flow.md):
RESERVED→(webhook cọc đủ)→DEPOSIT_HELD→(settle)→SETTLEMENT_DUE→(chuyển đủamount_due)→CONFIRMED→ SHIPPED → DELIVERED.- BE tự settle campaign OPEN khi đủ slot (
taken == capacity, chốt ngay) hoặc quácloses_at: đạt MOQ →CLOSED; thiếu MOQ →FAILED+ toàn bộ đơnDEPOSIT_HELD→REFUND_PENDING. - Admin hoàn cọc chủ động hoặc xử lý hàng hoàn:
POST /admin/orders/{id}/refund.
Admin (đăng nhập + is_admin=true)
| Method | Path | Mô tả |
|---|---|---|
| POST | /api/v1/admin/campaigns | Tạo campaign (draft) → 201 + object trạng thái DRAFT |
| PUT | /api/v1/admin/campaigns/{id} | Sửa campaign DRAFT/OPEN → 200 + object mới |
| POST | /api/v1/admin/campaigns/{id}/open | Mở bán (DRAFT → OPEN) |
| POST | /api/v1/admin/campaigns/{id}/close | Đóng nhóm |
| POST | /api/v1/admin/campaigns/{id}/cancel | Huỷ |
| POST | /api/v1/admin/campaigns/{id}/settle | Chốt giá + tính amount_due (vẫn dùng tay được; hệ thống cũng tự settle — xem lifecycle) |
| POST | /api/v1/admin/campaigns/{id}/fulfill | "Đã về hàng": campaign phải CLOSED → mọi đơn CONFIRMED chuyển SHIPPED |
| GET | /api/v1/admin/campaigns/{id}/orders | Đơn theo campaign |
| POST | /api/v1/admin/orders/{id}/refund | Hoàn cọc: DEPOSIT_HELD/SETTLEMENT_DUE/REFUND_PENDING → REFUNDED |
| POST | /api/v1/admin/orders/{id}/ship | Gửi hàng 1 đơn: CONFIRMED → SHIPPED |
| POST | /api/v1/admin/orders/{id}/deliver | Xác nhận đã giao: SHIPPED → DELIVERED |
| POST | /api/v1/admin/campaigns/{id}/banner | Upload/đổi banner (multipart) |
| POST | /api/v1/admin/campaigns/{id}/images | Thêm nhiều ảnh gallery trong 1 request (multipart, field image lặp lại, tối đa 10 ảnh) |
| DELETE | /api/v1/admin/campaigns/{id}/images/{imageID} | Xoá 1 ảnh gallery |
Tạo campaign — body (POST /api/v1/admin/campaigns):
| Field | Bắt buộc | Kiểu | Ghi chú |
|---|---|---|---|
title | ✅ | string | Slug tự sinh từ title: viết thường, giữ nguyên dấu tiếng Việt, khoảng trắng/-/_ gộp thành một dấu - (vd: "Áo Thun HOT 2026" → áo-thun-hot-2026). Trùng slug đã có → lỗi DB duplicate |
description | — | string | |
deposit_per_unit | ✅ | int ≥ 0 | Tiền cọc / 1 slot (VND) |
default_price | — | int ≥ 0 | Giá gốc hiển thị trên storefront |
promo_price | — | int ≥ 0 | Giá khuyến mãi hiển thị (fallback của giá hiệu lực khi chưa đạt tier nào) |
settlement_window_h | — | int ≥ 1 | Gửi 0 hoặc bỏ trống → BE mặc định 72 |
shipping_fee | — | int ≥ 0 | Mặc định 0 |
capacity | ✅ | int ≥ 1 | Tổng số slot |
closes_at | — | RFC3339 UTC | Phải đủ giây + múi giờ, vd 2026-09-01T00:00:00Z. Chuỗi thiếu múi giờ (vd giá trị của <input type="datetime-local">: 2026-09-01T00:00) sẽ fail parse → INVALID_JSON. FE phải convert sang ISO UTC trước khi gửi |
tiers | ✅ | array ≥ 1 mốc | qty_threshold phải tăng dần nghiêm ngặt (mốc sau > mốc trước), unit_price ≥ 0 — vi phạm → INVALID_TIER |
json
{
"title": "Áo Thun GroupBuy",
"description": "Áo thun cotton",
"deposit_per_unit": 50000,
"default_price": 180000,
"promo_price": 165000,
"settlement_window_h": 72,
"shipping_fee": 0,
"capacity": 100,
"closes_at": "2026-09-01T00:00:00Z",
"tiers": [
{ "qty_threshold": 10, "unit_price": 150000 },
{ "qty_threshold": 50, "unit_price": 120000 }
]
}Response: 201 + campaign object trạng thái DRAFT (đã có slug, chưa có banner_url/images — upload sau bằng endpoint multipart bên dưới).
Lỗi hay gặp khi tạo: VALIDATION_FAILED (400 — thiếu title/capacity/tiers…) · INVALID_JSON (400 — body sai JSON hoặc closes_at sai RFC3339) · INVALID_TIER (400 — qty_threshold không tăng dần) · UNAUTHORIZED/FORBIDDEN (401/403 — token hết hạn hoặc không phải admin).
⚠️ Admin là ai? Hiện Phase 0 user tạo qua
/registermặc địnhis_admin=false. Để có admin, phải set trực tiếp trong DB (UPDATE users SET is_admin=true WHERE email=...). Sau này sẽ có luồng mời admin.
Upload ảnh campaign (admin, multipart)
Ảnh campaign lưu trên Cloudflare R2, đọc công khai qua CDN *.r2.dev. FE không upload trực tiếp lên R2 — luôn đẩy file qua BE, nhận lại URL rồi render qua next/image.
⚠️ next/image trên FE: cả
nextjs-storefrontlẫnnextjs-adminchạyoutput: "export"(static), không có server tối ưu ảnh — vì vậynext.config.tsphải giữimages: { unoptimized: true }.<Image>vẫn cho lazy-load + chống layout shift; khi sau này bật optimizer thật (vd Cloudflare Images) chỉ cần thêm custom loader và khai báo domain R2 trongimages.remotePatterns, không phải đổi code render.
3 endpoint:
| Endpoint | Response |
|---|---|
POST /api/v1/admin/campaigns/{id}/banner | 200 + campaign object (đã cập nhật banner_url) |
POST /api/v1/admin/campaigns/{id}/images | 201 + mảng ảnh: { "data": [ { "id": 10, "url": "...", "sort_order": 3 }, … ] } |
DELETE /api/v1/admin/campaigns/{id}/images/{imageID} | 204 (không body) |
Quy tắc chung:
- Body là
multipart/form-data, file đặt trong field tên bắt buộcimage. - Banner: 1 request = 1 file. Gallery: 1 request được nhiều file — append nhiều part trùng tên
image, thứ tự part chính là thứ tự hiển thị. - Giới hạn 10 MB/ảnh, tối đa 10 ảnh/request gallery. Server tự dò MIME từ nội dung file (không tin header client gửi) — chỉ nhận
jpeg / png / webp / gif. - Upload gallery là all-or-nothing: lỗi bất kỳ ảnh nào (R2/DB) thì BE dọn sạch, không bao giờ lưu lại một nửa. FE chỉ cần xử lý thành công/thất bại của cả request.
- Upload được ở mọi trạng thái campaign (DRAFT/OPEN/CLOSED), chỉ cần token admin.
- Banner: upload lần sau ghi đè banner cũ (object cũ bị xoá nếu đổi định dạng). Không có khái niệm nhiều banner.
- Gallery: ảnh mới luôn append cuối —
sort_ordertăng dần từ 1 theo thứ tự gửi. BE chưa có API đổi thứ tự/xoá hàng loạt; muốn sắp xếp lại thì xoá rồi upload lại. Chưa có giới hạn tổng số ảnh/campaign (gọi endpoint nhiều lần thoải mái). - Delete ảnh: xoá cả DB record lẫn object trên R2.
imageIDkhông thuộc campaign đó →NOT_FOUND. - URL trả về là link public vĩnh viễn — cache được, không cần ký/token.
js
// Thêm NHIỀU ảnh gallery trong 1 request
const form = new FormData();
for (const file of selectedFiles) {
form.append("image", file); // field name PHẢI là "image" — lặp lại cho từng file
}
const res = await fetch(`${BASE}/admin/campaigns/${campaignId}/images`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
// ⚠️ KHÔNG tự set Content-Type — browser phải tự sinh boundary của multipart
body: form,
});
const { data } = await res.json();
// data = [{ id: 10, url: "https://pub-xxxx.r2.dev/campaigns/1/a1b2….jpg", sort_order: 3 }, ...]bash
# curl test nhanh (mỗi -F "image=@..." là 1 file)
curl -X POST "$BASE/admin/campaigns/1/images" \
-H "Authorization: Bearer $TOKEN" \
-F "image=@ao-thun-1.jpg" \
-F "image=@ao-thun-2.png"Luồng gợi ý cho màn hình tạo campaign (admin) — ảnh gallery gộp thành đúng 1 call:
js
// Bước 1 · Tạo campaign (JSON thuần, chưa có ảnh)
const { data: campaign } = await api("/admin/campaigns", {
method: "POST",
body: JSON.stringify({ title, description, deposit_per_unit, capacity, tiers, /* … */ }),
});
const id = campaign.id;
// Bước 2 · Upload banner nếu admin chọn ảnh bìa (tuỳ chọn, 1 file)
if (bannerFile) {
const fd = new FormData();
fd.append("image", bannerFile);
await api(`/admin/campaigns/${id}/banner`, { method: "POST", body: fd });
}
// Bước 3 · Upload TẤT CẢ ảnh gallery trong 1 request (tuỳ chọn, ≤10 file; >10 thì chia lô)
if (galleryFiles.length > 0) {
const fd = new FormData();
for (const f of galleryFiles) fd.append("image", f);
const { data: images } = await api(`/admin/campaigns/${id}/images`, { method: "POST", body: fd });
// images theo đúng thứ tự admin sắp trong form → render preview từ URL này
}
// Bước 4 (tuỳ chọn) · Mở bán ngay
await api(`/admin/campaigns/${id}/open`, { method: "POST" });Nếu admin sửa campaign DRAFT/OPEN và thêm ảnh mới → gọi lại bước 2/3 với cùng
{id}; ảnh cũ vẫn giữ nguyên, ảnh mới append cuối.
Lỗi riêng của upload: IMAGE_TOO_LARGE (413, 1 ảnh vượt 10MB) · TOO_MANY_IMAGES (400, quá 10 ảnh/request) · INVALID_IMAGE (400, sai định dạng) · MISSING_IMAGE (400, thiếu field) · INVALID_MULTIPART (400, body không phải multipart) · STORAGE_NOT_CONFIGURED (503, BE chưa cấu hình R2 — chỉ gặp ở môi trường dev).
Payment webhook (BE nhận từ SePay — FE KHÔNG gọi)
POST /api/v1/webhooks/sepay — endpoint cho SePay bắn biến động số dư ngân hàng, không phải cho FE. Auth bằng header Authorization: Apikey <SEPAY_API_KEY>; gửi thành công ⟺ BE trả HTTP 200/201 kèm body {"success": true} (các lỗi khác SePay tự retry, tối đa 7 lần).
Tiền vào (transferType = "in") được tách mã đơn GB-XXXXXX từ nội dung chuyển khoản rồi phân nhánh theo trạng thái đơn:
| Trạng thái đơn khi tiền về | Kết quả |
|---|---|
RESERVED | Tạo payment DEPOSIT → đơn DEPOSIT_HELD (chuyển thiếu vẫn giữ đơn, ghi cảnh báo đối soát) |
SETTLEMENT_DUE | Tạo payment SETTLEMENT, cộng dồn settlement_paid; khi ≥ amount_due → đơn CONFIRMED. Chuyển thừa được ghi nhận, không lỗi |
- Idempotent theo transaction id của SePay (
webhook_events.idempotency_key = "sepay:<tx_id>") — retry/resend không bị double-process. - Giao dịch không chứa mã đơn / mã không tồn tại / đơn sai trạng thái: vẫn lưu vào
webhook_eventsđể đối soát nhưng ack 200 (retry cũng vô ích). - User chuyển phần còn lại bằng cùng nội dung CK
GB-XXXXXXnhư lúc cọc.
5 · ERROR CODES (FE cần biết)
| Code | HTTP | Ý nghĩa |
|---|---|---|
INVALID_CREDENTIALS | 401 | Email/password sai |
EMAIL_EXISTS | 409 | Email đã đăng ký |
UNAUTHORIZED | 401 | Token thiếu/sai/hết hạn |
FORBIDDEN | 403 | Không đủ quyền (cần admin, hoặc không phải chủ đơn) |
NOT_FOUND | 404 | Campaign/order không tồn tại |
INVALID_CAMPAIGN_STATE | 409 | Campaign không ở trạng thái cho phép (message kèm status thật, vd campaign is CLOSED, must be OPEN) |
SLUG_EXISTS | 409 | Title tạo ra slug đã có campaign khác dùng — đổi title |
SLOT_FULL | 409 | Hết slot |
INVALID_ORDER_STATE | 409 | Đơn không ở trạng thái cho phép (message kèm trạng thái thật của đơn) |
NO_DEPOSITS | 409 | Settle campaign khi chưa có đơn cọc nào |
VALIDATION_FAILED | 400 | Body không hợp lệ. Message là danh sách field dễ đọc, vd title is required; tiers[0].qty_threshold must be at least 1 |
INVALID_JSON | 400 | Body không parse được JSON (gồm cả field thời gian sai định dạng RFC3339, vd closes_at thiếu múi giờ) |
INVALID_TIER | 400 | Tiers không hợp lệ: tạo/sửa thì qty_threshold không tăng dần nghiêm ngặt; settle thì tổng qty chưa đạt ngưỡng tier thấp nhất (message kèm số liệu) |
INVALID_MULTIPART | 400 | Upload ảnh nhưng body không phải multipart/form-data |
MISSING_IMAGE | 400 | Upload thiếu file field image |
TOO_MANY_IMAGES | 400 | Upload gallery vượt quá 10 ảnh trong 1 request |
INVALID_IMAGE | 400 | File không phải ảnh hợp lệ (chỉ nhận jpeg/png/webp/gif) |
IMAGE_TOO_LARGE | 413 | Ảnh vượt quá 10 MB |
STORAGE_NOT_CONFIGURED | 503 | BE chưa cấu hình storage R2 (môi trường dev chưa có R2 env) |
INTERNAL | 500 | Lỗi server. Message luôn là internal server error — nguyên nhân thật nằm ở log Grafana/Loki (lọc theo trace_id trong header response X-Trace-Id) |
6 · CORS + TRACE ID
Hiện BE cho phép * (mọi origin) để dev. Khi FE có domain thật, sẽ siết lại đúng domain đó. Nếu FE gọi bị CORS lỗi, báo BE để thêm origin.
Trace xuyên suốt (đã bật): FE tự sinh header traceparent chuẩn W3C cho mọi request (1 trace-id / phiên theo tab, span-id mới mỗi request — xem src/lib/api.ts của storefront/admin). BE extract và nối toàn bộ span vào cùng 1 trace trên Tempo. BE trả về header X-Trace-Id (đã expose qua CORS) — dùng để hiện cho user khi báo lỗi hoặc tra nhanh trên trang ops /trace/<id>.
7 · VÍ DỤ LUỒNG ĐẦY ĐỦ (FE)
js
const BASE = "https://gghut-core-24817855140.asia-southeast1.run.app/api/v1";
// 1. Đăng ký / đăng nhập → lưu token
const { data } = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}).then(r => r.json());
saveTokens(data.access_token, data.refresh_token);
// 2. Xem danh sách campaign đang mở
const campaigns = await fetch(`${BASE}/campaigns?status=OPEN`).then(r => r.json());
// 3. Đặt slot (cần auth)
const order = await fetch(`${BASE}/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify({ campaign_id: 1, qty: 2 }),
}).then(r => r.json());
// → order.data.payment.qr_url = link ảnh QR (nhúng <img src>), content = mã đơn GB-XXXXXX8 · LƯU Ý
- Tiền tệ: mọi amount là VND số nguyên (không có số thập phân).
- Slot giữ 15 phút: sau khi đặt (
RESERVED), user phải cọc trong 15 phút, không thì đơn tựEXPIREDvà slot được trả lại. FE nên đếm ngược từexpires_at. - Đặt cọc: Phase 0 cọc qua chuyển khoản — BE sinh sẵn QR (vietqr.app) trong
order.payment, SePay webhook xác nhận tự động khi tiền về. FE nhúngpayment.qr_urlvà nhắc user giữ nguyên nội dung CK. Sau settle, trang thanh toán/lịch sử mua hiển thịamount_due(phần còn lại) — user chuyển tiếp với cùngorder_code. - MOQ & campaign FAILED: MOQ =
qty_thresholdtier thấp nhất, set từ lúc tạo và hiển thị luôn trên storefront. Hết giờ không đạt MOQ → campaignFAILED, đơn cọc vào hàng hoàn (REFUND_PENDING). Spec đầy đủ:docs/groupbuy-flow.md. - Realtime: Phase 0 chưa có SSE/websocket. Trang detail storefront poll
GET /campaigns/{slug}mỗi 15–30s (dừng khi tab ẩn) để cập nhật giá theo mốc/progress; list page dùng field computed (current_unit_price,moq_reached) từ list endpoint, không fetch detail từng card.