317 lines
34 KiB
Markdown
317 lines
34 KiB
Markdown
---
|
||
section: "04"
|
||
title: Thiết kế API
|
||
status: approved
|
||
version: 3
|
||
reviewer_notes: ""
|
||
---
|
||
|
||
# 4. Thiết kế API (API Design)
|
||
|
||
> Phạm vi & style: theo mục 3.1, hệ thống dùng kiến trúc "modular microservices" theo bounded-context, giao tiếp đồng bộ giữa client và backend qua **REST/HTTPS** (JSON), giao tiếp nội bộ giữa service qua event (Kafka/MSK) — không thuộc phạm vi đặc tả API công khai ở mục này. Không có yêu cầu Partner/Public API cho bên thứ ba trong phạm vi MVP (brief không đề cập đối tác tích hợp ngoài VNPay/Momo/GHN/GHTK/OAuth, và các bên này được hệ thống gọi ra — không phải bên ngoài gọi vào), nên không thiết kế cơ chế API key cấp cho đối tác/public developer portal; toàn bộ endpoint dưới đây phục vụ 3 nhóm client nội bộ: **Web Storefront (Guest/Customer)**, **Seller Portal**, **Admin/Ops/CSR Backoffice**, đi qua **API Gateway/BFF** (Customer BFF, Seller BFF, Admin BFF — theo mục 3.2).
|
||
>
|
||
> Tên entity trong request/response tham chiếu đúng Glossary mục 1.3 (`Product`, `ProductVariant`, `Category`, `Cart`, `CartItem`, `Order`, `OrderItem`, `Payment`, `Shipment`, `ReturnRequest`, `Dispute`, `Promotion`, `Review`, `Notification`, `CommissionRule`, `Payout`, `KYCDocument`, `LoyaltyAccount`, `LoyaltyTransaction`, `MembershipTier`, `Wishlist`, `Currency`, `Language`). Không thiết kế bảng CSDL ở mục này (xem mục 5).
|
||
|
||
## 4.1 Đặc tả API
|
||
|
||
### 4.1.1 Quy ước chung
|
||
|
||
- **Base path:** `https://api.<domain>/v1/...` — tất cả endpoint dưới đây ngầm định tiền tố `/v1` (xem 4.3 Versioning).
|
||
- **Định dạng:** JSON (`Content-Type: application/json`); upload tài liệu KYC dùng `multipart/form-data`.
|
||
- **Đa ngôn ngữ (FR-15):** mọi endpoint hỗ trợ header `Accept-Language: vi-VN|en-US|zh-CN|ko-KR|ja-JP` (mặc định `vi-VN`); các trường nội dung đa ngôn ngữ (tên sản phẩm, mô tả, nội dung thông báo) trả về theo ngôn ngữ yêu cầu, fallback về `vi-VN` nếu thiếu bản dịch. Đây là năng lực cross-cutting áp dụng toàn bộ API, không phải endpoint/service riêng (khớp ghi chú mục 3.1).
|
||
- **Đa tiền tệ (FR-16):** mọi response có trường giá đều trả về `priceVnd` (giá giao dịch thật, VND) kèm `displayPrices[]` (mảng quy đổi tham khảo theo `Currency`) khi client gửi header `X-Display-Currency`; **không** có endpoint giao dịch bằng ngoại tệ (khớp brief — chỉ hiển thị quy đổi tham khảo).
|
||
- **Khách vãng lai (Guest):** các endpoint Cart/Checkout hỗ trợ định danh qua `X-Guest-Session-Id` thay cho JWT, cho phép FR-05/FR-06 hoạt động không cần đăng nhập. Giá trị `X-Guest-Session-Id` **phải** được sinh phía server bằng CSPRNG (cryptographically secure random) với entropy **tối thiểu 128-bit** (VD UUIDv4 sinh bằng CSPRNG, hoặc chuỗi random ≥16 byte mã hoá base64url); truyền cho client qua cookie `HttpOnly; Secure; SameSite=Lax` (không dùng `localStorage` — tránh lộ giá trị qua XSS), TTL tối đa 30 ngày không hoạt động. Toàn bộ endpoint **ghi** dữ liệu Cart cho Guest (`POST/PATCH/DELETE /v1/cart/items`, `POST /v1/cart/apply-coupon`, `POST /v1/checkout` khi không có JWT) áp dụng **rate limit riêng theo IP nguồn** (VD 30 req/phút/IP) ngoài giới hạn theo session, để chống lạm dụng khi chưa có định danh JWT (chi tiết rate limiting tại 4.2).
|
||
- **Quy tắc ownership (chống IDOR):** với mọi endpoint có tham số định danh tài nguyên trong path (VD `{orderId}`, `{shipmentId}`, `{returnRequestId}`, `{paymentId}`, ...) mà tài nguyên gắn với một `Customer`/`Seller` cụ thể, tầng Gateway/BFF hoặc service xử lý **bắt buộc** đối chiếu tài nguyên đó thuộc về `sub`/`customerId`/`sellerId` trong JWT của caller trước khi trả dữ liệu — **trừ khi** caller có scope `admin:*`/`ops:*`/`csr:*` được thiết kế truy cập toàn cục cho nhóm tài nguyên đó (ghi rõ theo từng endpoint tại 4.1.5–4.1.8, 4.1.12). Không khớp ownership → `403 ERR_FORBIDDEN_OWNERSHIP` (phân biệt với `403 ERR_FORBIDDEN_SCOPE` khi thiếu quyền/scope, xem 4.1.13).
|
||
- **Phân trang:** query `?page=&pageSize=` (mặc định `pageSize=20`, tối đa `100`), response bọc trong `{ "data": [...], "pagination": { "page", "pageSize", "totalItems" } }`.
|
||
- **Idempotency:** các endpoint ghi tiền (checkout, payment, payout, đổi điểm loyalty) yêu cầu header `Idempotency-Key` để tránh xử lý trùng khi client retry.
|
||
|
||
### 4.1.2 Cross-cutting config (FR-15, FR-16)
|
||
|
||
| Method | Path | Mô tả | FR | Response tóm tắt |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/config/languages` | Danh sách ngôn ngữ hỗ trợ và ngôn ngữ mặc định | FR-15 | `[{code:"vi",name:"Tiếng Việt",isDefault:true}, ...]` |
|
||
| GET | `/v1/config/currencies` | Danh sách tiền tệ hiển thị tham khảo và tỷ giá quy đổi hiện hành (nguồn: cấu hình tại Catalog & Inventory Service) | FR-16 | `[{code:"USD",rateToVnd:25400,updatedAt}, ...]` |
|
||
|
||
### 4.1.3 Identity & Access Service (FR-01, FR-02, FR-03, FR-27)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| POST | `/v1/auth/register` | Customer đăng ký tài khoản bằng email/password | FR-01 | Không |
|
||
| POST | `/v1/auth/login` | Đăng nhập email/password; trả `mfaRequired:true` nếu tài khoản Admin/Seller đã bật MFA | FR-01, FR-27 | Không |
|
||
| POST | `/v1/auth/mfa/challenge` | Xác minh mã OTP (TOTP/SMS) bước 2 sau `login`, trả access/refresh token khi thành công | FR-27 | Mã thách thức tạm (challenge token) |
|
||
| POST | `/v1/auth/mfa/enroll` | Bật MFA cho tài khoản Seller/Admin đang đăng nhập | FR-27 | Bearer JWT |
|
||
| POST | `/v1/auth/oauth/{provider}/callback` | Xử lý callback OAuth2 (`provider=google\|facebook`); xác thực tham số `state` (chống CSRF) khớp giá trị đã phát hành khi khởi tạo luồng OAuth — từ chối (`400 ERR_OAUTH_STATE_INVALID`) nếu thiếu/không khớp; nếu email do provider trả về đã có tài khoản Customer đăng ký sẵn bằng email/password, **không tự động liên kết (no auto-merge)** — trả `409 ERR_ACCOUNT_LINK_REQUIRED` và yêu cầu xác minh sở hữu email (gửi mã xác minh tới email đã đăng ký) trước khi cho phép liên kết tài khoản OAuth; nếu email chưa tồn tại, tạo tài khoản Customer mới liên kết provider | FR-02 | Không (redirect flow); tham số `state` bắt buộc |
|
||
| POST | `/v1/auth/refresh` | Cấp access token mới từ refresh token | FR-01 | Refresh token |
|
||
| POST | `/v1/auth/logout` | Thu hồi refresh token hiện tại | FR-01 | Bearer JWT |
|
||
| GET | `/v1/customers/me` | Xem hồ sơ cá nhân Customer đang đăng nhập | FR-03 | Bearer JWT (scope `customer:profile:read`) |
|
||
| PATCH | `/v1/customers/me` | Cập nhật hồ sơ (tên, số điện thoại, ngôn ngữ ưu tiên) | FR-03 | Bearer JWT (scope `customer:profile:write`) |
|
||
| GET | `/v1/customers/me/addresses` | Danh sách địa chỉ giao hàng | FR-03 | Bearer JWT |
|
||
| POST | `/v1/customers/me/addresses` | Thêm địa chỉ giao hàng mới | FR-03 | Bearer JWT |
|
||
| PUT | `/v1/customers/me/addresses/{addressId}` | Cập nhật địa chỉ | FR-03 | Bearer JWT |
|
||
| DELETE | `/v1/customers/me/addresses/{addressId}` | Xoá địa chỉ | FR-03 | Bearer JWT |
|
||
|
||
**Ví dụ — POST `/v1/auth/login`**
|
||
```json
|
||
// Request
|
||
{ "email": "customer@example.com", "password": "********" }
|
||
|
||
// Response 200 (không MFA)
|
||
{ "accessToken": "eyJ...", "refreshToken": "eyJ...", "expiresIn": 3600 }
|
||
|
||
// Response 200 (tài khoản Admin/Seller đã bật MFA)
|
||
{ "mfaRequired": true, "mfaChallengeToken": "chal_abc123", "mfaMethod": "TOTP" }
|
||
```
|
||
|
||
### 4.1.4 Catalog & Inventory Service + Search subsystem (FR-04, FR-10, FR-18, FR-24)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/categories` | Cây danh mục ngành hàng (`Category`) | FR-04 | Không |
|
||
| GET | `/v1/products` | Duyệt/lọc `Product` (theo `Category`, seller, khoảng giá, rating) — đọc qua Search subsystem (OpenSearch) | FR-04 | Không |
|
||
| GET | `/v1/search/products?q=` | Tìm kiếm full-text sản phẩm | FR-04 | Không |
|
||
| GET | `/v1/products/{productId}` | Chi tiết `Product` kèm danh sách `ProductVariant` | FR-04 | Không |
|
||
| GET | `/v1/customers/me/wishlist` | Danh sách `Wishlist` của Customer | FR-10 | Bearer JWT |
|
||
| POST | `/v1/customers/me/wishlist` | Thêm `Product` vào `Wishlist` | FR-10 | Bearer JWT |
|
||
| DELETE | `/v1/customers/me/wishlist/{productId}` | Bỏ khỏi `Wishlist` | FR-10 | Bearer JWT |
|
||
| GET | `/v1/seller/products` | Seller xem danh sách `Product` của gian hàng mình | FR-18 | Bearer JWT (scope `seller:catalog:write`) |
|
||
| POST | `/v1/seller/products` | Seller tạo `Product` mới (kèm `ProductVariant`) | FR-18 | Bearer JWT (scope `seller:catalog:write`) |
|
||
| PUT | `/v1/seller/products/{productId}` | Cập nhật thông tin `Product` | FR-18 | Bearer JWT (scope `seller:catalog:write`) |
|
||
| PATCH | `/v1/seller/products/{productId}/variants/{variantId}/inventory` | Cập nhật tồn kho/giá `ProductVariant` | FR-18 | Bearer JWT (scope `seller:catalog:write`) |
|
||
| GET | `/v1/admin/products` | Admin tra cứu toàn bộ `Product` trên sàn (giám sát) | FR-24 | Bearer JWT (scope `admin:catalog:read`) |
|
||
| PATCH | `/v1/admin/products/{productId}/status` | Admin ẩn/gỡ `Product` vi phạm (`status: hidden\|removed`) | FR-24 | Bearer JWT (scope `admin:catalog:write`) |
|
||
|
||
### 4.1.5 Cart & Order Service — bao gồm Dispute handling (FR-05, FR-06, FR-08, FR-09, FR-19, FR-25)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/cart` | Xem `Cart` hiện tại (đa seller) | FR-05 | Bearer JWT hoặc `X-Guest-Session-Id` |
|
||
| POST | `/v1/cart/items` | Thêm `CartItem` (sản phẩm của bất kỳ seller nào) vào `Cart` | FR-05 | Bearer JWT hoặc `X-Guest-Session-Id` |
|
||
| PATCH | `/v1/cart/items/{cartItemId}` | Cập nhật số lượng `CartItem` | FR-05 | Bearer JWT hoặc `X-Guest-Session-Id` |
|
||
| DELETE | `/v1/cart/items/{cartItemId}` | Xoá `CartItem` | FR-05 | Bearer JWT hoặc `X-Guest-Session-Id` |
|
||
| POST | `/v1/cart/apply-coupon` | Áp mã `Promotion` (coupon) vào `Cart` trước khi checkout | FR-13 | Bearer JWT hoặc `X-Guest-Session-Id` |
|
||
| POST | `/v1/checkout` | Tạo `Order` từ `Cart`; hệ thống tự tách thành các `Order` con theo từng seller | FR-06 | Bearer JWT hoặc `X-Guest-Session-Id`; header `Idempotency-Key` bắt buộc |
|
||
| GET | `/v1/orders` | Danh sách `Order` của Customer đang đăng nhập | FR-08 | Bearer JWT |
|
||
| GET | `/v1/orders/{orderId}` | Chi tiết `Order` (bao gồm `OrderItem`, `Shipment`, `Payment`) | FR-08 | Bearer JWT (chủ đơn — `customerId` trong JWT phải khớp `Order.customerId`; không khớp → `403 ERR_FORBIDDEN_OWNERSHIP`) |
|
||
| POST | `/v1/orders/{orderId}/cancel` | Huỷ `Order` (chỉ khi trạng thái cho phép) | FR-08 | Bearer JWT (chủ đơn — cùng quy tắc ownership như GET `/v1/orders/{orderId}`) |
|
||
| POST | `/v1/orders/{orderId}/return-requests` | Tạo `ReturnRequest` cho `Order` đã giao | FR-09 | Bearer JWT (chủ đơn — cùng quy tắc ownership như GET `/v1/orders/{orderId}`) |
|
||
| GET | `/v1/orders/{orderId}/return-requests/{returnRequestId}` | Xem trạng thái `ReturnRequest` | FR-09 | Bearer JWT (chủ đơn — ownership như trên) **hoặc** CSR/Admin (scope `csr:disputes:read`/`admin:*`, truy cập toàn cục theo thiết kế, không áp dụng kiểm tra ownership) |
|
||
| GET | `/v1/seller/orders` | Seller xem danh sách `Order` con thuộc gian hàng mình | FR-19 | Bearer JWT (scope `seller:orders:read`; kết quả tự động lọc theo `sellerId` trong JWT, không nhận `sellerId` qua query param — chống IDOR) |
|
||
| PATCH | `/v1/seller/orders/{orderId}/status` | Seller cập nhật trạng thái xử lý `Order` (xác nhận, chuẩn bị hàng) | FR-19 | Bearer JWT (scope `seller:orders:write`; `sellerId` trong JWT phải khớp seller sở hữu `Order`/`OrderItem` tương ứng `orderId`; không khớp → `403 ERR_FORBIDDEN_OWNERSHIP`) |
|
||
| GET | `/v1/admin/disputes` | CSR/Admin xem danh sách `Dispute` cần xử lý (phát sinh từ `ReturnRequest`/khiếu nại) | FR-25 | Bearer JWT (scope `csr:disputes:read` hoặc `admin:disputes:read`; truy cập toàn cục theo thiết kế — không áp dụng kiểm tra ownership vì CSR/Admin xử lý tranh chấp toàn sàn) |
|
||
| GET | `/v1/admin/disputes/{disputeId}` | Chi tiết `Dispute` kèm lịch sử `Order` liên quan | FR-25 | Bearer JWT (scope `csr:disputes:read`; truy cập toàn cục theo thiết kế, không áp dụng kiểm tra ownership) |
|
||
| PATCH | `/v1/admin/disputes/{disputeId}` | CSR/Admin cập nhật quyết định xử lý `Dispute` (hoàn tiền/từ chối/chuyển escalation); khi quyết định là hoàn tiền, hệ thống loại vĩnh viễn khoản hoa hồng liên quan khỏi payout kỳ tới (chuyển `payout_hold.release_status` sang trạng thái kết thúc `reversed`, xem mục 5.2.6/5 và mục 6) | FR-25 | Bearer JWT (scope `csr:disputes:write` hoặc `admin:disputes:write`; truy cập toàn cục theo thiết kế, không áp dụng kiểm tra ownership) |
|
||
|
||
**Ví dụ — POST `/v1/checkout`**
|
||
```json
|
||
// Request
|
||
{
|
||
"cartId": "cart_123",
|
||
"shippingAddressId": "addr_456",
|
||
"paymentMethod": "VNPAY",
|
||
"couponCode": "SALE50"
|
||
}
|
||
|
||
// Response 201
|
||
{
|
||
"parentOrderId": "order_parent_789",
|
||
"orders": [
|
||
{ "orderId": "order_001", "sellerId": "seller_11", "totalAmountVnd": 350000, "status": "PENDING_PAYMENT" },
|
||
{ "orderId": "order_002", "sellerId": "seller_22", "totalAmountVnd": 120000, "status": "PENDING_PAYMENT" }
|
||
],
|
||
"paymentRedirectUrl": "https://sandbox.vnpayment.vn/..."
|
||
}
|
||
```
|
||
|
||
### 4.1.6 Payment Service (FR-07)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| POST | `/v1/payments` | Khởi tạo `Payment` cho một `Order` (VNPay/Momo redirect URL, hoặc xác nhận COD) | FR-07 | Bearer JWT hoặc `X-Guest-Session-Id`; `Idempotency-Key` bắt buộc |
|
||
| GET | `/v1/payments/{paymentId}` | Tra cứu trạng thái `Payment` | FR-07 | Bearer JWT (chủ đơn — `customerId` khớp `Order.customerId` của Payment; không khớp → `403 ERR_FORBIDDEN_OWNERSHIP`) |
|
||
| POST | `/v1/payments/webhooks/vnpay` | Callback/IPN xác nhận giao dịch từ VNPay (nội bộ, không public docs) | FR-07 | Xác thực chữ ký VNPay (checksum), không dùng JWT; chống replay — xem ghi chú bên dưới |
|
||
| POST | `/v1/payments/webhooks/momo` | Callback/IPN xác nhận giao dịch từ Momo | FR-07 | Xác thực chữ ký Momo; chống replay — xem ghi chú bên dưới |
|
||
|
||
> **Chống replay cho toàn bộ webhook bên thứ ba** (`vnpay`, `momo`, `ghn`, `ghtk` — xem thêm 4.1.12): ngoài xác thực chữ ký/token của bên gửi, mỗi webhook **bắt buộc**: (1) kiểm tra trường timestamp có trong payload gốc của gateway — **từ chối** (`400 ERR_VALIDATION`, không xử lý) nếu lệch quá **5 phút** so với giờ hệ thống nhận; (2) áp dụng **idempotency theo `gatewayTransactionRef`** (mã giao dịch/mã vận đơn phía gateway, lưu kèm trạng thái đã xử lý) — nếu đã ghi nhận cùng `gatewayTransactionRef` trước đó, trả `200 OK` mà **không** xử lý lại nghiệp vụ (không tạo side-effect lần 2), tránh trùng khi gateway tự động retry hợp lệ. Hai lớp này kết hợp chống tấn công phát lại (replay) payload cũ hợp lệ chữ ký lẫn duplicate delivery thông thường.
|
||
|
||
### 4.1.7 Seller Management Service (FR-17, FR-20, FR-23)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| POST | `/v1/sellers/register` | Seller tự đăng ký gian hàng | FR-17 | Không (tạo tài khoản mới) hoặc Bearer JWT nếu nâng cấp từ Customer |
|
||
| POST | `/v1/sellers/{sellerId}/kyc-documents` | Upload `KYCDocument` (giấy phép kinh doanh/CMND), `multipart/form-data` | FR-17 | Bearer JWT (chủ seller) |
|
||
| GET | `/v1/sellers/{sellerId}/kyc-status` | Seller xem trạng thái duyệt KYC | FR-17 | Bearer JWT (chủ seller) |
|
||
| GET | `/v1/admin/sellers` | Admin danh sách seller (lọc theo trạng thái KYC/hoạt động) | FR-23 | Bearer JWT (scope `admin:sellers:read`) |
|
||
| PATCH | `/v1/admin/sellers/{sellerId}/kyc-review` | Admin duyệt/từ chối `KYCDocument` (`status: approved\|rejected`, `reason`) | FR-17 | Bearer JWT (scope `admin:sellers:write`) |
|
||
| PATCH | `/v1/admin/sellers/{sellerId}/status` | Admin khoá/mở khoá tài khoản Seller | FR-23 | Bearer JWT (scope `admin:sellers:write`) |
|
||
| GET | `/v1/seller/dashboard/summary` | Seller xem tóm tắt doanh thu, hoa hồng, trạng thái `Payout` | FR-20 | Bearer JWT (scope `seller:reports:read`) |
|
||
|
||
### 4.1.8 Commission & Payout Service (FR-21, FR-22)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/admin/commission-rules` | Danh sách `CommissionRule` theo `Category`, kèm `holdDays` (số ngày giữ tiền payout riêng cho ngành hàng — BR-04) | FR-21 | Bearer JWT (scope `admin:commission:read`) |
|
||
| PUT | `/v1/admin/commission-rules/{categoryId}` | Admin cấu hình/chỉnh % hoa hồng và `holdDays` cho một `Category` | FR-21 | Bearer JWT (scope `admin:commission:write`) |
|
||
| GET | `/v1/seller/payouts` | Seller xem lịch sử/trạng thái `Payout` của mình | FR-22 | Bearer JWT (scope `seller:payouts:read`; kết quả tự động lọc theo `sellerId` trong JWT, không nhận `sellerId` qua query param — chống IDOR) |
|
||
| GET | `/v1/admin/payouts` | Admin giám sát toàn bộ `Payout` theo kỳ (hàng tuần) | FR-22 | Bearer JWT (scope `admin:payouts:read`; truy cập toàn cục theo thiết kế) |
|
||
| POST | `/v1/admin/payouts/{payoutId}/retry` | Admin yêu cầu thử lại `Payout` thất bại (không tự động, theo mục 3.4) | FR-22 | Bearer JWT (scope `admin:payouts:write`; truy cập toàn cục theo thiết kế) |
|
||
|
||
**Ví dụ — GET `/v1/admin/commission-rules`**
|
||
```json
|
||
// Response 200
|
||
{
|
||
"data": [
|
||
{ "categoryId": "cat_electronics", "commissionPercent": 8.5, "holdDays": 7, "effectiveFrom": "2026-10-01", "updatedBy": "admin_01" },
|
||
{ "categoryId": "cat_fashion", "commissionPercent": 10, "holdDays": null, "effectiveFrom": "2026-09-01", "updatedBy": "admin_02" }
|
||
],
|
||
"pagination": { "page": 1, "pageSize": 20, "totalItems": 2 }
|
||
}
|
||
```
|
||
|
||
**Ví dụ — PUT `/v1/admin/commission-rules/{categoryId}`**
|
||
```json
|
||
// Request
|
||
{ "commissionPercent": 8.5, "holdDays": 7, "effectiveFrom": "2026-10-01" }
|
||
|
||
// Response 200
|
||
{ "categoryId": "cat_electronics", "commissionPercent": 8.5, "holdDays": 7, "effectiveFrom": "2026-10-01", "updatedBy": "admin_01" }
|
||
```
|
||
|
||
> `holdDays` (integer, nullable, khuyến nghị **3-7**): số ngày giữ tiền payout riêng cho `Category` này sau khi `Order` giao hàng thành công, theo BR-04. Nếu `null`/không truyền, hệ thống áp dụng mặc định toàn sàn **5 ngày** (khớp `commission_rule.hold_days` mục 5.2.6). Validation: nếu có giá trị, `422 ERR_BUSINESS_RULE` khi ngoài khoảng 3-7 (cảnh báo, vẫn cho phép admin override có xác nhận theo BR-04, ghi log audit).
|
||
|
||
### 4.1.9 Promotion & Loyalty Service (FR-13, FR-14)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/admin/promotions` | Danh sách `Promotion` (coupon) | FR-13 | Bearer JWT (scope `admin:promotions:read`) |
|
||
| POST | `/v1/admin/promotions` | Tạo `Promotion` mới | FR-13 | Bearer JWT (scope `admin:promotions:write`) |
|
||
| PUT | `/v1/admin/promotions/{promotionId}` | Cập nhật `Promotion` | FR-13 | Bearer JWT (scope `admin:promotions:write`) |
|
||
| GET | `/v1/customers/me/loyalty` | Xem `LoyaltyAccount` (điểm hiện có, `MembershipTier`) | FR-14 | Bearer JWT |
|
||
| GET | `/v1/customers/me/loyalty/transactions` | Lịch sử `LoyaltyTransaction` (tích/đổi điểm) | FR-14 | Bearer JWT |
|
||
| POST | `/v1/customers/me/loyalty/redeem` | Đổi điểm thưởng thành giảm giá áp cho `Cart`/`Order` | FR-14 | Bearer JWT; header `Idempotency-Key` bắt buộc |
|
||
|
||
### 4.1.10 Review Service (FR-11)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/products/{productId}/reviews` | Danh sách `Review` của một `Product` | FR-11 | Không |
|
||
| POST | `/v1/products/{productId}/reviews` | Customer tạo `Review` (chỉ khi đã mua và `Order` đã giao) | FR-11 | Bearer JWT |
|
||
|
||
### 4.1.11 Notification Service (FR-12)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/customers/me/notifications` | Lịch sử `Notification` đã gửi cho Customer (in-app) | FR-12 | Bearer JWT |
|
||
| GET | `/v1/customers/me/notification-preferences` | Xem tuỳ chọn nhận thông báo (email/SMS) | FR-12 | Bearer JWT |
|
||
| PATCH | `/v1/customers/me/notification-preferences` | Cập nhật tuỳ chọn nhận thông báo | FR-12 | Bearer JWT |
|
||
| GET | `/v1/admin/notifications/{notificationId}` | Ops/Admin tra cứu trạng thái gửi `Notification` (phục vụ xử lý sự cố dead-letter, theo mục 3.4) | FR-12 | Bearer JWT (scope `admin:notifications:read`) |
|
||
|
||
> Lưu ý: luồng gửi chính của `Notification` (email/SMS xác nhận đơn hàng, cập nhật giao hàng) được kích hoạt bất đồng bộ qua event nội bộ (`OrderPlaced`, `PaymentConfirmed`, ...) theo mục 3.2, không qua REST API công khai; các endpoint trên chỉ phục vụ tra cứu/tuỳ chọn.
|
||
|
||
### 4.1.12 Shipping & Fulfillment Service (FR-26)
|
||
|
||
| Method | Path | Mô tả | FR | Auth |
|
||
|---|---|---|---|---|
|
||
| GET | `/v1/ops/orders/{orderId}/fulfillment` | Ops xem thông tin đóng gói/tồn kho cần xử lý cho `Order` | FR-26 | Bearer JWT (scope `ops:fulfillment:read`; giới hạn theo đơn hàng/gian hàng được phân công, xem 4.2) |
|
||
| PATCH | `/v1/ops/orders/{orderId}/fulfillment` | Ops cập nhật trạng thái đóng gói | FR-26 | Bearer JWT (scope `ops:fulfillment:write`; giới hạn theo đơn hàng/gian hàng được phân công, xem 4.2) |
|
||
| POST | `/v1/ops/shipments` | Tạo `Shipment` (gọi API tạo vận đơn GHN/GHTK) | FR-26 | Bearer JWT (scope `ops:fulfillment:write`) |
|
||
| GET | `/v1/shipments/{shipmentId}/tracking` | Customer/Seller/Ops/Admin tra cứu trạng thái vận chuyển `Shipment` | FR-26 | Bearer JWT (chủ đơn hàng liên quan — `customerId` khớp `Order.customerId` của `Order` gắn với `Shipment`; **hoặc** `sellerId` khớp seller của `order_seller`/`OrderItem` liên quan đến `Shipment`; **hoặc** scope `ops:fulfillment:read`/`admin:*` truy cập toàn cục; không khớp → `403 ERR_FORBIDDEN_OWNERSHIP`) |
|
||
| POST | `/v1/webhooks/ghn` | Webhook cập nhật trạng thái từ GHN | FR-26 | Xác thực chữ ký/token GHN; chống replay — xem ghi chú tại 4.1.6 (áp dụng đồng thời) |
|
||
| POST | `/v1/webhooks/ghtk` | Webhook cập nhật trạng thái từ GHTK | FR-26 | Xác thực chữ ký/token GHTK; chống replay — xem ghi chú tại 4.1.6 (áp dụng đồng thời) |
|
||
|
||
### 4.1.13 Mã lỗi chuẩn hoá
|
||
|
||
Định dạng lỗi thống nhất toàn hệ thống (mọi service qua API Gateway):
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "ERR_VALIDATION",
|
||
"message": "Trường 'quantity' phải lớn hơn 0",
|
||
"details": [ { "field": "quantity", "reason": "must_be_positive" } ]
|
||
},
|
||
"traceId": "req_9f8a7b6c"
|
||
}
|
||
```
|
||
|
||
| HTTP Status | Mã lỗi nội bộ | Ý nghĩa | Áp dụng ví dụ |
|
||
|---|---|---|---|
|
||
| 400 | `ERR_VALIDATION` | Dữ liệu đầu vào không hợp lệ | Thiếu trường bắt buộc, sai định dạng |
|
||
| 400 | `ERR_OAUTH_STATE_INVALID` | Tham số `state` của callback OAuth thiếu hoặc không khớp giá trị đã phát hành (nghi CSRF) | Callback `/v1/auth/oauth/{provider}/callback` giả mạo/không có `state` hợp lệ |
|
||
| 401 | `ERR_AUTH_REQUIRED` | Thiếu token xác thực | Gọi endpoint yêu cầu JWT mà không có header |
|
||
| 401 | `ERR_AUTH_INVALID_TOKEN` | Token hết hạn/không hợp lệ | Access token expired |
|
||
| 401 | `ERR_MFA_REQUIRED` | Cần hoàn tất bước MFA | Login Admin/Seller đã bật MFA nhưng chưa xác minh OTP |
|
||
| 403 | `ERR_FORBIDDEN_SCOPE` | Token hợp lệ nhưng thiếu quyền/scope | Seller gọi endpoint `admin:*` |
|
||
| 403 | `ERR_FORBIDDEN_OWNERSHIP` | Token hợp lệ, đủ scope, nhưng tài nguyên không thuộc về `customerId`/`sellerId` của caller (IDOR) | Customer A gọi `GET /v1/shipments/{shipmentId}/tracking` của đơn hàng thuộc Customer B |
|
||
| 404 | `ERR_NOT_FOUND` | Tài nguyên không tồn tại | `productId` không tồn tại |
|
||
| 409 | `ERR_CONFLICT` | Xung đột trạng thái/dữ liệu | Trùng email khi đăng ký, tồn kho không đủ khi checkout |
|
||
| 409 | `ERR_ACCOUNT_LINK_REQUIRED` | Email trả về từ OAuth trùng tài khoản email/password đã có, cần xác minh sở hữu trước khi liên kết | Đăng nhập Google với email đã đăng ký thủ công trước đó |
|
||
| 422 | `ERR_BUSINESS_RULE` | Vi phạm quy tắc nghiệp vụ | Huỷ đơn khi trạng thái không cho phép, coupon hết hạn, `holdDays` ngoài khoảng khuyến nghị 3-7 |
|
||
| 429 | `ERR_RATE_LIMITED` | Vượt giới hạn tần suất gọi | Bot gọi liên tục `/checkout` mùa flash sale |
|
||
| 502 | `ERR_UPSTREAM_UNAVAILABLE` | Dịch vụ bên thứ ba không phản hồi | VNPay/Momo/GHN/GHTK timeout (xem mục 3.4) |
|
||
| 503 | `ERR_SERVICE_UNAVAILABLE` | Service nội bộ tạm thời quá tải/bảo trì | Circuit breaker mở khi downstream lỗi |
|
||
| 500 | `ERR_INTERNAL` | Lỗi hệ thống không xác định | Exception chưa được xử lý |
|
||
|
||
## 4.2 Xác thực & phân quyền API
|
||
|
||
- **Cơ chế:** OAuth2-style **JWT Bearer token** (access token TTL ngắn ~15-60 phút + refresh token TTL dài ~7-30 ngày), phát hành bởi **Identity & Access Service**, xác thực tại tầng **API Gateway/BFF** trước khi route tới service nội bộ (theo mục 3.2). OAuth2 Authorization Code flow áp dụng riêng cho luồng Google/Facebook social login (FR-02) — tham số `state` bắt buộc để chống CSRF và trường hợp trùng email với tài khoản email/password xử lý theo quy tắc "không auto-merge" tại 4.1.3; không dùng API Key cấp cho đối tác vì không có Public/Partner API trong phạm vi MVP.
|
||
- **Guest:** không cần token cho endpoint duyệt/tìm kiếm sản phẩm; Cart/Checkout dùng `X-Guest-Session-Id` (định danh ẩn danh tạm thời sinh bằng CSPRNG ≥128-bit, cookie `HttpOnly/Secure/SameSite=Lax`, TTL theo phiên — chi tiết tại 4.1.1) thay cho JWT để hỗ trợ guest checkout (FR-05, FR-06) mà không lộ endpoint ghi dữ liệu nhạy cảm cho người chưa xác thực.
|
||
- **Ownership (chống IDOR):** ngoài kiểm tra scope, mọi endpoint đọc/ghi theo ID tài nguyên gắn với một Customer/Seller cụ thể đều kiểm tra khớp `customerId`/`sellerId` trong JWT (quy tắc chi tiết và danh sách endpoint áp dụng tại 4.1.1 và các bảng 4.1.5–4.1.8, 4.1.12); vi phạm trả `403 ERR_FORBIDDEN_OWNERSHIP`.
|
||
- **MFA (FR-27):** bắt buộc với scope `admin:*` (chặn hoàn toàn nếu chưa hoàn tất `mfa/challenge`); khuyến khích (không chặn) với scope `seller:*` — access token phát hành cho Seller chưa bật MFA vẫn hợp lệ nhưng hệ thống nhắc bật qua Seller Portal. Đây là kiểm soát ở tầng API; cơ chế MFA chi tiết (TOTP/SMS provider, chính sách khoá tài khoản) thuộc mục 8.
|
||
- **Scope/permission theo nhóm người dùng** (ánh xạ 1-1 với nhóm actor mục 1.2):
|
||
|
||
| Nhóm người dùng | Scope tiêu biểu | Ghi chú |
|
||
|---|---|---|
|
||
| Guest | (không token) | Chỉ endpoint public + `X-Guest-Session-Id` cho Cart/Checkout |
|
||
| Customer | `customer:profile:read/write`, `customer:orders:read`, `customer:loyalty:read` | Chỉ truy cập dữ liệu của chính mình (kiểm tra `sub` claim khớp `customerId` tài nguyên — xem quy tắc ownership 4.1.1) |
|
||
| Seller | `seller:catalog:write`, `seller:orders:read/write`, `seller:reports:read`, `seller:payouts:read` | Chỉ truy cập dữ liệu gian hàng của chính mình (kiểm tra `sellerId` claim — xem quy tắc ownership 4.1.1) |
|
||
| PlatformAdmin | `admin:*` (catalog, sellers, commission, payouts, promotions, disputes, notifications) | Toàn quyền theo mục 1.2; bắt buộc MFA; các nhóm tài nguyên toàn cục (disputes, payouts giám sát) không áp dụng kiểm tra ownership theo thiết kế |
|
||
| OpsStaff | `ops:fulfillment:read/write` | Giới hạn theo đơn hàng/gian hàng được phân công (kiểm tra assignment, chi tiết RBAC ở mục 8) |
|
||
| CSR | `csr:disputes:read/write`, `customer:orders:read` (read-only hỗ trợ tra cứu) | Không có quyền `write` lên cấu hình hệ thống; truy cập `Dispute` toàn cục theo thiết kế (không áp dụng ownership) |
|
||
|
||
- **Rate limiting (theo NFR-01, NFR-02):** áp dụng tại API Gateway, theo cấp độ:
|
||
- Endpoint đọc nhiều (catalog/search — FR-04): giới hạn rộng (VD 300 req/phút/IP), có cache CDN/Redis phía sau nên hiếm khi chạm ngưỡng.
|
||
- Endpoint ghi nhạy cảm/độ trễ thấp bắt buộc (checkout, payment — FR-06, FR-07): giới hạn chặt hơn theo user/session (VD 20 req/phút) kèm cơ chế hàng đợi (queue) hấp thụ đột biến khi flash sale thay vì từ chối cứng, khớp NFR-02.
|
||
- Endpoint ghi Cart cho Guest (`X-Guest-Session-Id`, chưa có JWT — VD `POST/PATCH/DELETE /v1/cart/items`, `POST /v1/cart/apply-coupon`): giới hạn bổ sung **theo IP nguồn** (VD 30 req/phút/IP), song song với giới hạn theo session, để chống tạo hàng loạt guest session/bot khi chưa có định danh JWT (xem 4.1.1).
|
||
- Endpoint auth (`/auth/login`, `/auth/register`): giới hạn theo IP + captcha/backoff sau N lần thất bại để chống brute-force (bổ sung ở mục 8).
|
||
- Endpoint Admin/Ops/Seller: giới hạn lỏng hơn nhưng đi kèm kiểm soát truy cập mạng (VPN/IP allowlist cho Admin theo mục 3.3), không public internet trực tiếp với Admin Backoffice.
|
||
- Vượt ngưỡng trả `429 ERR_RATE_LIMITED` kèm header `Retry-After`.
|
||
|
||
## 4.3 Quản lý phiên bản API (Versioning)
|
||
|
||
- **Chiến lược:** version hoá theo **path prefix** (`/v1/...`), áp dụng thống nhất tại API Gateway cho toàn bộ service — phù hợp phong cách REST đã chọn ở mục 3.1 và dễ kiểm soát khi từng service phát triển độc lập (mỗi service có thể tăng version nội bộ khác nhịp, nhưng Gateway expose version hợp nhất cho client Web Storefront/Seller Portal/Admin Backoffice).
|
||
- **Không áp dụng** header-based versioning hoặc GraphQL schema versioning — không cần thiết vì chỉ phục vụ client nội bộ do chính đội dự án kiểm soát release (không có bên thứ ba tiêu thụ API theo hợp đồng SLA riêng).
|
||
- **Chính sách deprecation:** khi phát hành `/v2` cho một nhóm endpoint, `/v1` tương ứng được giữ tối thiểu **6 tháng** kèm header `Deprecation: true` và `Sunset: <date>` trong response; thông báo trước cho đội frontend/Seller Portal qua changelog nội bộ ít nhất 1 sprint trước khi khoá `/v1`. Breaking change (đổi cấu trúc response, xoá trường bắt buộc) luôn đi kèm version mới, không sửa trực tiếp trên version đang chạy production.
|
||
- **Không áp dụng — Partner/Public API versioning phức tạp** (API catalog công khai, hợp đồng SLA theo version cho đối tác bên ngoài): brief không xác nhận có đối tác tích hợp API công khai nào ngoài các dịch vụ hệ thống chủ động gọi ra (VNPay/Momo/GHN/GHTK/OAuth), nên không cần cổng thông tin nhà phát triển (developer portal), API key marketplace, hay chính sách billing theo version.
|
||
|
||
## 4.4 Truy vết yêu cầu bổ sung cho mục 2.4
|
||
|
||
| Requirement ID | Endpoint/nhóm endpoint chính |
|
||
|---|---|
|
||
| FR-01 | `/v1/auth/register`, `/v1/auth/login`, `/v1/auth/refresh`, `/v1/auth/logout` |
|
||
| FR-02 | `/v1/auth/oauth/{provider}/callback` |
|
||
| FR-03 | `/v1/customers/me`, `/v1/customers/me/addresses` |
|
||
| FR-04 | `/v1/categories`, `/v1/products`, `/v1/search/products` |
|
||
| FR-05 | `/v1/cart`, `/v1/cart/items` |
|
||
| FR-06 | `/v1/checkout` |
|
||
| FR-07 | `/v1/payments`, `/v1/payments/webhooks/{vnpay,momo}` |
|
||
| FR-08 | `/v1/orders`, `/v1/orders/{orderId}/cancel` |
|
||
| FR-09 | `/v1/orders/{orderId}/return-requests` |
|
||
| FR-10 | `/v1/customers/me/wishlist` |
|
||
| FR-11 | `/v1/products/{productId}/reviews` |
|
||
| FR-12 | `/v1/customers/me/notifications`, `/v1/customers/me/notification-preferences` |
|
||
| FR-13 | `/v1/admin/promotions`, `/v1/cart/apply-coupon` |
|
||
| FR-14 | `/v1/customers/me/loyalty`, `/v1/customers/me/loyalty/transactions`, `/v1/customers/me/loyalty/redeem` |
|
||
| FR-15 | `/v1/config/languages` + header `Accept-Language` (cross-cutting) |
|
||
| FR-16 | `/v1/config/currencies` + header `X-Display-Currency` (cross-cutting) |
|
||
| FR-17 | `/v1/sellers/register`, `/v1/sellers/{sellerId}/kyc-documents`, `/v1/admin/sellers/{sellerId}/kyc-review` |
|
||
| FR-18 | `/v1/seller/products`, `/v1/seller/products/{productId}/variants/{variantId}/inventory` |
|
||
| FR-19 | `/v1/seller/orders`, `/v1/seller/orders/{orderId}/status` |
|
||
| FR-20 | `/v1/seller/dashboard/summary` |
|
||
| FR-21 | `/v1/admin/commission-rules`, `/v1/admin/commission-rules/{categoryId}` (kèm `holdDays`, BR-04) |
|
||
| FR-22 | `/v1/seller/payouts`, `/v1/admin/payouts` |
|
||
| FR-23 | `/v1/admin/sellers`, `/v1/admin/sellers/{sellerId}/status` |
|
||
| FR-24 | `/v1/admin/products`, `/v1/admin/products/{productId}/status` |
|
||
| FR-25 | `/v1/admin/disputes`, `/v1/admin/disputes/{disputeId}` |
|
||
| FR-26 | `/v1/ops/orders/{orderId}/fulfillment`, `/v1/ops/shipments`, `/v1/shipments/{shipmentId}/tracking`, `/v1/webhooks/{ghn,ghtk}` |
|
||
| FR-27 | `/v1/auth/mfa/challenge`, `/v1/auth/mfa/enroll` |
|