Danh sách bài viết

Bài 67: GET /orders List + Filter — Cursor Pagination + N+1 Avoidance

Bài 67 của series Rust RESTful API — bài CODE thực tế nối tiếp B66 (POST /api/v1/orders wire create_order_atomic + Idempotency middleware + 5 DTO + sanitize PII): triển khai GET /api/v1/orders endpoint với cursor pagination base64 URL-safe no-pad format base64(rfc3339_timestamp + "_" + order_id) tránh offset chậm khi list lớn, 6 filter (status, user_id, from_date, to_date, min_total, max_total) qua QueryBuilder<Postgres> dynamic SQL B59 continued, và pattern N+1 avoidance 3 query batch fetch thay vì 1 + 2N query anti-pattern; tạo mới crates/shop-api/src/cursor.rs chứa OrderCursor { created_at, order_id } với encode()/decode() qua base64::engine::general_purpose::URL_SAFE_NO_PAD + chrono::DateTime::parse_from_rfc3339; extend crates/shop-db/src/orders.rs với OrderListFilter struct 8 field + list_orders(pool, filter) dùng QueryBuilder::new("SELECT ... WHERE 1=1") + push_bind per-filter + (created_at, id) < ($1, $2) row comparison Postgres cursor seek + ORDER BY created_at DESC, id DESC LIMIT $N + 1 trick detect has_next + fetch_items_for_orders(pool, &[i64]) -> HashMap<i64, Vec<OrderItemRow>> batch lookup qua WHERE order_id = ANY($1) Postgres + HashMap groupby Rust + fetch_payments_for_orders(pool, &[i64]) -> HashMap<i64, PaymentRow> batch 1:1; extend crates/shop-api/src/routes/orders.rs NEW handler list_orders(State<AppState>, AppQuery<OrderListQuery>) -> Result<Json<OrderListResponse>, AppError> 4 step (decode cursor → build filter → 3 query batch → build response DTOs) + wire .route("/orders", get(list_orders).post(create_order)) chain method giữ Idempotency middleware chỉ POST; extend crates/shop-common/src/dto/order.rs NEW OrderListQuery 8 field (6 filter + cursor + limit validate range 1-100 default 20) + OrderListResponse 4 field (items + next_cursor + has_next + count KHÔNG include total) lock decision cursor pagination property; 3 query pattern N+1 avoidance lock vĩnh viễn — list parent endpoint MANDATORY batch fetch related qua WHERE = ANY($1) + HashMap groupby thay 1 + 2N anti-pattern (100 order = 1 + 200 query ~500ms vs 3 query ~15ms); cursor format lock vĩnh viễn base64::URL_SAFE_NO_PAD encoding (no +/= URL-safe) + RFC 3339 timestamp + "_" separator + order_id i64 đảm bảo uniqueness khi timestamp trùng; row comparison Postgres (col1, col2) < (val1, val2) lock cho cursor query stable across insert + composite index (created_at DESC, id DESC) match query order plan; limit + 1 trick detect has_next truncate trước response 1 query thay 2 query (1 fetch + 1 count); OrderListResponse KHÔNG include total count lock decision cursor pagination property (UI 95% offset có total B47 lock, list cursor scroll infinite KHÔNG cần total); user-scoped vs admin endpoint lock decision: /api/v1/users/me/orders auto inject current_user.id user self vs /api/v1/admin/orders admin filter user_id allowed (B112 enforce auth gate); 6 filter Shop API lock vĩnh viễn: status (state machine), user_id (admin scope), from_date/to_date (date range), min_total/max_total (revenue analytics); workspace dep mới base64 = "0.22" URL-safe encoding cho query param; foundation cho B68 (GET /orders/{id} detail + state transition history + PATCH limited cancel/refund), B71 (payment query reuse cursor), G14 (admin analytics dashboard /admin/orders + drill-down filter).

15/06/2026
13 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Implement GET /api/v1/orders endpoint với filter status/user_id/date range/total range — 6 filter qua QueryBuilder<Postgres> dynamic SQL (B59 continued).
  • Implement cursor pagination base64 URL-safe no-pad format base64(rfc3339_timestamp + "_" + order_id) — encode/decode helper module mới crates/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 OrderListQuery 8 field DTO (6 filter + cursor + limit) + OrderListResponse 4 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/orders vs 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).
2

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 orders dễ đạt 1M+ row toàn hệ thống. Offset OFFSET 10000 trê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_cursor opaque 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.

