Mục lục
- Mục Tiêu Bài Học
- Cursor Pagination Vs Offset — Khi Nào Cursor
- OrderListQuery DTO Với 6 Filter
- Cursor Encode/Decode Helper
- list_orders Function Trong shop-db — Batch Fetch N+1 Avoidance
- Handler list_orders Với Batch + Cursor
- Endpoint User-Scoped Vs Admin
- Verify End-To-End
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Implement
GET /api/v1/ordersendpoint với filterstatus/user_id/date range/total range — 6 filter quaQueryBuilder<Postgres>dynamic SQL (B59 continued). - Implement cursor pagination base64 URL-safe no-pad format
base64(rfc3339_timestamp + "_" + order_id)— encode/decode helper module mớicrates/shop-api/src/cursor.rs. - Avoid N+1 query khi load items + payment cho N order — pattern 3 query batch fetch (list orders + items batch + payments batch) qua
WHERE order_id = ANY($1)+ HashMap groupby Rust. - Define
OrderListQuery8 field DTO (6 filter + cursor + limit) +OrderListResponse4 field (items + next_cursor + has_next + count) — KHÔNG include total count, đặc tính cursor pagination. - Lock pattern reuse cho endpoint user-scoped
/users/me/ordersvs admin/admin/orders(B112 enforce auth). - Workspace dep mới
base64 = "0.22"URL-safe encoding cho query param. - Foundation cho B68 (order detail + state transition), B71 (payment query reuse cursor), G14 (admin analytics dashboard).
Cursor Pagination Vs Offset — Khi Nào Cursor
B61 đã lock decision offset pagination mặc định cho UI 95% (?page=1&per_page=20 + envelope ProductListResponse 5 field items/total/page/per_page/total_pages lock B47) — phù hợp page jump random, hiển thị "trang 5/20" rõ ràng. Cursor pagination dùng cho NDJSON export (B49) và admin stream — phù hợp infinite scroll, list lớn không cần jump page.
Với endpoint GET /api/v1/orders, ba lý do chuyển sang cursor pagination:
- Order count tăng nhanh — 1 user trung bình 10-50 order/năm, power user dropshipping 100-500 order/tháng; chạy 5 năm bảng
ordersdễ đạt 1M+ row toàn hệ thống. OffsetOFFSET 10000trên 1M row tốn ~200-500ms (Postgres phải skip qua 10k row mới tới page cần) — cursor seek(created_at, id) < ($1, $2)chỉ tốn ~5ms với composite index. - UX scroll infinite — UI mobile order history dùng pattern "Xem thêm" tải tiếp 20 đơn cũ; user hiếm khi nhảy page 5 random. Cursor friendly cho infinite scroll: response trả
next_cursoropaque client gửi lại cho request tiếp. - Stable khi data thay đổi — user đang scroll xong page 1 (20 đơn mới nhất) → server đẩy thêm 5 đơn mới (do user khác checkout) → user click "Xem thêm" với offset: thấy 5 đơn vừa hiện đã ở page 1 bị đẩy xuống page 2 trùng kết quả. Cursor không gặp vấn đề: cursor cố định
(created_at_cuối_page_1, id_cuối)luôn cắt đúng vị trí dù data mới chèn vào đầu.
Cursor format Shop API lock vĩnh viễn:
cursor = base64_url_safe_no_pad(rfc3339_timestamp + "_" + order_id)
# Ví dụ cụ thể
created_at = 2026-06-15T10:00:00Z
order_id = 123
raw = "2026-06-15T10:00:00+00:00_123"
encoded = "MjAyNi0wNi0xNVQxMDowMDowMCswMDowMF8xMjM"
# URL-safe no-pad: không có ký tự +/= phải url-encode trong query string
2 ưu cursor: performance constant với composite index seek (không scan tăng theo page number), stable khi data thay đổi giữa lúc scroll. 2 nhược: KHÔNG jump tới page N random (cursor là pointer tuần tự), cursor opaque cho user (không đọc được như ?page=5). Trade-off này hợp với order history — user thực tế không jump page 47/200, chỉ "Xem thêm" lần lượt.
OrderListQuery DTO Với 6 Filter
Cập nhật crates/shop-common/src/dto/order.rs thêm DTO request OrderListQuery + DTO response OrderListResponse:
// File: crates/shop-common/src/dto/order.rs (extend)
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
use super::{Money, OrderResponseDto, UserId};
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct OrderListQuery {
// 6 filter
#[serde(default)]
pub status: Option<String>, // pending|paid|shipped|delivered|cancelled
#[serde(default)]
pub user_id: Option<UserId>, // admin endpoint scope
#[serde(default)]
pub from_date: Option<DateTime<Utc>>,
#[serde(default)]
pub to_date: Option<DateTime<Utc>>,
#[serde(default)]
pub min_total: Option<Decimal>,
#[serde(default)]
pub max_total: Option<Decimal>,
// Cursor pagination
#[serde(default)]
pub cursor: Option<String>, // base64 URL-safe no-pad
#[serde(default = "default_limit")]
#[validate(range(min = 1, max = 100, message = "limit 1-100"))]
pub limit: u32,
}
fn default_limit() -> u32 { 20 }
#[derive(Debug, Clone, Serialize)]
pub struct OrderListResponse {
pub items: Vec<OrderResponseDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub has_next: bool,
pub count: u32, // số item returned, KHÔNG phải total toàn hệ thống
}
4 lock decision DTO:
statusOption<String> — khớp 5 value state machine lock B54 (pending/paid/shipped/delivered/cancelled); validate ở service layer khi map sang SQL (tránh SQL injection qua raw string). Có thể nâng cấp thành enumOrderStatusB73, B67 giữ String đơn giản.user_idOption<UserId> — chỉ effective trong admin context; endpoint user self/users/me/orderssẽ auto injectcurrent_user.idoverride field này. B112 thêm gate authorization.- Date range + total range —
from_date/to_datedùngDateTime<Utc>wire format RFC 3339 (2026-06-15T00:00:00Z) lock JSON Format Policy B6;min_total/max_totaldùngDecimalwire format JSON string (lock B44serde-with-str) tránh JS lose precision. limitu32 default 20 + validate range 1-100 — 20 hợp lệ default UI 1 page, cap 100 chống abuse client request?limit=10000kéo cả bảng; quá 100 phía client phải gọi nhiều round.
OrderListResponse 4 field — chú ý KHÔNG có field total: cursor pagination không quan tâm total vì user không cần biết "còn bao nhiêu", chỉ cần "có còn để tải thêm" (has_next). Lock decision khác biệt rõ rệt vs ProductListResponse envelope 5 field offset pagination (B47):
Offset pagination (B47): items + total + page + per_page + total_pages
Cursor pagination (B67): items + next_cursor + has_next + count
Cập nhật re-export module:
// File: crates/shop-common/src/dto/mod.rs (extend)
pub use order::{
CreateOrderDto, CreateOrderItemDto, OrderResponseDto,
OrderItemResponseDto, PaymentResponseDto,
OrderListQuery, OrderListResponse, // B67 thêm
};
Cursor Encode/Decode Helper
Tạo file mới crates/shop-api/src/cursor.rs đóng gói struct OrderCursor + 2 method encode/decode:
// File: crates/shop-api/src/cursor.rs
use base64::{engine::general_purpose, Engine};
use chrono::{DateTime, Utc};
use shop_common::error::AppError;
#[derive(Debug, Clone, Copy)]
pub struct OrderCursor {
pub created_at: DateTime<Utc>,
pub order_id: i64,
}
impl OrderCursor {
pub fn encode(&self) -> String {
let raw = format!("{}_{}", self.created_at.to_rfc3339(), self.order_id);
general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
pub fn decode(encoded: &str) -> Result<Self, AppError> {
let bytes = general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| AppError::BadRequest("invalid cursor format".into()))?;
let raw = std::str::from_utf8(&bytes)
.map_err(|_| AppError::BadRequest("invalid cursor utf8".into()))?;
let (date_str, id_str) = raw
.rsplit_once('_')
.ok_or_else(|| AppError::BadRequest("invalid cursor structure".into()))?;
let created_at = DateTime::parse_from_rfc3339(date_str)
.map_err(|_| AppError::BadRequest("invalid cursor date".into()))?
.with_timezone(&Utc);
let order_id = id_str.parse::<i64>()
.map_err(|_| AppError::BadRequest("invalid cursor id".into()))?;
Ok(Self { created_at, order_id })
}
}
4 chi tiết lock vĩnh viễn:
URL_SAFE_NO_PADbase64 — biến thể URL-safe thay+/=bằng-_+ bỏ padding=ở cuối; an toàn nhúng thẳng vào query param HTTP không cần url-encode lần 2 (raw=trong query string có thể nhầm với key=value delimiter ở vài server cũ). Stripe/GitHub đều dùng URL-safe no-pad cho cursor query.- Format
<rfc3339>_<id>— RFC 3339 timestamp chứa ký tự:và+không xung đột vì đã đi qua base64;"_"separator chọn vì không xuất hiện trong RFC 3339 (chỉ có-/:/./+/T/Z);rsplit_once('_')đảm bảo chỉ tách lần cuối, an toàn nếu future thêm prefix. - Tuple
(created_at, order_id)đảm bảo uniqueness — 2 order khác nhau có thể trùngcreated_attới milli giây (rare nhưng có, đặc biệt khi seed test); kết hợp vớiidi64 unique tuyệt đối, cursor pointer chính xác 100%. AppError::BadRequest4 case — invalid base64, invalid utf8, invalid structure (thiếu_), invalid date/id — bubble lên handler trả 400 envelope chuẩn (lock B16). Client gửi cursor sai → 400 ngay, KHÔNG fallback silent (tránh log nhiễu).
Workspace dep thêm base64 trong Cargo.toml root:
[workspace.dependencies]
# ... các dep B10-B66 đã có ...
base64 = "0.22"
crates/shop-api/Cargo.toml consume base64.workspace = true. Phiên bản 0.22 stable từ 2024 hỗ trợ Engine trait + 3 engine có sẵn STANDARD/URL_SAFE/URL_SAFE_NO_PAD; KHÔNG dùng base64::encode top-level legacy đã deprecated từ 0.21.
Update crates/shop-api/src/lib.rs (hoặc main.rs nếu chưa tách lib) thêm pub mod cursor; để module hiện diện trong crate tree.
list_orders Function Trong shop-db — Batch Fetch N+1 Avoidance
Vấn đề N+1 nếu code naive: handler fetch list 20 order → loop từng order gọi SELECT items WHERE order_id = $1 20 lần + SELECT payment WHERE order_id = $1 20 lần = 41 query (1 + 2N); với 100 order là 201 query, mỗi query mất ~5ms network round-trip = >1s response time. Pattern đúng: 3 query batch fetch toàn bộ N order ngay từ đầu.
Cập nhật crates/shop-db/src/orders.rs thêm OrderListFilter struct + 3 hàm:
// File: crates/shop-db/src/orders.rs (extend)
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use sqlx::{PgPool, QueryBuilder, Postgres};
use std::collections::HashMap;
pub struct OrderListFilter {
pub status: Option<String>,
pub user_id: Option<i64>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub min_total: Option<Decimal>,
pub max_total: Option<Decimal>,
pub cursor: Option<(DateTime<Utc>, i64)>, // (created_at, order_id)
pub limit: i64,
}
pub async fn list_orders(
pool: &PgPool,
filter: OrderListFilter,
) -> Result<Vec<OrderRow>, sqlx::Error> {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"SELECT id, user_id, total, status, created_at, updated_at \
FROM orders WHERE 1=1"
);
if let Some(status) = &filter.status {
qb.push(" AND status = ").push_bind(status.clone());
}
if let Some(uid) = filter.user_id {
qb.push(" AND user_id = ").push_bind(uid);
}
if let Some(from) = filter.from_date {
qb.push(" AND created_at >= ").push_bind(from);
}
if let Some(to) = filter.to_date {
qb.push(" AND created_at <= ").push_bind(to);
}
if let Some(min) = filter.min_total {
qb.push(" AND total >= ").push_bind(min);
}
if let Some(max) = filter.max_total {
qb.push(" AND total <= ").push_bind(max);
}
// Cursor seek qua row comparison Postgres
if let Some((cur_date, cur_id)) = filter.cursor {
qb.push(" AND (created_at, id) < (");
qb.push_bind(cur_date);
qb.push(", ");
qb.push_bind(cur_id);
qb.push(")");
}
qb.push(" ORDER BY created_at DESC, id DESC LIMIT ");
qb.push_bind(filter.limit + 1); // +1 detect has_next
qb.build_query_as::<OrderRow>().fetch_all(pool).await
}
3 chi tiết quan trọng QueryBuilder:
WHERE 1=1placeholder cho phép mọi nhánh filterif let Some(...)đềupush(" AND ...")không cần logic kiểm tra "filter đầu tiên thì WHERE, sau đó AND".1=1luôn true, Postgres planner tối ưu đi không phát sinh chi phí.(created_at, id) < ($1, $2)row comparison Postgres — cú pháp SQL chuẩn so sánh tuple theo lexicographic order:(a, b) < (c, d)tương đươnga < c OR (a = c AND b < d). Cursor seek đúng vị trí strict less than tránh trùng row cuối page trước; composite index(created_at DESC, id DESC)match query order plan giúp Postgres seek O(log n) thay vì scan.LIMIT $N + 1trick — fetch hơn 1 row so vớilimitclient yêu cầu; nếu nhận đúngN + 1row → còn data (has_next = true) → truncate vềNtrước trả response; nếu nhận≤ Nrow → hết data (has_next = false). Tiết kiệm 1 query so với cách "1 fetch + 1 SELECT COUNT" — tuy phải fetch thêm 1 row body nhỏ (~50 byte), tổng cost nhẹ hơn nhiều so với 1 round-trip mạng + COUNT scan trên bảng lớn.
Hai hàm batch fetch còn lại — pattern WHERE col = ANY($1) Postgres + HashMap groupby Rust:
// File: crates/shop-db/src/orders.rs (tiếp)
pub struct OrderItemRow {
pub product_id: i64,
pub product_name: String,
pub product_slug: String,
pub quantity: i32,
pub unit_price: Decimal,
}
pub async fn fetch_items_for_orders(
pool: &PgPool,
order_ids: &[i64],
) -> Result<HashMap<i64, Vec<OrderItemRow>>, sqlx::Error> {
let rows = sqlx::query!(
r#"
SELECT oi.order_id, oi.product_id, oi.quantity, oi.unit_price,
p.name AS product_name, p.slug AS product_slug
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ANY($1)
ORDER BY oi.order_id, oi.id
"#,
order_ids
)
.fetch_all(pool)
.await?;
let mut map: HashMap<i64, Vec<OrderItemRow>> = HashMap::new();
for r in rows {
map.entry(r.order_id).or_insert_with(Vec::new).push(OrderItemRow {
product_id: r.product_id,
product_name: r.product_name,
product_slug: r.product_slug,
quantity: r.quantity,
unit_price: r.unit_price,
});
}
Ok(map)
}
pub async fn fetch_payments_for_orders(
pool: &PgPool,
order_ids: &[i64],
) -> Result<HashMap<i64, PaymentRow>, sqlx::Error> {
let rows = sqlx::query_as!(
PaymentRow,
r#"
SELECT id, order_id, payment_type,
payment_payload as "payment_payload: serde_json::Value",
status, created_at
FROM payments
WHERE order_id = ANY($1)
"#,
order_ids
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|p| (p.order_id, p)).collect())
}
Tổng số query với N order: 3 query cố định — list_orders (1) + fetch_items_for_orders (1, batch toàn bộ items) + fetch_payments_for_orders (1, batch toàn bộ payments). So với anti-pattern 1 + 2N: với N=100, 3 query vs 201 query — chênh ~50× round-trip. Pattern WHERE col = ANY($1) Postgres nhận BIGINT[] bind từ Rust &[i64]; planner dùng = ANY tương đương IN ($1, $2, ...) nhưng KHÔNG generate dynamic SQL phụ thuộc N — 1 prepared statement reuse mọi N.
HashMap groupby Rust — sau khi fetch flat list (mọi order_item của mọi order trộn lẫn), gom về map order_id → Vec<OrderItemRow> bằng map.entry(key).or_insert_with(Vec::new).push(value). Pattern này lock vĩnh viễn cho mọi batch fetch related entity Shop API (B68 detail JOIN address, B71 payment lookup, G14 admin analytics drill-down).
Handler list_orders Với Batch + Cursor
Handler thin pattern 4 step (decode cursor → build filter → 3 query batch → build response DTOs). Cập nhật crates/shop-api/src/routes/orders.rs:
// File: crates/shop-api/src/routes/orders.rs (extend)
use axum::{extract::State, Json};
use rust_decimal::Decimal;
use shop_common::dto::{
Money, OrderId, OrderItemResponseDto, OrderListQuery, OrderListResponse,
OrderResponseDto, PaymentResponseDto, ProductId, UserId,
};
use shop_common::error::AppError;
use shop_db::orders as db;
use crate::cursor::OrderCursor;
use crate::extractors::AppQuery;
use crate::state::AppState;
pub async fn list_orders(
State(state): State<AppState>,
AppQuery(query): AppQuery<OrderListQuery>,
) -> Result<Json<OrderListResponse>, AppError> {
query.validate()?;
// Step 1: decode cursor (nếu có)
let cursor = match &query.cursor {
Some(c) => {
let parsed = OrderCursor::decode(c)?;
Some((parsed.created_at, parsed.order_id))
}
None => None,
};
// Step 2: build filter
let filter = db::OrderListFilter {
status: query.status,
user_id: query.user_id.map(|u| u.0 as i64),
from_date: query.from_date,
to_date: query.to_date,
min_total: query.min_total,
max_total: query.max_total,
cursor,
limit: query.limit as i64,
};
// Step 3: fetch orders + detect has_next + truncate
let mut orders = db::list_orders(&state.db, filter).await?;
let has_next = orders.len() > query.limit as usize;
if has_next {
orders.truncate(query.limit as usize);
}
if orders.is_empty() {
return Ok(Json(OrderListResponse {
items: vec![],
next_cursor: None,
has_next: false,
count: 0,
}));
}
// Step 4: batch fetch items + payments
let order_ids: Vec<i64> = orders.iter().map(|o| o.id).collect();
let items_map = db::fetch_items_for_orders(&state.db, &order_ids).await?;
let payments_map = db::fetch_payments_for_orders(&state.db, &order_ids).await?;
// Step 5: build response DTOs
let items: Vec<OrderResponseDto> = orders.iter().map(|order| {
let order_items = items_map.get(&order.id).cloned().unwrap_or_default();
let payment = payments_map.get(&order.id);
let items_dto: Vec<OrderItemResponseDto> = order_items.into_iter().map(|i| {
let qty = i.quantity as u32;
OrderItemResponseDto {
product_id: ProductId(i.product_id as u64),
product_name: i.product_name,
product_slug: i.product_slug,
quantity: qty,
unit_price: Money(i.unit_price),
subtotal: Money(i.unit_price * Decimal::from(qty)),
}
}).collect();
OrderResponseDto {
id: OrderId(order.id as u64),
user_id: UserId(order.user_id as u64),
total: Money(order.total),
status: order.status.clone(),
items: items_dto,
payment: payment.map(|p| PaymentResponseDto {
payment_type: p.payment_type.clone(),
status: p.status.clone(),
payload_safe: super::sanitize_payment_payload(&p.payment_payload),
}).unwrap_or_else(|| PaymentResponseDto {
payment_type: "unknown".into(),
status: "unknown".into(),
payload_safe: serde_json::Value::Null,
}),
note: None,
created_at: order.created_at,
updated_at: order.updated_at,
}
}).collect();
// Step 6: build next_cursor từ order cuối list
let next_cursor = if has_next {
orders.last().map(|o| OrderCursor {
created_at: o.created_at,
order_id: o.id,
}.encode())
} else {
None
};
let count = items.len() as u32;
Ok(Json(OrderListResponse {
items,
next_cursor,
has_next,
count,
}))
}
Wire route trong function routes() đã có từ B66 — chain method GET + POST cùng path /orders, middleware Idempotency apply selective chỉ cho POST:
// File: crates/shop-api/src/routes/orders.rs (cập nhật routes() B66)
pub fn routes(state: AppState) -> Router<AppState> {
// POST /orders riêng layer Idempotency (Stripe pattern selective)
let post_orders = Router::new()
.route("/orders", post(create_order))
.layer(from_fn_with_state(
state.clone(),
crate::middleware::idempotency::idempotency_middleware,
));
// GET /orders + cancel route KHÔNG cần Idempotency
let other_routes = Router::new()
.route("/orders", get(list_orders)) // B67 mới
.route("/orders/{id}/cancel", post(cancel_order)); // B65
post_orders.merge(other_routes)
}
2 lock decision wiring:
- 2 sub-router merge —
POST /orderstách riêng để ápidempotency_middlewareselective (B66 lock);GET /orders+POST /orders/{id}/cancelKHÔNG cần idempotency (lock B66 đã giải thích). Axum merge 2 router có cùng path/ordersnhưng khác method router — combine thành 1 MethodRouter chứa cảGET+POST. sanitize_payment_payloadreuse quasuper::sanitize_payment_payload— hàm đã định nghĩa B66 trong cùng fileroutes/orders.rs, gọi quasuper::vì handler ở scope con (nếu tách thêm module). Pattern PII sanitize MANDATORY trước expose payment payload (lock B66) tự động áp dụng cho list endpoint không cần copy-paste logic.
Endpoint User-Scoped Vs Admin
REST resource design pattern cho 2 use case khác nhau cùng entity orders:
User self → GET /api/v1/users/me/orders
(auto inject current_user.id, user_id query param ignored)
Admin global → GET /api/v1/admin/orders?user_id=123
(filter user_id allowed, scope toàn hệ thống)
B67 hiện chưa có auth (B112 mới implement JWT extractor + CurrentUser) — endpoint duy nhất GET /api/v1/orders cho phép user_id query param effective (admin pattern). Khi G12 hoàn thành auth, refactor 2 endpoint:
// Preview G12 — KHÔNG implement ở B67
// Admin endpoint (gate role check qua middleware require_admin)
pub async fn list_orders_admin(
State(state): State<AppState>,
_admin: RequireAdmin, // B135 lock
AppQuery(query): AppQuery<OrderListQuery>,
) -> Result<Json<OrderListResponse>, AppError> {
// user_id filter EFFECTIVE — admin context
list_orders_impl(state.db, query).await
}
// User self endpoint (auto inject current_user.id)
pub async fn list_my_orders(
State(state): State<AppState>,
current_user: CurrentUser, // B112 lock
AppQuery(mut query): AppQuery<OrderListQuery>,
) -> Result<Json<OrderListResponse>, AppError> {
// OVERRIDE user_id — user self chỉ thấy orders của mình
query.user_id = Some(UserId(current_user.id as u64));
list_orders_impl(state.db, query).await
}
Lock decision Shop API vĩnh viễn:
/api/v1/users/me/orders— user self orders, auto injectcurrent_user.idoverride mọiuser_idquery param client cố tình gửi (tránh user A spy orders user B qua query manipulation)./api/v1/admin/orders— admin global view, gate qua middlewarerequire_admin(B135),user_idfilter effective + 6 filter còn lại + cursor pagination cùng cấu trúcOrderListResponse.- Implementation pattern: tách logic core thành hàm
list_orders_impl(db, query) -> OrderListResponsereuse cả 2 endpoint; mỗi handler chỉ làm thêm step gate role + inject scopeuser_id. DRY tối đa, test cũng dễ — test core function 1 lần với mọi filter, test handler chỉ verify gate role + inject scope. - B67 placeholder: endpoint
GET /api/v1/orderstạm thời cho phépuser_idquery param effective (admin pattern); B112 sẽ remove endpoint này thay bằng 2 endpoint phân biệt rõ ràng. Code logic không thay đổi nhiều, chỉ thêm gate auth + inject.
Cùng pattern áp dụng cho mọi resource Shop API tương lai có 2 scope user/admin: /users/me/cart vs /admin/carts, /users/me/payments vs /admin/payments, /users/me/reviews vs /admin/reviews.
Verify End-To-End
Setup 5 order seed trước khi test (reuse POST /orders B66):
for i in {1..5}; do
curl -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"items": [{"product_id": 1, "quantity": 2}],
"payment_method": {"type": "cod", "phone": "+84912345678"}
}'
sleep 1
done
# → 5 order tạo với created_at cách nhau ~1s
Test 1 — List page đầu (limit=3):
curl 'http://localhost:3000/api/v1/orders?limit=3' | jq
# {
# "items": [
# { "id": 5, "user_id": 1, "total": "50000000.00", ... },
# { "id": 4, ... },
# { "id": 3, ... }
# ],
# "next_cursor": "MjAyNi0wNi0xNVQxMDowMDowMyswMDowMF8z",
# "has_next": true,
# "count": 3
# }
Test 2 — Follow cursor lấy page tiếp:
curl 'http://localhost:3000/api/v1/orders?limit=3&cursor=MjAyNi0wNi0xNVQxMDowMDowMyswMDowMF8z' | jq
# {
# "items": [
# { "id": 2, ... },
# { "id": 1, ... }
# ],
# "has_next": false,
# "count": 2
# }
# next_cursor bị bỏ vì skip_serializing_if Option::is_none
Test 3 — Filter status:
curl 'http://localhost:3000/api/v1/orders?status=cancelled' | jq '.count'
# 0 (chưa có order nào cancelled)
# Cancel order 1 qua B65 endpoint
curl -X POST http://localhost:3000/api/v1/orders/1/cancel
curl 'http://localhost:3000/api/v1/orders?status=cancelled' | jq '.items[0].id'
# 1
Test 4 — Filter date range:
curl 'http://localhost:3000/api/v1/orders?from_date=2026-06-15T00:00:00Z&to_date=2026-06-16T00:00:00Z' | jq '.count'
# 5 (tất cả order trong ngày 15/6)
curl 'http://localhost:3000/api/v1/orders?from_date=2026-06-14T00:00:00Z&to_date=2026-06-14T23:59:59Z' | jq '.count'
# 0 (không có order ngày 14/6)
Test 5 — Filter total range:
curl 'http://localhost:3000/api/v1/orders?min_total=1000000&max_total=50000000' | jq '.count'
# 5 (tất cả 5 đơn 50,000,000đ nằm trong range)
curl 'http://localhost:3000/api/v1/orders?min_total=60000000' | jq '.count'
# 0 (không có đơn nào trên 60 triệu)
Test 6 — Invalid cursor:
curl -i 'http://localhost:3000/api/v1/orders?cursor=invalidbase64@#$'
# HTTP/1.1 400 Bad Request
# {
# "error": "invalid cursor format",
# "code": "BAD_REQUEST",
# "request_id": "..."
# }
curl -i 'http://localhost:3000/api/v1/orders?cursor=aGVsbG8' # valid base64 "hello"
# HTTP/1.1 400 Bad Request
# {
# "error": "invalid cursor structure",
# "code": "BAD_REQUEST",
# ...
# }
Verify N+1 avoidance qua tracing:
RUST_LOG=sqlx=debug cargo run -p shop-api -- serve
# Trong log thấy 3 query khi gọi GET /orders với 5 result:
#
# sqlx::query: SELECT id, user_id, total, status, created_at, updated_at
# FROM orders WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT $1
#
# sqlx::query: SELECT oi.order_id, oi.product_id, ..., p.name AS product_name, p.slug
# FROM order_items oi JOIN products p ON p.id = oi.product_id
# WHERE oi.order_id = ANY($1) ORDER BY oi.order_id, oi.id
#
# sqlx::query: SELECT id, order_id, payment_type, ... FROM payments
# WHERE order_id = ANY($1)
#
# 3 query — KHÔNG phải 11 query (1 + 2*5) như pattern N+1 anti-pattern
Tổng response time với 5 order trên local Postgres: ~15ms (3 query × ~5ms). Cùng dataset, nếu N+1 anti-pattern: ~55ms (11 query × ~5ms) — chênh 3.7×. Với 100 order: ~15ms vs ~1s — chênh 67×.
Tổng Kết
- Cursor pagination cho orders endpoint — performance constant với composite index seek, stable scroll UX khi data thay đổi.
- Cursor format:
base64(<rfc3339_timestamp>_<order_id>)URL-safe no-pad — không có ký tự+/=phải url-encode. (created_at, id) < ($1, $2)row comparison Postgres — cursor seek O(log n) với composite index(created_at DESC, id DESC).OrderListQuery8 field: 6 filter (status + user_id + from_date + to_date + min_total + max_total) + cursor + limit; KHÔNG có total count.- 3 query pattern N+1 avoidance:
list_orders+fetch_items_for_ordersbatch +fetch_payments_for_ordersbatch — thay1 + 2Nanti-pattern. WHERE order_id = ANY($1)batch lookup Postgres — 1 prepared statement reuse mọi N, KHÔNG generate dynamic SQL phụ thuộc N.LIMIT $N + 1trick detecthas_next— 1 query thay 2 query (1 fetch + 1 count), truncate trước trả response.- HashMap groupby Rust —
map.entry(key).or_insert_with(Vec::new).push(value)gom flat list vềorder_id → Vec<items>. - Helper
OrderCursor::encode/decode+URL_SAFE_NO_PADbase64 + RFC 3339 timestamp +"_"separator + 4 error case BadRequest. - User-scoped vs admin endpoint lock:
/users/me/ordersauto injectcurrent_user.idvs/admin/ordersfilteruser_idallowed; B112 enforce auth gate. - 6 filter Shop API: status (state machine), user_id (admin scope), from_date/to_date (date range), min_total/max_total (revenue analytics).
- Cursor pagination KHÔNG include total count — đặc tính cursor: user không cần biết "còn bao nhiêu", chỉ cần
has_next. - Reuse
sanitize_payment_payloadB66 trong list endpoint — PII sanitize tự động áp dụng cho mọi payment response. - File path lock: NEW
crates/shop-api/src/cursor.rs+ extendcrates/shop-db/src/orders.rs+ extendcrates/shop-api/src/routes/orders.rs+ extendcrates/shop-common/src/dto/order.rs; workspace dep mớibase64 = "0.22".
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Cursor pagination — tại sao tốt hơn offset với data 1M row? Cho ví dụ EXPLAIN cụ thể chỉ rõ Postgres planner làm gì khác nhau giữa offset 10000 và cursor seek.
(created_at, id) < (cur_date, cur_id)row comparison — pitfall index nếu chỉ có index 1 cộtcreated_at? Solution composite index + matching ORDER BY.- Batch fetch items + payments — pattern
WHERE = ANY($1)Postgres + HashMap groupby Rust. Cho ví dụ scenario 100 order: tính tổng query thay tổng round-trip vs tổng response time, so sánh với N+1 anti-pattern. LIMIT + 1trick detecthas_next— pros/cons so với 2 query (1 fetch + 1SELECT COUNT(*))? Khi nào nên dùng COUNT thay trick?- Cursor opaque base64 — tại sao encode thay raw timestamp + id? Tốt cho bảo mật/extensibility cụ thể như nào?
Đáp án
- Cursor vs offset trên 1M row + EXPLAIN cụ thể: Offset 10000 LIMIT 20: Postgres planner sinh kế hoạch
Limit (cost=... rows=20) → Index Scan Backward on orders_created_at_idx. Index scan đọc từ trang index tới row 10001 → skip 10000 row đầu (mỗi row vẫn fetch từ heap để kiểm tra index visibility map, không tránh được do MVCC) → trả 20 row tiếp. Cost ~10010 page fetch. EXPLAIN ANALYZE actual time ~200-500ms với data nóng cache, ~1-2s khi cold cache; latency tăng tuyến tính với offset value. Cursor seek(created_at, id) < ($1, $2)LIMIT 20: planner sinhLimit (cost=... rows=20) → Index Scan Backward on orders_created_at_id_idx Index Cond: ((created_at, id) < (...)). Index B-tree composite(created_at DESC, id DESC)seek thẳng vị trí key < cursor → trả 20 row liên tiếp. Cost ~24 page fetch (8 page index + 16 page heap đại diện 20 row). EXPLAIN ANALYZE actual time ~5-10ms constant không phụ thuộc cursor sâu bao nhiêu. Cốt lõi: offset không tránh được scan + skip, cursor là pure seek; index seek O(log n), index scan + skip O(offset). Với offset 100000 (page 5000) trên 1M row, offset query ~5-10s — UX hỏng hoàn toàn; cursor query vẫn 5ms. Lock Shop API: cursor MANDATORY cho list endpoint dự đoán data > 100k row (orders, audit_logs, notifications, payment_logs); offset OK cho list ≤ 10k row (products khoảng 10-100k SKU, categories 100-1000 node) + UI cần page jump random. Generalize: rule of thumb production — bất kỳ list endpoint có ORDER BY + LIMIT trên bảng tăng trưởng tuyến tính theo thời gian (orders, logs, messages, events) đều dùng cursor; bảng catalog tĩnh + UI cần page jump dùng offset. - Row comparison pitfall index 1 cột + composite + ORDER BY matching: Pitfall: nếu chỉ có index
CREATE INDEX ON orders(created_at DESC)đơn cột, queryWHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20sẽ KHÔNG dùng được index seek hiệu quả cho row comparison nguyên cú pháp tuple. Postgres planner thường rewrite vềWHERE created_at < $1 OR (created_at = $1 AND id < $2)— sau đó scan tất cả row cócreated_at < $1+ filter thêm bằngid. Khi cursor rơi vào nhóm timestamp cluster (10 đơn cùng giây), planner phải fetch 10 row + sort theoid→ cost lặt vặt. Solution composite index:CREATE INDEX orders_created_at_id_idx ON orders(created_at DESC, id DESC). Postgres B-tree composite index lưu key theo lexicographic order — seek(created_at, id) < ($1, $2)đi thẳng tới điểm < tuple trong tree (single index scan operation), trả ngay 20 row tiếp theo từ tree mà KHÔNG cần re-sort. ORDER BY phải khớp index order:ORDER BY created_at DESC, id DESCmatch exact thứ tự cột + direction index — Postgres skip sort step (cost giảm thêm). Nếu viếtORDER BY created_at DESC, id ASC(mix direction), planner phải sort lại — mất lợi thế index. Hệ quả: 2 thành phần MANDATORY phải đi đôi: (a) composite index(created_at DESC, id DESC); (b) ORDER BY khớp exact. Thiếu 1 trong 2 → performance degrade về linear scan. Verify: EXPLAIN ANALYZE phải thấyIndex Scan Backward using orders_created_at_id_idxkhông có nodeSortđi kèm. Lock Shop API: migration B54 đã tạoorders_created_at_idx DESCđơn cột — cần migration mới (B68 sẽ thêm) tạo composite indexorders(created_at DESC, id DESC)cho cursor pagination optimal. Generalize: mọi cursor pagination Shop API tương lai (payments, notifications, audit_logs) MANDATORY composite index(sort_col DESC, id DESC)+ ORDER BY khớp exact — không có ngoại lệ. - Pattern
WHERE = ANY($1)+ HashMap groupby + scenario 100 order: Pattern:SELECT ... FROM order_items WHERE order_id = ANY($1::BIGINT[])nhận Rust&[i64]bind quasqlx::query!(..., order_ids)— Postgres planner xử lý array bằng= ANYtương đươngIN ($1, $2, ..., $N)nhưng đặc biệt hơn: dùng 1 prepared statement duy nhất cho mọi N, KHÔNG cache miss khi N thay đổi (IN list dynamic gây prepared statement explosion). Postgres planner pick chiến lược dựa N: nhỏ (≤ 100) hash lookup, lớn (> 1000) merge join hoặc bitmap scan. HashMap groupby Rust: sau khifetch_alltrảVec<OrderItemRow>flat (items của mọi order trộn lẫn), gom vềHashMap<i64, Vec<OrderItemRow>>bằngfor r in rows { map.entry(r.order_id).or_insert_with(Vec::new).push(...) }.entry().or_insert_with()idiomatic Rust tránh 2 lần lookup (1 lần check exist + 1 lần insert) — đặt entry, nếu không tồn tại tạo Vec rỗng, return mutable ref để push value. Complexity O(N) total với N = total items. Scenario 100 order × trung bình 5 item/order = 500 items + 100 payment: N+1 anti-pattern: 1 query list orders + 100 query fetch items + 100 query fetch payments = 201 query. Mỗi query ~5ms network round-trip local Postgres → ~1005ms (UX hỏng). Quan trọng hơn: 200 connection acquire/release từ pool (max 20 connection lock B51), 180 query phải queue → contention thực tế ~3-5s. 3 query batch pattern: 3 query total. Query 1 list 100 order ~10ms. Query 2 batch items ~30ms (fetch 500 row + index lookup). Query 3 batch payments ~10ms. HashMap groupby Rust ~1ms (500 entry insert). Total ~50ms — speedup 60× thực tế, ~20× absolute thấy được. Lock Shop API: 3 query batch pattern MANDATORY cho mọi list endpoint kèm related entity (B68 detail thêm address JOIN, B71 payment list reuse pattern, G14 admin dashboard drill-down 5+ related entity); pattern Vec → HashMap groupby reuse helper functiongroup_by_keynếu cần — B67 inline đơn giản, B68+ có thể extract. Generalize: nguyên tắc "batch fetch + groupby in-memory" áp dụng cho mọi ORM/data layer (Rails preload, Django prefetch_related, GraphQL DataLoader pattern); ngôn ngữ khác nhau, ý tưởng identical — gom N+1 query thành 2 query qua batch lookup. LIMIT + 1trick vs 2 query (fetch + COUNT) — pros/cons + khi nào dùng COUNT:LIMIT + 1trick: fetchlimit + 1row, nếu nhận đúnglimit + 1→has_next = true+ truncate vềlimit; nếu nhận ≤limit→has_next = false. 1 query total. Pros: (a) 1 round-trip thay 2 — tiết kiệm ~5-10ms latency mạng (đáng kể trên mạng cross-region); (b) consistency atomic — fetch + has_next từ cùng snapshot, không có race condition data chèn vào giữa 2 query; (c) tránh COUNT scan đắt trên bảng lớn —SELECT COUNT(*) FROM orders WHERE ...trên 1M row tốn ~500ms-2s do MVCC visibility check toàn bảng. Cons: (a) fetch thêm 1 row body — overhead network bytes ~50-200 byte (negligible cho most cases); (b) KHÔNG biết tổng số match — nếu UI cần "found 1247 results" phải query COUNT riêng. 2 query (fetch + COUNT): query 1 fetchLIMIT N, query 2SELECT COUNT(*) WHERE same_filter. Pros: biết exact total, hiển thị "trang 5/63" + "1247 results found"; UI offset pagination kiểu Google search cần. Cons: (a) 2 round-trip; (b) race condition tiềm năng total và items không cùng snapshot (insert mới giữa 2 query); (c) COUNT scan đắt bảng lớn — Postgres MVCC phải check visibility từng row không có shortcut. Khi nào dùng COUNT: (a) offset pagination kiểu Google/Stack Overflow cần "trang N/M" + "found X results" UX (B47ProductListResponse5 field cótotal+total_pages); (b) bảng vừa phải ≤ 100k row (COUNT ~50-100ms acceptable); (c) UI cần aggregate analytics "tổng đơn hàng tháng 6: 1247". Khi nào dùng LIMIT + 1 trick: (a) cursor pagination + infinite scroll UX không cần total; (b) bảng lớn ≥ 1M row COUNT đắt không acceptable; (c) endpoint mobile bandwidth-sensitive cần tối ưu round-trip. Lock Shop API: cursor pagination B67 dùngLIMIT + 1trick MANDATORY; offset pagination products B47 dùng 2 query (fetch + COUNT) vì products ≤ 100k SKU + UI hiển thị total cần thiết; admin analytics G14 đặc biệt có thể dùng materialized view + cached COUNT. Generalize: chọn dựa UX requirement — UX cần total cụ thể → COUNT; UX không cần → trick. Tránh COUNT trên bảng > 1M row trừ khi có index materialized view (Postgres pg_class.reltuples approximation hoặc lưu cached count update qua trigger).- Cursor opaque base64 vs raw timestamp + id — bảo mật/extensibility: Tại sao encode opaque thay raw:
?cursor=2026-06-15T10:00:00Z_123raw vs?cursor=MjAyNi0wNi0xNVQxMDowMDowMFpfMTIzbase64 — cả 2 đều decode được, opaque KHÔNG bảo mật cryptographic, nhưng có 4 lợi ích thiết kế. Lợi ích 1 — URL encoding clean: raw cursor chứa:,+,-phải url-encode trong query string thành%3A,%2B— xấu cho debugger + log + share URL; base64 URL-safe no-pad chỉ dùngA-Z a-z 0-9 - _không cần encode. Lợi ích 2 — client không infer logic: client thấy raw timestamp + id dễ "đoán" cách parse + tự construct cursor bypassing server. Ví dụ frontend dev "tự ý" build cursor2026-01-01T00:00:00Z_999999để jump nhanh tới page xa — phá vỡ contract pagination, có thể return data không nhất quán + leak data filter. Opaque base64 ngầm thông báo: "cursor là blob server-provided, đừng tự tạo, chỉ truyền lại nguyên xi". Stripe/GitHub đều opaque encode cursor cùng lý do. Lợi ích 3 — extensibility format internal: tương lai cần thêm field vào cursor (ví dụ B68 muốn cursor includesort_directionđể toggle ASC/DESC giữa scroll, hoặc B71 thêmshard_idcho sharded DB) — chỉ cần đổi format internal"created_at_id_extra"hoặc switch sang protobuf binary inside base64; client KHÔNG cần thay đổi vì coi cursor là blob. Nếu cursor raw, mỗi lần đổi format = breaking change API. Lợi ích 4 — versioning rollout dễ dàng: thêm prefix version vào cursor"v2:<data>"trong format internal; server detect prefix → decode đúng format; cursor v1 cũ vẫn hoạt động đến khi expire (TTL cursor implicit ~24h dùng sau scroll session). Migration cursor format zero-downtime. Tại sao KHÔNG dùng signed/encrypted cursor: HMAC sign cursor (như JWT) chống tamper — overkill cho cursor pagination (worst case tamper = page trật, không leak data nếu filter scope đúng). Encrypt cursor (chacha20) hoàn toàn opaque không reverse — phù hợp khi cursor chứa thông tin sensitive (vd encode hidden filter); B67 KHÔNG cần vì cursor chỉ chứa public timestamp + id. Lock Shop API: cursor pagination MANDATORY base64 URL-safe no-pad encode raw text format; KHÔNG signed/encrypted ở B67 (over-engineering); G19+ enterprise có thể nâng cấp signed cursor nếu cần multi-tenancy isolation cứng. Generalize: opaque encoding là "interface convention" thay "data structure" — server reserve quyền thay đổi internal format mà không phá vỡ client; pattern này rộng rãi cho mọi token API (cursor, refresh token, OAuth state, idempotency key).
Bài Tiếp Theo
Bài 68: GET /orders/{id} + State Detail — implement GET /api/v1/orders/{id} với full detail (items + payment + audit timeline), state transition history, PATCH limited cho status update (cancel/refund), authorization preview (user chỉ thấy orders mình, admin thấy all).