3

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:

  • status Option<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 enum OrderStatus B73, B67 giữ String đơn giản.
  • user_id Option<UserId> — chỉ effective trong admin context; endpoint user self /users/me/orders sẽ auto inject current_user.id override field này. B112 thêm gate authorization.
  • Date range + total rangefrom_date/to_date dùng DateTime<Utc> wire format RFC 3339 (2026-06-15T00:00:00Z) lock JSON Format Policy B6; min_total/max_total dùng Decimal wire format JSON string (lock B44 serde-with-str) tránh JS lose precision.
  • limit u32 default 20 + validate range 1-100 — 20 hợp lệ default UI 1 page, cap 100 chống abuse client request ?limit=10000 ké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
};
4

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_PAD base64 — 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ự :+ 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ùng created_at tới milli giây (rare nhưng có, đặc biệt khi seed test); kết hợp với id i64 unique tuyệt đối, cursor pointer chính xác 100%.
  • AppError::BadRequest 4 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.

5

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=1 placeholder cho phép mọi nhánh filter if let Some(...) đều push(" AND ...") không cần logic kiểm tra "filter đầu tiên thì WHERE, sau đó AND". 1=1 luô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 đương a < 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 + 1 trick — fetch hơn 1 row so với limit client yêu cầu; nếu nhận đúng N + 1 row → còn data (has_next = true) → truncate về N trước trả response; nếu nhận ≤ N row → 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ố địnhlist_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).

6

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 mergePOST /orders tách riêng để áp idempotency_middleware selective (B66 lock); GET /orders + POST /orders/{id}/cancel KHÔNG cần idempotency (lock B66 đã giải thích). Axum merge 2 router có cùng path /orders nhưng khác method router — combine thành 1 MethodRouter chứa cả GET + POST.
  • sanitize_payment_payload reuse qua super::sanitize_payment_payload — hàm đã định nghĩa B66 trong cùng file routes/orders.rs, gọi qua super:: 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.
7

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 inject current_user.id override mọi user_id query 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 middleware require_admin (B135), user_id filter effective + 6 filter còn lại + cursor pagination cùng cấu trúc OrderListResponse.
  • Implementation pattern: tách logic core thành hàm list_orders_impl(db, query) -> OrderListResponse reuse cả 2 endpoint; mỗi handler chỉ làm thêm step gate role + inject scope user_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/orders tạm thời cho phép user_id query 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.

8

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×.

9

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).
  • OrderListQuery 8 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_orders batch + fetch_payments_for_orders batch — thay 1 + 2N anti-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 + 1 trick detect has_next — 1 query thay 2 query (1 fetch + 1 count), truncate trước trả response.
  • HashMap groupby Rustmap.entry(key).or_insert_with(Vec::new).push(value) gom flat list về order_id → Vec<items>.
  • Helper OrderCursor::encode/decode + URL_SAFE_NO_PAD base64 + RFC 3339 timestamp + "_" separator + 4 error case BadRequest.
  • User-scoped vs admin endpoint lock: /users/me/orders auto inject current_user.id vs /admin/orders filter user_id allowed; 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_payload B66 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 + extend crates/shop-db/src/orders.rs + extend crates/shop-api/src/routes/orders.rs + extend crates/shop-common/src/dto/order.rs; workspace dep mới base64 = "0.22".
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. 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.
  2. (created_at, id) < (cur_date, cur_id) row comparison — pitfall index nếu chỉ có index 1 cột created_at? Solution composite index + matching ORDER BY.
  3. 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.
  4. LIMIT + 1 trick detect has_next — pros/cons so với 2 query (1 fetch + 1 SELECT COUNT(*))? Khi nào nên dùng COUNT thay trick?
  5. 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
  1. 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 sinh Limit (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.
  2. 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, query WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20 sẽ 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ằng id. Khi cursor rơi vào nhóm timestamp cluster (10 đơn cùng giây), planner phải fetch 10 row + sort theo id → 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 DESC match exact thứ tự cột + direction index — Postgres skip sort step (cost giảm thêm). Nếu viết ORDER 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ấy Index Scan Backward using orders_created_at_id_idx không có node Sort đi kèm. Lock Shop API: migration B54 đã tạo orders_created_at_idx DESC đơn cột — cần migration mới (B68 sẽ thêm) tạo composite index orders(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ệ.
  3. 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 qua sqlx::query!(..., order_ids) — Postgres planner xử lý array bằng = ANY tương đương IN ($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 khi fetch_all trả Vec<OrderItemRow> flat (items của mọi order trộn lẫn), gom về HashMap<i64, Vec<OrderItemRow>> bằng for 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 function group_by_key nế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.
  4. LIMIT + 1 trick vs 2 query (fetch + COUNT) — pros/cons + khi nào dùng COUNT: LIMIT + 1 trick: fetch limit + 1 row, nếu nhận đúng limit + 1has_next = true + truncate về limit; nếu nhận ≤ limithas_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 fetch LIMIT N, query 2 SELECT 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 (B47 ProductListResponse 5 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ùng LIMIT + 1 trick 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).
  5. 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_123 raw vs ?cursor=MjAyNi0wNi0xNVQxMDowMDowMFpfMTIz base64 — 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ùng A-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 cursor 2026-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 include sort_direction để toggle ASC/DESC giữa scroll, hoặc B71 thêm shard_id cho 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).
11

Bài Tiếp Theo

— 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).